Compare commits
No commits in common. "main" and "main" have entirely different histories.
110
.drone.yml
110
.drone.yml
|
@ -15,7 +15,6 @@ trigger:
|
|||
services:
|
||||
- name: api
|
||||
image: vikunja/api:unstable
|
||||
pull: always
|
||||
environment:
|
||||
VIKUNJA_SERVICE_TESTINGTOKEN: averyLongSecretToSe33dtheDB
|
||||
VIKUNJA_LOG_LEVEL: DEBUG
|
||||
|
@ -42,12 +41,11 @@ steps:
|
|||
# - .cache
|
||||
|
||||
- name: dependencies
|
||||
image: node:20.11.0-alpine
|
||||
image: node:18-alpine
|
||||
pull: always
|
||||
environment:
|
||||
PNPM_CACHE_FOLDER: .cache/pnpm
|
||||
CYPRESS_CACHE_FOLDER: .cache/cypress
|
||||
PUPPETEER_SKIP_DOWNLOAD: true
|
||||
commands:
|
||||
- corepack enable && pnpm config set store-dir .cache/pnpm
|
||||
- pnpm install --fetch-timeout 100000
|
||||
|
@ -55,7 +53,7 @@ steps:
|
|||
# - restore-cache
|
||||
|
||||
- name: lint
|
||||
image: node:20.11.0-alpine
|
||||
image: node:18-alpine
|
||||
pull: always
|
||||
environment:
|
||||
PNPM_CACHE_FOLDER: .cache/pnpm
|
||||
|
@ -66,7 +64,7 @@ steps:
|
|||
- dependencies
|
||||
|
||||
- name: build-prod
|
||||
image: node:20.11.0-alpine
|
||||
image: node:18-alpine
|
||||
pull: always
|
||||
environment:
|
||||
PNPM_CACHE_FOLDER: .cache/pnpm
|
||||
|
@ -77,7 +75,7 @@ steps:
|
|||
- dependencies
|
||||
|
||||
- name: test-unit
|
||||
image: node:20.11.0-alpine
|
||||
image: node:18-alpine
|
||||
pull: always
|
||||
commands:
|
||||
- corepack enable && pnpm config set store-dir .cache/pnpm
|
||||
|
@ -87,7 +85,7 @@ steps:
|
|||
|
||||
- name: typecheck
|
||||
failure: ignore
|
||||
image: node:20.11.0-alpine
|
||||
image: node:18-alpine
|
||||
pull: always
|
||||
environment:
|
||||
PNPM_CACHE_FOLDER: .cache/pnpm
|
||||
|
@ -98,7 +96,7 @@ steps:
|
|||
- dependencies
|
||||
|
||||
- name: test-frontend
|
||||
image: cypress/browsers:node18.12.0-chrome107
|
||||
image: cypress/browsers:node18.12.0-chrome106-ff106
|
||||
pull: always
|
||||
environment:
|
||||
CYPRESS_API_URL: http://api:3456/api/v1
|
||||
|
@ -112,7 +110,8 @@ steps:
|
|||
- sed -i 's/localhost/api/g' dist/index.html
|
||||
- corepack enable && pnpm config set store-dir .cache/pnpm
|
||||
- pnpm cypress install
|
||||
- pnpm run test:e2e-record
|
||||
- pnpm run serve:dist & npx wait-on http://localhost:4173
|
||||
- pnpm run test:frontend --browser chrome --record
|
||||
depends_on:
|
||||
- build-prod
|
||||
|
||||
|
@ -137,9 +136,8 @@ steps:
|
|||
# - dependencies
|
||||
|
||||
- name: deploy-preview
|
||||
image: williamjackson/netlify-cli
|
||||
image: node:18-alpine
|
||||
pull: always
|
||||
user: root # The rest runs as root and thus the permissions wouldn't work
|
||||
environment:
|
||||
NETLIFY_AUTH_TOKEN:
|
||||
from_secret: netlify_auth_token
|
||||
|
@ -152,10 +150,8 @@ steps:
|
|||
# Override the default api url used for preview
|
||||
- sed -i 's|http://localhost:3456|https://try.vikunja.io|g' dist-preview/index.html
|
||||
- apk add --no-cache perl-utils
|
||||
# create via:
|
||||
# `shasum -a 384 ./scripts/deploy-preview-netlify.mjs > ./scripts/deploy-preview-netlify.mjs.sha384`
|
||||
- shasum -a 384 -c ./scripts/deploy-preview-netlify.mjs.sha384
|
||||
- node ./scripts/deploy-preview-netlify.mjs
|
||||
- shasum -a 384 -c ./scripts/deploy-preview-netlify.js.sha384
|
||||
- node ./scripts/deploy-preview-netlify.js
|
||||
depends_on:
|
||||
- build-prod
|
||||
when:
|
||||
|
@ -202,18 +198,13 @@ steps:
|
|||
# - .cache
|
||||
|
||||
- name: build
|
||||
image: node:20.11.0-alpine
|
||||
image: node:18-alpine
|
||||
pull: always
|
||||
environment:
|
||||
PNPM_CACHE_FOLDER: .cache/pnpm
|
||||
SENTRY_AUTH_TOKEN:
|
||||
from_secret: sentry_auth_token
|
||||
SENTRY_ORG: vikunja
|
||||
SENTRY_PROJECT: frontend-oss
|
||||
PUPPETEER_SKIP_DOWNLOAD: true
|
||||
commands:
|
||||
- apk add git
|
||||
- corepack enable && pnpm config set store-dir .cache/pnpm
|
||||
- corepack enable && pnpm config set store-dir .cache/.pnpm
|
||||
- pnpm install --fetch-timeout 100000 --frozen-lockfile
|
||||
- pnpm run lint
|
||||
- "echo '{\"VERSION\": \"'$(git describe --tags --always --abbrev=10 | sed 's/-/+/' | sed 's/^v//' | sed 's/-g/-/')'\"}' > src/version.json"
|
||||
|
@ -226,7 +217,6 @@ steps:
|
|||
image: kolaente/zip
|
||||
pull: always
|
||||
commands:
|
||||
- cp src/version.json dist
|
||||
- cd dist
|
||||
- zip -r ../vikunja-frontend-unstable.zip *
|
||||
- cd ..
|
||||
|
@ -285,14 +275,10 @@ steps:
|
|||
# - .cache
|
||||
|
||||
- name: build
|
||||
image: node:20.11.0-alpine
|
||||
image: node:18-alpine
|
||||
pull: always
|
||||
environment:
|
||||
PNPM_CACHE_FOLDER: .cache/pnpm
|
||||
SENTRY_AUTH_TOKEN:
|
||||
from_secret: sentry_auth_token
|
||||
SENTRY_ORG: vikunja
|
||||
SENTRY_PROJECT: frontend-oss
|
||||
commands:
|
||||
- apk add git
|
||||
- corepack enable && pnpm config set store-dir .cache/pnpm
|
||||
|
@ -308,7 +294,6 @@ steps:
|
|||
image: kolaente/zip
|
||||
pull: always
|
||||
commands:
|
||||
- cp src/version.json dist
|
||||
- cd dist
|
||||
- zip -r ../vikunja-frontend-${DRONE_TAG##v}.zip *
|
||||
- cd ..
|
||||
|
@ -360,7 +345,8 @@ type: docker
|
|||
name: docker-release
|
||||
|
||||
depends_on:
|
||||
- build
|
||||
- release-latest
|
||||
- release-version
|
||||
|
||||
trigger:
|
||||
ref:
|
||||
|
@ -379,7 +365,7 @@ steps:
|
|||
- name: docker-unstable
|
||||
image: thegeeklab/drone-docker-buildx
|
||||
privileged: true
|
||||
pull: always
|
||||
pull: true
|
||||
settings:
|
||||
username:
|
||||
from_secret: docker_username
|
||||
|
@ -388,7 +374,8 @@ steps:
|
|||
repo: vikunja/frontend
|
||||
tags: unstable
|
||||
build_args:
|
||||
- USE_RELEASE=false
|
||||
- USE_RELEASE=true
|
||||
- RELEASE_VERSION=unstable
|
||||
platforms:
|
||||
- linux/386
|
||||
- linux/amd64
|
||||
|
@ -400,36 +387,27 @@ steps:
|
|||
ref:
|
||||
- refs/heads/main
|
||||
|
||||
- name: generate-tags
|
||||
image: thegeeklab/docker-autotag
|
||||
environment:
|
||||
DOCKER_AUTOTAG_VERSION: ${DRONE_TAG}
|
||||
DOCKER_AUTOTAG_EXTRA_TAGS: latest
|
||||
DOCKER_AUTOTAG_OUTPUT_FILE: .tags
|
||||
depends_on: [ fetch-tags ]
|
||||
when:
|
||||
ref:
|
||||
- "refs/tags/**"
|
||||
|
||||
- name: docker-release
|
||||
image: thegeeklab/drone-docker-buildx
|
||||
privileged: true
|
||||
pull: always
|
||||
pull: true
|
||||
settings:
|
||||
username:
|
||||
from_secret: docker_username
|
||||
password:
|
||||
from_secret: docker_password
|
||||
repo: vikunja/frontend
|
||||
auto_tag: true
|
||||
build_args:
|
||||
- USE_RELEASE=false
|
||||
- USE_RELEASE=true
|
||||
- RELEASE_VERSION=${DRONE_TAG##v}
|
||||
platforms:
|
||||
- linux/386
|
||||
- linux/amd64
|
||||
- linux/arm/v6
|
||||
- linux/arm/v7
|
||||
- linux/arm64/v8
|
||||
depends_on: [ generate-tags ]
|
||||
depends_on: [ fetch-tags ]
|
||||
when:
|
||||
ref:
|
||||
- "refs/tags/**"
|
||||
|
@ -475,25 +453,24 @@ name: update-translations
|
|||
|
||||
trigger:
|
||||
branch:
|
||||
include:
|
||||
- main
|
||||
- main
|
||||
event:
|
||||
include:
|
||||
- cron
|
||||
- cron
|
||||
cron:
|
||||
- update_translations
|
||||
|
||||
steps:
|
||||
- name: download
|
||||
pull: always
|
||||
image: ghcr.io/kolaente/kolaente/drone-crowdin-v2:latest
|
||||
image: jonasfranz/crowdin
|
||||
settings:
|
||||
crowdin_key:
|
||||
download: true
|
||||
export_dir: src/i18n/lang/
|
||||
ignore_branch: true
|
||||
project_identifier: vikunja
|
||||
environment:
|
||||
CROWDIN_KEY:
|
||||
from_secret: crowdin_key
|
||||
project_id: 462614
|
||||
target: download
|
||||
download_to: src/i18n/lang/
|
||||
download_export_approved_only: true
|
||||
|
||||
- name: move-files
|
||||
pull: always
|
||||
|
@ -513,25 +490,26 @@ steps:
|
|||
author_name: Frederick [Bot]
|
||||
branch: main
|
||||
commit: true
|
||||
commit_message: "chore(i18n): update translations via Crowdin"
|
||||
commit_message: "[skip ci] Updated translations via Crowdin"
|
||||
remote: "ssh://git@kolaente.dev:9022/vikunja/frontend.git"
|
||||
ssh_key:
|
||||
from_secret: git_push_ssh_key
|
||||
from_secret: translation_git_push_ssh_key
|
||||
|
||||
- name: upload
|
||||
pull: always
|
||||
image: ghcr.io/kolaente/kolaente/drone-crowdin-v2:latest
|
||||
image: jonasfranz/crowdin
|
||||
depends_on:
|
||||
- clone
|
||||
settings:
|
||||
crowdin_key:
|
||||
from_secret: crowdin_key
|
||||
project_id: 462614
|
||||
target: upload
|
||||
upload_files:
|
||||
src/i18n/lang/en.json: en.json
|
||||
files:
|
||||
en.json: src/i18n/lang/en.json
|
||||
ignore_branch: true
|
||||
project_identifier: vikunja
|
||||
environment:
|
||||
CROWDIN_KEY:
|
||||
from_secret: crowdin_key
|
||||
---
|
||||
kind: signature
|
||||
hmac: a044c7c4db3c2a11299d4d118397e9d25be36db241723a1bbd0a2f9cc90ffdac
|
||||
hmac: 381766491766da14ff022383ce0107955f811f7ccb55e95c9c03094e4df809e1
|
||||
|
||||
...
|
||||
|
|
|
@ -1,12 +0,0 @@
|
|||
# (1) Duplicate this file and remove the '.example' suffix.
|
||||
# Naming this file '.env.local' is a Vite convention to prevent accidentally
|
||||
# submitting to git.
|
||||
# For more info see: https://vitejs.dev/guide/env-and-mode.html#env-files
|
||||
|
||||
# (2) Comment in and adjust the values as needed.
|
||||
|
||||
# VITE_IS_ONLINE=true
|
||||
# SENTRY_AUTH_TOKEN=YOUR_TOKEN
|
||||
# SENTRY_ORG=vikunja
|
||||
# SENTRY_PROJECT=frontend-oss
|
||||
# VIKUNJA_FRONTEND_BASE=/custom-subpath
|
|
@ -1,5 +1,5 @@
|
|||
/* eslint-env node */
|
||||
require('@rushstack/eslint-patch/modern-module-resolution')
|
||||
require("@rushstack/eslint-patch/modern-module-resolution")
|
||||
|
||||
module.exports = {
|
||||
'root': true,
|
||||
|
@ -7,54 +7,52 @@ module.exports = {
|
|||
'browser': true,
|
||||
'es2022': true,
|
||||
'node': true,
|
||||
'vue/setup-compiler-macros': true,
|
||||
},
|
||||
'extends': [
|
||||
'eslint:recommended',
|
||||
'plugin:vue/vue3-recommended',
|
||||
'plugin:vue/vue3-essential',
|
||||
'@vue/eslint-config-typescript/recommended',
|
||||
],
|
||||
'rules': {
|
||||
'quotes': ['error', 'single'],
|
||||
'comma-dangle': ['error', 'always-multiline'],
|
||||
'semi': ['error', 'never'],
|
||||
|
||||
'vue/v-on-event-hyphenation': ['warn', 'never', { 'autofix': true }],
|
||||
'vue/multi-word-component-names': 'off',
|
||||
|
||||
// uncategorized rules:
|
||||
'vue/component-api-style': ['error', ['script-setup']],
|
||||
'vue/component-name-in-template-casing': ['warn', 'PascalCase'],
|
||||
'vue/custom-event-name-casing': ['error', 'camelCase'],
|
||||
'vue/define-macros-order': 'error',
|
||||
'vue/match-component-file-name': ['error', {
|
||||
'extensions': ['.js', '.jsx', '.ts', '.tsx', '.vue'],
|
||||
'shouldMatchCase': true,
|
||||
}],
|
||||
'vue/no-boolean-default': ['warn', 'default-false'],
|
||||
'vue/match-component-import-name': 'error',
|
||||
'vue/prefer-separate-static-class': 'warn',
|
||||
|
||||
'vue/padding-line-between-blocks': 'error',
|
||||
'vue/next-tick-style': ['error', 'promise'],
|
||||
'vue/block-lang': [
|
||||
'vue/html-quotes': [
|
||||
'error',
|
||||
{ 'script': { 'lang': 'ts' } },
|
||||
'double',
|
||||
],
|
||||
'quotes': [
|
||||
'error',
|
||||
'single',
|
||||
],
|
||||
'comma-dangle': [
|
||||
'error',
|
||||
'always-multiline',
|
||||
],
|
||||
'semi': [
|
||||
'error',
|
||||
'never',
|
||||
],
|
||||
'vue/no-required-prop-with-default': ['error', { 'autofix': true }],
|
||||
'vue/no-duplicate-attr-inheritance': 'error',
|
||||
'vue/no-empty-component-block': 'error',
|
||||
'vue/html-indent': ['error', 'tab'],
|
||||
|
||||
// vue3
|
||||
'vue/no-ref-object-destructure': 'error',
|
||||
// see https://segmentfault.com/q/1010000040813116/a-1020000041134455 (original in chinese)
|
||||
'no-unused-vars': 'off',
|
||||
'@typescript-eslint/no-unused-vars': ['error', { vars: 'all', args: 'after-used', ignoreRestSiblings: true }],
|
||||
|
||||
'vue/multi-word-component-names': 0,
|
||||
// disabled until we have support for reactivityTransform
|
||||
// See https://github.com/vuejs/eslint-plugin-vue/issues/1948
|
||||
// see also setting in `vite.config`
|
||||
'vue/no-setup-props-destructure': 0,
|
||||
},
|
||||
'parser': 'vue-eslint-parser',
|
||||
'parserOptions': {
|
||||
'parser': '@typescript-eslint/parser',
|
||||
'ecmaVersion': 'latest',
|
||||
'ecmaVersion': 2022,
|
||||
'sourceType': 'module',
|
||||
},
|
||||
'ignorePatterns': [
|
||||
'*.test.*',
|
||||
'cypress/*',
|
||||
],
|
||||
'globals': {
|
||||
'defineProps': 'readonly',
|
||||
},
|
||||
}
|
|
@ -1,3 +1,2 @@
|
|||
github: kolaente
|
||||
open_collective: vikunja
|
||||
custom: ["https://vikunja.cloud", "https://www.buymeacoffee.com/kolaente"]
|
||||
custom: https://www.buymeacoffee.com/kolaente
|
||||
|
|
|
@ -1,23 +0,0 @@
|
|||
name: 'Repo Lockdown'
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: opened
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
action:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: dessant/repo-lockdown@v4
|
||||
with:
|
||||
pr-comment: 'Hi! Thank you for your contribution.
|
||||
|
||||
This repo is only a mirror and unfortunately we can''t accept PRs made here. Please re-submit your changes to [our Gitea instance](https://kolaente.dev/vikunja/frontend/pulls).
|
||||
|
||||
Also check out the [contribution guidelines](https://vikunja.io/docs/development/#pull-requests).
|
||||
|
||||
Thank you for your understanding.'
|
|
@ -1,31 +1,26 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
stats.html
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
node_modules
|
||||
/dist*
|
||||
coverage
|
||||
*.zip
|
||||
.direnv/
|
||||
|
||||
# Test files
|
||||
cypress/screenshots
|
||||
cypress/videos
|
||||
|
||||
# local env files
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Log files
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
stats.html
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Editor directories and files
|
||||
.vscode
|
||||
.idea
|
||||
.vscode
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
|
@ -33,9 +28,9 @@ cypress/videos
|
|||
*.sw*
|
||||
!rollup.sw.js
|
||||
|
||||
# Test files
|
||||
cypress/screenshots
|
||||
cypress/videos
|
||||
|
||||
# Local Netlify folder
|
||||
.netlify
|
||||
|
||||
# histoire
|
||||
.histoire
|
14
.npmrc
14
.npmrc
|
@ -1,14 +1,2 @@
|
|||
fetch-timeout=100000
|
||||
|
||||
# pnpm settings
|
||||
# The following settings prepare for the new default value of pnpm 8
|
||||
# they can be removed directly after having moved to pnpm 8
|
||||
auto-install-peers=true
|
||||
dedupe-peer-dependents=true
|
||||
resolve-peers-from-workspace-root=true
|
||||
save-workspace-protocol=rolling
|
||||
resolution-mode=lowest-direct
|
||||
publishConfig.linkDirectory=true
|
||||
|
||||
# remove some time after having moved to pnpm 8
|
||||
use-lockfile-v6=true
|
||||
fetch-timeout=100000
|
|
@ -8,7 +8,6 @@
|
|||
"lokalise.i18n-ally",
|
||||
"mgmcdermott.vscode-language-babel",
|
||||
"mikestead.dotenv",
|
||||
"Syler.sass-indented",
|
||||
"zixuanchen.vitest-explorer"
|
||||
"Syler.sass-indented"
|
||||
]
|
||||
}
|
|
@ -18,12 +18,6 @@
|
|||
"javascriptreact",
|
||||
"vue"
|
||||
],
|
||||
|
||||
"volar.completion.preferredTagNameCase": "pascal",
|
||||
|
||||
// disable vetur in case it is installed
|
||||
"vetur.validation.template": false,
|
||||
|
||||
// i18n ally
|
||||
"i18n-ally.localesPaths": [
|
||||
"src/i18n/lang"
|
||||
|
|
1674
CHANGELOG.md
1674
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
94
Dockerfile
94
Dockerfile
|
@ -1,73 +1,49 @@
|
|||
# syntax=docker/dockerfile:1
|
||||
# ┬─┐┬ ┐o┬ ┬─┐
|
||||
# │─││ │││ │ │
|
||||
# ┘─┘┘─┘┘┘─┘┘─┘
|
||||
|
||||
FROM --platform=$BUILDPLATFORM node:20.11.0-alpine AS builder
|
||||
# Stage 1: Build application
|
||||
FROM --platform=$BUILDPLATFORM node:18-alpine AS compile-image
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
ARG USE_RELEASE=false
|
||||
ARG RELEASE_VERSION=unstable
|
||||
ARG RELEASE_VERSION=main
|
||||
|
||||
ENV PNPM_CACHE_FOLDER .cache/pnpm/
|
||||
ENV PUPPETEER_SKIP_DOWNLOAD true
|
||||
ADD . ./
|
||||
|
||||
COPY package.json ./
|
||||
COPY pnpm-lock.yaml ./
|
||||
COPY patches ./patches/
|
||||
RUN \
|
||||
if [ $USE_RELEASE = true ]; then \
|
||||
wget https://dl.vikunja.io/frontend/vikunja-frontend-$RELEASE_VERSION.zip -O frontend-release.zip && \
|
||||
unzip frontend-release.zip -d dist/ && \
|
||||
exit 0; \
|
||||
fi && \
|
||||
# https://pnpm.io/installation#using-corepack
|
||||
corepack enable && \
|
||||
# we don't use corepack prepare here by intend since
|
||||
# we have renovate to keep our dependencies up to date
|
||||
# Build the frontend
|
||||
pnpm install && \
|
||||
apk add --no-cache git && \
|
||||
echo '{"VERSION": "'$(git describe --tags --always --abbrev=10 | sed 's/-/+/' | sed 's/^v//' | sed 's/-g/-/')'"}' > src/version.json && \
|
||||
pnpm run build
|
||||
|
||||
RUN if [ "$USE_RELEASE" != true ]; then \
|
||||
# https://pnpm.io/installation#using-corepack
|
||||
corepack enable && \
|
||||
pnpm install; \
|
||||
fi
|
||||
# Stage 2: copy
|
||||
FROM nginx:alpine
|
||||
|
||||
COPY . ./
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
COPY scripts/run.sh /run.sh
|
||||
|
||||
RUN if [ "$USE_RELEASE" != true ]; then \
|
||||
apk add --no-cache --virtual .build-deps git jq && \
|
||||
git describe --tags --always --abbrev=10 | sed 's/-/+/; s/^v//; s/-g/-/' | \
|
||||
xargs -0 -I{} jq -Mcnr --arg version {} '{VERSION:$version}' | \
|
||||
tee src/version.json && \
|
||||
apk del .build-deps; \
|
||||
fi
|
||||
# copy compiled files from stage 1
|
||||
COPY --from=compile-image /build/dist /usr/share/nginx/html
|
||||
|
||||
RUN if [ "$USE_RELEASE" = true ]; then \
|
||||
wget "https://dl.vikunja.io/frontend/vikunja-frontend-${RELEASE_VERSION}.zip" -O frontend-release.zip && \
|
||||
unzip frontend-release.zip -d dist/; \
|
||||
else \
|
||||
# we don't use corepack prepare here by intend since
|
||||
# we have renovate to keep our dependencies up to date
|
||||
# Build the frontend
|
||||
pnpm run build; \
|
||||
fi
|
||||
# Unprivileged user
|
||||
ENV PUID 1000
|
||||
ENV PGID 1000
|
||||
|
||||
# ┌┐┐┌─┐o┌┐┐┐ │
|
||||
# ││││ ┬││││┌┼┘
|
||||
# ┘└┘┘─┘┘┘└┘┘ └
|
||||
|
||||
FROM nginx:stable-alpine AS runner
|
||||
WORKDIR /usr/share/nginx/html
|
||||
LABEL maintainer="maintainers@vikunja.io"
|
||||
|
||||
ENV VIKUNJA_HTTP_PORT 80
|
||||
ENV VIKUNJA_HTTP2_PORT 81
|
||||
ENV VIKUNJA_LOG_FORMAT main
|
||||
ENV VIKUNJA_API_URL /api/v1
|
||||
ENV VIKUNJA_SENTRY_ENABLED false
|
||||
ENV VIKUNJA_SENTRY_DSN https://85694a2d757547cbbc90cd4b55c5a18d@o1047380.ingest.sentry.io/6024480
|
||||
ENV VIKUNJA_PROJECT_INFINITE_NESTING_ENABLED false
|
||||
ENV VIKUNJA_ALLOW_ICON_CHANGES true
|
||||
ENV VIKUNJA_CUSTOM_LOGO_URL "''"
|
||||
RUN apk add --no-cache \
|
||||
# for sh file
|
||||
bash \
|
||||
# installs usermod and groupmod
|
||||
shadow
|
||||
|
||||
COPY docker/injector.sh /docker-entrypoint.d/50-injector.sh
|
||||
COPY docker/ipv6-disable.sh /docker-entrypoint.d/60-ipv6-disable.sh
|
||||
COPY docker/nginx.conf /etc/nginx/nginx.conf
|
||||
COPY docker/templates/. /etc/nginx/templates/
|
||||
# copy compiled files from stage 1
|
||||
COPY --from=builder /build/dist ./
|
||||
# manage permissions
|
||||
RUN chmod 0755 /docker-entrypoint.d/*.sh /etc/nginx/templates && \
|
||||
chmod -R 0644 /etc/nginx/nginx.conf && \
|
||||
chown -R nginx:nginx ./ /etc/nginx/conf.d /etc/nginx/templates && \
|
||||
rm -f /docker-entrypoint.d/10-listen-on-ipv6-by-default.sh
|
||||
CMD "/run.sh"
|
||||
|
|
20
README.md
20
README.md
|
@ -1,14 +1,10 @@
|
|||
# This repository was merged with the api and is now archived
|
||||
|
||||
You can find the new (old) code over on [vikunja/vikunja](https://kolaente.dev/vikunja/vikunja).
|
||||
|
||||
# Web frontend for Vikunja
|
||||
|
||||
> The todo app to organize your life.
|
||||
|
||||
[![Build Status](https://drone.kolaente.de/api/badges/vikunja/frontend/status.svg)](https://drone.kolaente.de/vikunja/frontend)
|
||||
[![License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](LICENSE)
|
||||
[![Download](https://img.shields.io/badge/download-v0.22.1-brightgreen.svg)](https://dl.vikunja.io)
|
||||
[![Download](https://img.shields.io/badge/download-v0.20.2-brightgreen.svg)](https://dl.vikunja.io)
|
||||
[![Translation](https://badges.crowdin.net/vikunja/localized.svg)](https://crowdin.com/project/vikunja)
|
||||
|
||||
This is the web frontend for Vikunja, written in Vue.js.
|
||||
|
@ -22,14 +18,6 @@ If you find any security-related issues you don't want to disclose publicly, ple
|
|||
## Docker
|
||||
|
||||
There is a [docker image available](https://hub.docker.com/r/vikunja/api) with support for http/2 and aggressive caching enabled.
|
||||
In order to build it from sources run the command below. (Docker >= v19.03)
|
||||
|
||||
```shell
|
||||
export DOCKER_BUILDKIT=1
|
||||
docker build -t vikunja/frontend .
|
||||
```
|
||||
|
||||
Refer to [multi-platform documentation](https://docs.docker.com/build/building/multi-platform/) in order to build for different platforms.
|
||||
|
||||
## Project setup
|
||||
|
||||
|
@ -53,4 +41,8 @@ pnpm run build
|
|||
|
||||
```shell
|
||||
pnpm run lint
|
||||
```
|
||||
```
|
||||
|
||||
## Sponsors
|
||||
|
||||
[![Relm](https://vikunja.io/images/sponsors/relm.png)](https://relm.us)
|
||||
|
|
|
@ -11,10 +11,8 @@ export default defineConfig({
|
|||
},
|
||||
projectId: '181c7x',
|
||||
e2e: {
|
||||
specPattern: 'cypress/e2e/**/*.{cy,spec}.{js,jsx,ts,tsx}',
|
||||
baseUrl: 'http://127.0.0.1:4173',
|
||||
experimentalRunAllSpecs: true,
|
||||
// testIsolation: false,
|
||||
baseUrl: 'http://localhost:4173',
|
||||
specPattern: 'cypress/e2e/**/*.{js,jsx,ts,tsx}',
|
||||
},
|
||||
component: {
|
||||
devServer: {
|
||||
|
@ -24,5 +22,4 @@ export default defineConfig({
|
|||
},
|
||||
viewportWidth: 1600,
|
||||
viewportHeight: 900,
|
||||
experimentalMemoryManagement: true,
|
||||
})
|
|
@ -36,7 +36,7 @@ to get a shell inside the cypress container.
|
|||
In that shell you can then execute the tests with
|
||||
|
||||
```shell
|
||||
pnpm run test:e2e
|
||||
pnpm run test:frontend
|
||||
```
|
||||
|
||||
### Using The Cypress Dashboard
|
||||
|
@ -44,5 +44,5 @@ pnpm run test:e2e
|
|||
To open the Cypress Dashboard and run tests from there, run
|
||||
|
||||
```shell
|
||||
pnpm run test:e2e:dev
|
||||
pnpm run cypress:open
|
||||
```
|
||||
|
|
|
@ -9,7 +9,7 @@ services:
|
|||
ports:
|
||||
- 3456:3456
|
||||
cypress:
|
||||
image: cypress/browsers:node18.12.0-chrome107
|
||||
image: cypress/browsers:node16.14.0-chrome99-ff97
|
||||
volumes:
|
||||
- ..:/project
|
||||
- $HOME/.cache:/home/node/.cache/
|
||||
|
|
|
@ -0,0 +1,56 @@
|
|||
import {ListFactory} from '../../factories/list'
|
||||
|
||||
import '../../support/authenticateUser'
|
||||
import {prepareLists} from './prepareLists'
|
||||
|
||||
describe('List History', () => {
|
||||
prepareLists()
|
||||
|
||||
it('should show a list history on the home page', () => {
|
||||
cy.intercept(Cypress.env('API_URL') + '/namespaces*').as('loadNamespaces')
|
||||
cy.intercept(Cypress.env('API_URL') + '/lists/*').as('loadList')
|
||||
|
||||
const lists = ListFactory.create(6)
|
||||
|
||||
cy.visit('/')
|
||||
cy.wait('@loadNamespaces')
|
||||
cy.get('body')
|
||||
.should('not.contain', 'Last viewed')
|
||||
|
||||
cy.visit(`/lists/${lists[0].id}`)
|
||||
cy.wait('@loadNamespaces')
|
||||
cy.wait('@loadList')
|
||||
cy.visit(`/lists/${lists[1].id}`)
|
||||
cy.wait('@loadNamespaces')
|
||||
cy.wait('@loadList')
|
||||
cy.visit(`/lists/${lists[2].id}`)
|
||||
cy.wait('@loadNamespaces')
|
||||
cy.wait('@loadList')
|
||||
cy.visit(`/lists/${lists[3].id}`)
|
||||
cy.wait('@loadNamespaces')
|
||||
cy.wait('@loadList')
|
||||
cy.visit(`/lists/${lists[4].id}`)
|
||||
cy.wait('@loadNamespaces')
|
||||
cy.wait('@loadList')
|
||||
cy.visit(`/lists/${lists[5].id}`)
|
||||
cy.wait('@loadNamespaces')
|
||||
cy.wait('@loadList')
|
||||
|
||||
// cy.visit('/')
|
||||
// cy.wait('@loadNamespaces')
|
||||
// Not using cy.visit here to work around the redirect issue fixed in #1337
|
||||
cy.get('nav.menu.top-menu a')
|
||||
.contains('Overview')
|
||||
.click()
|
||||
|
||||
cy.get('body')
|
||||
.should('contain', 'Last viewed')
|
||||
cy.get('[data-cy="listCardGrid"]')
|
||||
.should('not.contain', lists[0].title)
|
||||
.should('contain', lists[1].title)
|
||||
.should('contain', lists[2].title)
|
||||
.should('contain', lists[3].title)
|
||||
.should('contain', lists[4].title)
|
||||
.should('contain', lists[5].title)
|
||||
})
|
||||
})
|
|
@ -1,17 +1,15 @@
|
|||
import {formatISO, format} from 'date-fns'
|
||||
|
||||
import {createFakeUserAndLogin} from '../../support/authenticateUser'
|
||||
|
||||
import {TaskFactory} from '../../factories/task'
|
||||
import {prepareProjects} from './prepareProjects'
|
||||
import {prepareLists} from './prepareLists'
|
||||
|
||||
describe('Project View Gantt', () => {
|
||||
createFakeUserAndLogin()
|
||||
prepareProjects()
|
||||
import '../../support/authenticateUser'
|
||||
|
||||
describe('List View Gantt', () => {
|
||||
prepareLists()
|
||||
|
||||
it('Hides tasks with no dates', () => {
|
||||
const tasks = TaskFactory.create(1)
|
||||
cy.visit('/projects/1/gantt')
|
||||
cy.visit('/lists/1/gantt')
|
||||
|
||||
cy.get('.g-gantt-rows-container')
|
||||
.should('not.contain', tasks[0].title)
|
||||
|
@ -25,7 +23,7 @@ describe('Project View Gantt', () => {
|
|||
nextMonth.setDate(1)
|
||||
nextMonth.setMonth(9)
|
||||
|
||||
cy.visit('/projects/1/gantt')
|
||||
cy.visit('/lists/1/gantt')
|
||||
|
||||
cy.get('.g-timeunits-container')
|
||||
.should('contain', format(now, 'MMMM'))
|
||||
|
@ -35,10 +33,10 @@ describe('Project View Gantt', () => {
|
|||
it('Shows tasks with dates', () => {
|
||||
const now = new Date()
|
||||
const tasks = TaskFactory.create(1, {
|
||||
start_date: now.toISOString(),
|
||||
end_date: new Date(new Date(now).setDate(now.getDate() + 4)).toISOString(),
|
||||
start_date: formatISO(now),
|
||||
end_date: formatISO(now.setDate(now.getDate() + 4)),
|
||||
})
|
||||
cy.visit('/projects/1/gantt')
|
||||
cy.visit('/lists/1/gantt')
|
||||
|
||||
cy.get('.g-gantt-rows-container')
|
||||
.should('not.be.empty')
|
||||
|
@ -50,7 +48,7 @@ describe('Project View Gantt', () => {
|
|||
start_date: null,
|
||||
end_date: null,
|
||||
})
|
||||
cy.visit('/projects/1/gantt')
|
||||
cy.visit('/lists/1/gantt')
|
||||
|
||||
cy.get('.gantt-options .fancycheckbox')
|
||||
.contains('Show tasks which don\'t have dates set')
|
||||
|
@ -62,14 +60,15 @@ describe('Project View Gantt', () => {
|
|||
})
|
||||
|
||||
it('Drags a task around', () => {
|
||||
cy.intercept(Cypress.env('API_URL') + '/tasks/*').as('taskUpdate')
|
||||
cy.intercept('**/api/v1/tasks/*')
|
||||
.as('taskUpdate')
|
||||
|
||||
const now = new Date()
|
||||
TaskFactory.create(1, {
|
||||
start_date: now.toISOString(),
|
||||
end_date: new Date(new Date(now).setDate(now.getDate() + 4)).toISOString(),
|
||||
start_date: formatISO(now),
|
||||
end_date: formatISO(now.setDate(now.getDate() + 4)),
|
||||
})
|
||||
cy.visit('/projects/1/gantt')
|
||||
cy.visit('/lists/1/gantt')
|
||||
|
||||
cy.get('.g-gantt-rows-container .g-gantt-row .g-gantt-row-bars-container div .g-gantt-bar')
|
||||
.first()
|
||||
|
@ -83,9 +82,9 @@ describe('Project View Gantt', () => {
|
|||
const now = Date.UTC(2022, 10, 9)
|
||||
cy.clock(now, ['Date'])
|
||||
|
||||
cy.visit('/projects/1/gantt')
|
||||
cy.visit('/lists/1/gantt')
|
||||
|
||||
cy.get('.project-gantt .gantt-options .field .control input.input.form-control')
|
||||
cy.get('.list-gantt .gantt-options .field .control input.input.form-control')
|
||||
.click()
|
||||
cy.get('.flatpickr-calendar .flatpickr-innerContainer .dayContainer .flatpickr-day')
|
||||
.first()
|
||||
|
@ -99,13 +98,13 @@ describe('Project View Gantt', () => {
|
|||
})
|
||||
|
||||
it('Should change the date range based on date query parameters', () => {
|
||||
cy.visit('/projects/1/gantt?dateFrom=2022-09-25&dateTo=2022-11-05')
|
||||
cy.visit('/lists/1/gantt?dateFrom=2022-09-25&dateTo=2022-11-05')
|
||||
|
||||
cy.get('.g-timeunits-container')
|
||||
.should('contain', 'September 2022')
|
||||
.should('contain', 'October 2022')
|
||||
.should('contain', 'November 2022')
|
||||
cy.get('.project-gantt .gantt-options .field .control input.input.form-control')
|
||||
cy.get('.list-gantt .gantt-options .field .control input.input.form-control')
|
||||
.should('have.value', '25 Sep 2022 to 5 Nov 2022')
|
||||
})
|
||||
|
||||
|
@ -115,7 +114,7 @@ describe('Project View Gantt', () => {
|
|||
start_date: formatISO(now),
|
||||
end_date: formatISO(now.setDate(now.getDate() + 4)),
|
||||
})
|
||||
cy.visit('/projects/1/gantt')
|
||||
cy.visit('/lists/1/gantt')
|
||||
|
||||
cy.get('.gantt-container .g-gantt-chart .g-gantt-row-bars-container .g-gantt-bar')
|
||||
.dblclick()
|
|
@ -1,38 +1,24 @@
|
|||
import {createFakeUserAndLogin} from '../../support/authenticateUser'
|
||||
|
||||
import {BucketFactory} from '../../factories/bucket'
|
||||
import {ProjectFactory} from '../../factories/project'
|
||||
import {ListFactory} from '../../factories/list'
|
||||
import {TaskFactory} from '../../factories/task'
|
||||
import {prepareProjects} from './prepareProjects'
|
||||
import {prepareLists} from './prepareLists'
|
||||
|
||||
function createSingleTaskInBucket(count = 1, attrs = {}) {
|
||||
const projects = ProjectFactory.create(1)
|
||||
const buckets = BucketFactory.create(2, {
|
||||
project_id: projects[0].id,
|
||||
})
|
||||
const tasks = TaskFactory.create(count, {
|
||||
project_id: projects[0].id,
|
||||
bucket_id: buckets[0].id,
|
||||
...attrs,
|
||||
})
|
||||
return tasks[0]
|
||||
}
|
||||
import '../../support/authenticateUser'
|
||||
|
||||
describe('Project View Kanban', () => {
|
||||
createFakeUserAndLogin()
|
||||
prepareProjects()
|
||||
|
||||
describe('List View Kanban', () => {
|
||||
let buckets
|
||||
prepareLists()
|
||||
|
||||
beforeEach(() => {
|
||||
buckets = BucketFactory.create(2)
|
||||
})
|
||||
|
||||
it('Shows all buckets with their tasks', () => {
|
||||
const data = TaskFactory.create(10, {
|
||||
project_id: 1,
|
||||
list_id: 1,
|
||||
bucket_id: 1,
|
||||
})
|
||||
cy.visit('/projects/1/kanban')
|
||||
cy.visit('/lists/1/kanban')
|
||||
|
||||
cy.get('.kanban .bucket .title')
|
||||
.contains(buckets[0].title)
|
||||
|
@ -47,12 +33,12 @@ describe('Project View Kanban', () => {
|
|||
|
||||
it('Can add a new task to a bucket', () => {
|
||||
TaskFactory.create(2, {
|
||||
project_id: 1,
|
||||
list_id: 1,
|
||||
bucket_id: 1,
|
||||
})
|
||||
cy.visit('/projects/1/kanban')
|
||||
cy.visit('/lists/1/kanban')
|
||||
|
||||
cy.get('.kanban .bucket')
|
||||
cy.getSettled('.kanban .bucket')
|
||||
.contains(buckets[0].title)
|
||||
.get('.bucket-footer .button')
|
||||
.contains('Add another task')
|
||||
|
@ -68,7 +54,7 @@ describe('Project View Kanban', () => {
|
|||
})
|
||||
|
||||
it('Can create a new bucket', () => {
|
||||
cy.visit('/projects/1/kanban')
|
||||
cy.visit('/lists/1/kanban')
|
||||
|
||||
cy.get('.kanban .bucket.new-bucket .button')
|
||||
.click()
|
||||
|
@ -82,9 +68,9 @@ describe('Project View Kanban', () => {
|
|||
})
|
||||
|
||||
it('Can set a bucket limit', () => {
|
||||
cy.visit('/projects/1/kanban')
|
||||
cy.visit('/lists/1/kanban')
|
||||
|
||||
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-trigger')
|
||||
cy.getSettled('.kanban .bucket .bucket-header .dropdown.options .dropdown-trigger')
|
||||
.first()
|
||||
.click()
|
||||
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-menu .dropdown-item')
|
||||
|
@ -103,9 +89,9 @@ describe('Project View Kanban', () => {
|
|||
})
|
||||
|
||||
it('Can rename a bucket', () => {
|
||||
cy.visit('/projects/1/kanban')
|
||||
cy.visit('/lists/1/kanban')
|
||||
|
||||
cy.get('.kanban .bucket .bucket-header .title')
|
||||
cy.getSettled('.kanban .bucket .bucket-header .title')
|
||||
.first()
|
||||
.type('{selectall}New Bucket Title{enter}')
|
||||
cy.get('.kanban .bucket .bucket-header .title')
|
||||
|
@ -114,9 +100,9 @@ describe('Project View Kanban', () => {
|
|||
})
|
||||
|
||||
it('Can delete a bucket', () => {
|
||||
cy.visit('/projects/1/kanban')
|
||||
cy.visit('/lists/1/kanban')
|
||||
|
||||
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-trigger')
|
||||
cy.getSettled('.kanban .bucket .bucket-header .dropdown.options .dropdown-trigger')
|
||||
.first()
|
||||
.click()
|
||||
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-menu .dropdown-item')
|
||||
|
@ -138,12 +124,12 @@ describe('Project View Kanban', () => {
|
|||
|
||||
it('Can drag tasks around', () => {
|
||||
const tasks = TaskFactory.create(2, {
|
||||
project_id: 1,
|
||||
list_id: 1,
|
||||
bucket_id: 1,
|
||||
})
|
||||
cy.visit('/projects/1/kanban')
|
||||
cy.visit('/lists/1/kanban')
|
||||
|
||||
cy.get('.kanban .bucket .tasks .task')
|
||||
cy.getSettled('.kanban .bucket .tasks .task')
|
||||
.contains(tasks[0].title)
|
||||
.first()
|
||||
.drag('.kanban .bucket:nth-child(2) .tasks')
|
||||
|
@ -157,12 +143,12 @@ describe('Project View Kanban', () => {
|
|||
it('Should navigate to the task when the task card is clicked', () => {
|
||||
const tasks = TaskFactory.create(5, {
|
||||
id: '{increment}',
|
||||
project_id: 1,
|
||||
list_id: 1,
|
||||
bucket_id: 1,
|
||||
})
|
||||
cy.visit('/projects/1/kanban')
|
||||
cy.visit('/lists/1/kanban')
|
||||
|
||||
cy.get('.kanban .bucket .tasks .task')
|
||||
cy.getSettled('.kanban .bucket .tasks .task')
|
||||
.contains(tasks[0].title)
|
||||
.should('be.visible')
|
||||
.click()
|
||||
|
@ -171,20 +157,20 @@ describe('Project View Kanban', () => {
|
|||
.should('contain', `/tasks/${tasks[0].id}`, { timeout: 1000 })
|
||||
})
|
||||
|
||||
it('Should remove a task from the kanban board when moving it to another project', () => {
|
||||
const projects = ProjectFactory.create(2)
|
||||
it('Should remove a task from the kanban board when moving it to another list', () => {
|
||||
const lists = ListFactory.create(2)
|
||||
BucketFactory.create(2, {
|
||||
project_id: '{increment}',
|
||||
list_id: '{increment}',
|
||||
})
|
||||
const tasks = TaskFactory.create(5, {
|
||||
id: '{increment}',
|
||||
project_id: 1,
|
||||
list_id: 1,
|
||||
bucket_id: 1,
|
||||
})
|
||||
const task = tasks[0]
|
||||
cy.visit('/projects/1/kanban')
|
||||
cy.visit('/lists/1/kanban')
|
||||
|
||||
cy.get('.kanban .bucket .tasks .task')
|
||||
cy.getSettled('.kanban .bucket .tasks .task')
|
||||
.contains(task.title)
|
||||
.should('be.visible')
|
||||
.click()
|
||||
|
@ -193,7 +179,7 @@ describe('Project View Kanban', () => {
|
|||
.contains('Move')
|
||||
.click()
|
||||
cy.get('.task-view .content.details .field .multiselect.control .input-wrapper input')
|
||||
.type(`${projects[1].title}{enter}`)
|
||||
.type(`${lists[1].title}{enter}`)
|
||||
// The requests happen with a 200ms timeout. Because of that, the results are not yet there when cypress
|
||||
// presses enter and we can't simulate pressing on enter to select the item.
|
||||
cy.get('.task-view .content.details .field .multiselect.control .search-results')
|
||||
|
@ -210,20 +196,28 @@ describe('Project View Kanban', () => {
|
|||
|
||||
it('Shows a button to filter the kanban board', () => {
|
||||
const data = TaskFactory.create(10, {
|
||||
project_id: 1,
|
||||
list_id: 1,
|
||||
bucket_id: 1,
|
||||
})
|
||||
cy.visit('/projects/1/kanban')
|
||||
cy.visit('/lists/1/kanban')
|
||||
|
||||
cy.get('.project-kanban .filter-container .base-button')
|
||||
cy.get('.list-kanban .filter-container .base-button')
|
||||
.should('exist')
|
||||
})
|
||||
|
||||
it('Should remove a task from the board when deleting it', () => {
|
||||
const task = createSingleTaskInBucket(5)
|
||||
cy.visit('/projects/1/kanban')
|
||||
const lists = ListFactory.create(1)
|
||||
const buckets = BucketFactory.create(2, {
|
||||
list_id: lists[0].id,
|
||||
})
|
||||
const tasks = TaskFactory.create(5, {
|
||||
list_id: 1,
|
||||
bucket_id: buckets[0].id,
|
||||
})
|
||||
const task = tasks[0]
|
||||
cy.visit('/lists/1/kanban')
|
||||
|
||||
cy.get('.kanban .bucket .tasks .task')
|
||||
cy.getSettled('.kanban .bucket .tasks .task')
|
||||
.contains(task.title)
|
||||
.should('be.visible')
|
||||
.click()
|
||||
|
@ -240,46 +234,7 @@ describe('Project View Kanban', () => {
|
|||
cy.get('.global-notification')
|
||||
.should('contain', 'Success')
|
||||
|
||||
cy.get('.kanban .bucket .tasks')
|
||||
cy.getSettled('.kanban .bucket .tasks')
|
||||
.should('not.contain', task.title)
|
||||
})
|
||||
|
||||
it('Should show a task description icon if the task has a description', () => {
|
||||
cy.intercept(Cypress.env('API_URL') + '/projects/1/buckets**').as('loadTasks')
|
||||
const task = createSingleTaskInBucket(1, {
|
||||
description: 'Lorem Ipsum',
|
||||
})
|
||||
|
||||
cy.visit(`/projects/${task.project_id}/kanban`)
|
||||
cy.wait('@loadTasks')
|
||||
|
||||
cy.get('.bucket .tasks .task .footer .icon svg')
|
||||
.should('exist')
|
||||
})
|
||||
|
||||
it('Should not show a task description icon if the task has an empty description', () => {
|
||||
cy.intercept(Cypress.env('API_URL') + '/projects/1/buckets**').as('loadTasks')
|
||||
const task = createSingleTaskInBucket(1, {
|
||||
description: '',
|
||||
})
|
||||
|
||||
cy.visit(`/projects/${task.project_id}/kanban`)
|
||||
cy.wait('@loadTasks')
|
||||
|
||||
cy.get('.bucket .tasks .task .footer .icon svg')
|
||||
.should('not.exist')
|
||||
})
|
||||
|
||||
it('Should not show a task description icon if the task has a description containing only an empty p tag', () => {
|
||||
cy.intercept(Cypress.env('API_URL') + '/projects/1/buckets**').as('loadTasks')
|
||||
const task = createSingleTaskInBucket(1, {
|
||||
description: '<p></p>',
|
||||
})
|
||||
|
||||
cy.visit(`/projects/${task.project_id}/kanban`)
|
||||
cy.wait('@loadTasks')
|
||||
|
||||
cy.get('.bucket .tasks .task .footer .icon svg')
|
||||
.should('not.exist')
|
||||
})
|
||||
})
|
|
@ -1,32 +1,31 @@
|
|||
import {createFakeUserAndLogin} from '../../support/authenticateUser'
|
||||
|
||||
import {UserProjectFactory} from '../../factories/users_project'
|
||||
import {UserListFactory} from '../../factories/users_list'
|
||||
import {TaskFactory} from '../../factories/task'
|
||||
import {UserFactory} from '../../factories/user'
|
||||
import {ProjectFactory} from '../../factories/project'
|
||||
import {prepareProjects} from './prepareProjects'
|
||||
import {ListFactory} from '../../factories/list'
|
||||
import {prepareLists} from './prepareLists'
|
||||
|
||||
describe('Project View Project', () => {
|
||||
createFakeUserAndLogin()
|
||||
prepareProjects()
|
||||
import '../../support/authenticateUser'
|
||||
|
||||
it('Should be an empty project', () => {
|
||||
cy.visit('/projects/1')
|
||||
describe('List View List', () => {
|
||||
prepareLists()
|
||||
|
||||
it('Should be an empty list', () => {
|
||||
cy.visit('/lists/1')
|
||||
cy.url()
|
||||
.should('contain', '/projects/1/list')
|
||||
cy.get('.project-title')
|
||||
.should('contain', 'First Project')
|
||||
cy.get('.project-title-dropdown')
|
||||
.should('contain', '/lists/1/list')
|
||||
cy.get('.list-title h1')
|
||||
.should('contain', 'First List')
|
||||
cy.get('.list-title .dropdown')
|
||||
.should('exist')
|
||||
cy.get('p')
|
||||
.contains('This project is currently empty.')
|
||||
.contains('This list is currently empty.')
|
||||
.should('exist')
|
||||
})
|
||||
|
||||
it('Should create a new task', () => {
|
||||
const newTaskTitle = 'New task'
|
||||
|
||||
cy.visit('/projects/1')
|
||||
cy.visit('/lists/1')
|
||||
cy.get('.task-add textarea')
|
||||
.type(newTaskTitle+'{enter}')
|
||||
cy.get('.tasks')
|
||||
|
@ -36,9 +35,9 @@ describe('Project View Project', () => {
|
|||
it('Should navigate to the task when the title is clicked', () => {
|
||||
const tasks = TaskFactory.create(5, {
|
||||
id: '{increment}',
|
||||
project_id: 1,
|
||||
list_id: 1,
|
||||
})
|
||||
cy.visit('/projects/1/list')
|
||||
cy.visit('/lists/1/list')
|
||||
|
||||
cy.get('.tasks .task .tasktext')
|
||||
.contains(tasks[0].title)
|
||||
|
@ -49,32 +48,33 @@ describe('Project View Project', () => {
|
|||
.should('contain', `/tasks/${tasks[0].id}`)
|
||||
})
|
||||
|
||||
it('Should not see any elements for a project which is shared read only', () => {
|
||||
it('Should not see any elements for a list which is shared read only', () => {
|
||||
UserFactory.create(2)
|
||||
UserProjectFactory.create(1, {
|
||||
project_id: 2,
|
||||
UserListFactory.create(1, {
|
||||
list_id: 2,
|
||||
user_id: 1,
|
||||
right: 0,
|
||||
})
|
||||
const projects = ProjectFactory.create(2, {
|
||||
const lists = ListFactory.create(2, {
|
||||
owner_id: '{increment}',
|
||||
namespace_id: '{increment}',
|
||||
})
|
||||
cy.visit(`/projects/${projects[1].id}/`)
|
||||
cy.visit(`/lists/${lists[1].id}/`)
|
||||
|
||||
cy.get('.project-title-wrapper .icon')
|
||||
cy.get('.list-title .icon')
|
||||
.should('not.exist')
|
||||
cy.get('input.input[placeholder="Add a new task..."')
|
||||
.should('not.exist')
|
||||
})
|
||||
|
||||
it('Should only show the color of a project in the navigation and not in the list view', () => {
|
||||
const projects = ProjectFactory.create(1, {
|
||||
it('Should only show the color of a list in the navigation and not in the list view', () => {
|
||||
const lists = ListFactory.create(1, {
|
||||
hex_color: '00db60',
|
||||
})
|
||||
TaskFactory.create(10, {
|
||||
project_id: projects[0].id,
|
||||
list_id: lists[0].id,
|
||||
})
|
||||
cy.visit(`/projects/${projects[0].id}/`)
|
||||
cy.visit(`/lists/${lists[0].id}/`)
|
||||
|
||||
cy.get('.menu-list li .list-menu-link .color-bubble')
|
||||
.should('have.css', 'background-color', 'rgb(0, 219, 96)')
|
||||
|
@ -86,9 +86,9 @@ describe('Project View Project', () => {
|
|||
const tasks = TaskFactory.create(100, {
|
||||
id: '{increment}',
|
||||
title: i => `task${i}`,
|
||||
project_id: 1,
|
||||
list_id: 1,
|
||||
})
|
||||
cy.visit('/projects/1/list')
|
||||
cy.visit('/lists/1/list')
|
||||
|
||||
cy.get('.tasks')
|
||||
.should('contain', tasks[1].title)
|
|
@ -1,38 +1,36 @@
|
|||
import {createFakeUserAndLogin} from '../../support/authenticateUser'
|
||||
|
||||
import {TaskFactory} from '../../factories/task'
|
||||
|
||||
describe('Project View Table', () => {
|
||||
createFakeUserAndLogin()
|
||||
import '../../support/authenticateUser'
|
||||
|
||||
describe('List View Table', () => {
|
||||
it('Should show a table with tasks', () => {
|
||||
const tasks = TaskFactory.create(1)
|
||||
cy.visit('/projects/1/table')
|
||||
cy.visit('/lists/1/table')
|
||||
|
||||
cy.get('.project-table table.table')
|
||||
cy.get('.list-table table.table')
|
||||
.should('exist')
|
||||
cy.get('.project-table table.table')
|
||||
cy.get('.list-table table.table')
|
||||
.should('contain', tasks[0].title)
|
||||
})
|
||||
|
||||
it('Should have working column switches', () => {
|
||||
TaskFactory.create(1)
|
||||
cy.visit('/projects/1/table')
|
||||
cy.visit('/lists/1/table')
|
||||
|
||||
cy.get('.project-table .filter-container .items .button')
|
||||
cy.get('.list-table .filter-container .items .button')
|
||||
.contains('Columns')
|
||||
.click()
|
||||
cy.get('.project-table .filter-container .card.columns-filter .card-content .fancycheckbox')
|
||||
cy.get('.list-table .filter-container .card.columns-filter .card-content .fancycheckbox .check')
|
||||
.contains('Priority')
|
||||
.click()
|
||||
cy.get('.project-table .filter-container .card.columns-filter .card-content .fancycheckbox')
|
||||
cy.get('.list-table .filter-container .card.columns-filter .card-content .fancycheckbox .check')
|
||||
.contains('Done')
|
||||
.click()
|
||||
|
||||
cy.get('.project-table table.table th')
|
||||
cy.get('.list-table table.table th')
|
||||
.contains('Priority')
|
||||
.should('exist')
|
||||
cy.get('.project-table table.table th')
|
||||
cy.get('.list-table table.table th')
|
||||
.contains('Done')
|
||||
.should('not.exist')
|
||||
})
|
||||
|
@ -40,11 +38,11 @@ describe('Project View Table', () => {
|
|||
it('Should navigate to the task when the title is clicked', () => {
|
||||
const tasks = TaskFactory.create(5, {
|
||||
id: '{increment}',
|
||||
project_id: 1,
|
||||
list_id: 1,
|
||||
})
|
||||
cy.visit('/projects/1/table')
|
||||
cy.visit('/lists/1/table')
|
||||
|
||||
cy.get('.project-table table.table')
|
||||
cy.get('.list-table table.table')
|
||||
.contains(tasks[0].title)
|
||||
.click()
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
import {TaskFactory} from '../../factories/task'
|
||||
import {prepareLists} from './prepareLists'
|
||||
|
||||
import '../../support/authenticateUser'
|
||||
|
||||
describe('Lists', () => {
|
||||
let lists
|
||||
prepareLists((newLists) => (lists = newLists))
|
||||
|
||||
it('Should create a new list', () => {
|
||||
cy.visit('/')
|
||||
cy.get('.namespace-title .dropdown-trigger')
|
||||
.click()
|
||||
cy.get('.namespace-title .dropdown .dropdown-item')
|
||||
.contains('New list')
|
||||
.click()
|
||||
cy.url()
|
||||
.should('contain', '/lists/new/1')
|
||||
cy.get('.card-header-title')
|
||||
.contains('New list')
|
||||
cy.get('input.input')
|
||||
.type('New List')
|
||||
cy.get('.button')
|
||||
.contains('Create')
|
||||
.click()
|
||||
|
||||
cy.get('.global-notification', { timeout: 1000 }) // Waiting until the request to create the new list is done
|
||||
.should('contain', 'Success')
|
||||
cy.url()
|
||||
.should('contain', '/lists/')
|
||||
cy.get('.list-title h1')
|
||||
.should('contain', 'New List')
|
||||
})
|
||||
|
||||
it('Should redirect to a specific list view after visited', () => {
|
||||
cy.visit('/lists/1/kanban')
|
||||
cy.url()
|
||||
.should('contain', '/lists/1/kanban')
|
||||
cy.visit('/lists/1')
|
||||
cy.url()
|
||||
.should('contain', '/lists/1/kanban')
|
||||
})
|
||||
|
||||
it('Should rename the list in all places', () => {
|
||||
TaskFactory.create(5, {
|
||||
id: '{increment}',
|
||||
list_id: 1,
|
||||
})
|
||||
const newListName = 'New list name'
|
||||
|
||||
cy.visit('/lists/1')
|
||||
cy.get('.list-title h1')
|
||||
.should('contain', 'First List')
|
||||
|
||||
cy.get('.namespace-container .menu.namespaces-lists .menu-list li:first-child .dropdown .menu-list-dropdown-trigger')
|
||||
.click()
|
||||
cy.get('.namespace-container .menu.namespaces-lists .menu-list li:first-child .dropdown .dropdown-content')
|
||||
.contains('Edit')
|
||||
.click()
|
||||
cy.get('#title')
|
||||
.type(`{selectall}${newListName}`)
|
||||
cy.get('footer.card-footer .button')
|
||||
.contains('Save')
|
||||
.click()
|
||||
|
||||
cy.get('.global-notification')
|
||||
.should('contain', 'Success')
|
||||
cy.get('.list-title h1')
|
||||
.should('contain', newListName)
|
||||
.should('not.contain', lists[0].title)
|
||||
cy.get('.namespace-container .menu.namespaces-lists .menu-list li:first-child')
|
||||
.should('contain', newListName)
|
||||
.should('not.contain', lists[0].title)
|
||||
cy.visit('/')
|
||||
cy.get('.card-content')
|
||||
.should('contain', newListName)
|
||||
.should('not.contain', lists[0].title)
|
||||
})
|
||||
|
||||
it('Should remove a list', () => {
|
||||
cy.visit(`/lists/${lists[0].id}`)
|
||||
|
||||
cy.get('.namespace-container .menu.namespaces-lists .menu-list li:first-child .dropdown .menu-list-dropdown-trigger')
|
||||
.click()
|
||||
cy.get('.namespace-container .menu.namespaces-lists .menu-list li:first-child .dropdown .dropdown-content')
|
||||
.contains('Delete')
|
||||
.click()
|
||||
cy.url()
|
||||
.should('contain', '/settings/delete')
|
||||
cy.get('[data-cy="modalPrimary"]')
|
||||
.contains('Do it')
|
||||
.click()
|
||||
|
||||
cy.get('.global-notification')
|
||||
.should('contain', 'Success')
|
||||
cy.get('.namespace-container .menu.namespaces-lists .menu-list')
|
||||
.should('not.contain', lists[0].title)
|
||||
cy.location('pathname')
|
||||
.should('equal', '/')
|
||||
})
|
||||
|
||||
it('Should archive a list', () => {
|
||||
cy.visit(`/lists/${lists[0].id}`)
|
||||
|
||||
cy.get('.list-title .dropdown')
|
||||
.click()
|
||||
cy.get('.list-title .dropdown .dropdown-menu .dropdown-item')
|
||||
.contains('Archive')
|
||||
.click()
|
||||
cy.get('.modal-content')
|
||||
.should('contain.text', 'Archive this list')
|
||||
cy.get('.modal-content [data-cy=modalPrimary]')
|
||||
.click()
|
||||
|
||||
cy.get('.namespace-container .menu.namespaces-lists .menu-list')
|
||||
.should('not.contain', lists[0].title)
|
||||
cy.get('main.app-content')
|
||||
.should('contain.text', 'This list is archived. It is not possible to create new or edit tasks for it.')
|
||||
})
|
||||
})
|
|
@ -0,0 +1,145 @@
|
|||
import {UserFactory} from '../../factories/user'
|
||||
|
||||
import '../../support/authenticateUser'
|
||||
import {ListFactory} from '../../factories/list'
|
||||
import {NamespaceFactory} from '../../factories/namespace'
|
||||
|
||||
describe('Namepaces', () => {
|
||||
let namespaces
|
||||
|
||||
beforeEach(() => {
|
||||
UserFactory.create(1)
|
||||
namespaces = NamespaceFactory.create(1)
|
||||
ListFactory.create(1)
|
||||
})
|
||||
|
||||
it('Should be all there', () => {
|
||||
cy.visit('/namespaces')
|
||||
cy.get('[data-cy="namespace-title"]')
|
||||
.should('contain', namespaces[0].title)
|
||||
})
|
||||
|
||||
it('Should create a new Namespace', () => {
|
||||
const newNamespaceTitle = 'New Namespace'
|
||||
|
||||
cy.visit('/namespaces')
|
||||
cy.get('[data-cy="new-namespace"]')
|
||||
.should('contain', 'New namespace')
|
||||
.click()
|
||||
|
||||
cy.url()
|
||||
.should('contain', '/namespaces/new')
|
||||
cy.get('.card-header-title')
|
||||
.should('contain', 'New namespace')
|
||||
cy.get('input.input')
|
||||
.type(newNamespaceTitle)
|
||||
cy.get('.button')
|
||||
.contains('Create')
|
||||
.click()
|
||||
|
||||
cy.get('.global-notification')
|
||||
.should('contain', 'Success')
|
||||
cy.get('.namespace-container')
|
||||
.should('contain', newNamespaceTitle)
|
||||
cy.url()
|
||||
.should('contain', '/namespaces')
|
||||
})
|
||||
|
||||
it('Should rename the namespace all places', () => {
|
||||
const newNamespaces = NamespaceFactory.create(5)
|
||||
const newNamespaceName = 'New namespace name'
|
||||
|
||||
cy.visit('/namespaces')
|
||||
|
||||
cy.get(`.namespace-container .menu.namespaces-lists .namespace-title:contains(${newNamespaces[0].title}) .dropdown .dropdown-trigger`)
|
||||
.click()
|
||||
cy.get('.namespace-container .menu.namespaces-lists .namespace-title .dropdown .dropdown-content')
|
||||
.contains('Edit')
|
||||
.click()
|
||||
cy.url()
|
||||
.should('contain', '/settings/edit')
|
||||
cy.get('#namespacetext')
|
||||
.invoke('val')
|
||||
.should('equal', newNamespaces[0].title) // wait until the namespace data is loaded
|
||||
cy.get('#namespacetext')
|
||||
.type(`{selectall}${newNamespaceName}`)
|
||||
cy.get('footer.card-footer .button')
|
||||
.contains('Save')
|
||||
.click()
|
||||
|
||||
cy.get('.global-notification', { timeout: 1000 })
|
||||
.should('contain', 'Success')
|
||||
cy.get('.namespace-container .menu.namespaces-lists')
|
||||
.should('contain', newNamespaceName)
|
||||
.should('not.contain', newNamespaces[0].title)
|
||||
cy.get('[data-cy="namespaces-list"]')
|
||||
.should('contain', newNamespaceName)
|
||||
.should('not.contain', newNamespaces[0].title)
|
||||
})
|
||||
|
||||
it('Should remove a namespace when deleting it', () => {
|
||||
const newNamespaces = NamespaceFactory.create(5)
|
||||
|
||||
cy.visit('/')
|
||||
|
||||
cy.get(`.namespace-container .menu.namespaces-lists .namespace-title:contains(${newNamespaces[0].title}) .dropdown .dropdown-trigger`)
|
||||
.click()
|
||||
cy.get('.namespace-container .menu.namespaces-lists .namespace-title .dropdown .dropdown-content')
|
||||
.contains('Delete')
|
||||
.click()
|
||||
cy.url()
|
||||
.should('contain', '/settings/delete')
|
||||
cy.get('[data-cy="modalPrimary"]')
|
||||
.contains('Do it')
|
||||
.click()
|
||||
|
||||
cy.get('.global-notification')
|
||||
.should('contain', 'Success')
|
||||
cy.get('.namespace-container .menu.namespaces-lists')
|
||||
.should('not.contain', newNamespaces[0].title)
|
||||
})
|
||||
|
||||
it('Should not show archived lists & namespaces if the filter is not checked', () => {
|
||||
const n = NamespaceFactory.create(1, {
|
||||
id: 2,
|
||||
is_archived: true,
|
||||
}, false)
|
||||
ListFactory.create(1, {
|
||||
id: 2,
|
||||
namespace_id: n[0].id,
|
||||
}, false)
|
||||
|
||||
ListFactory.create(1, {
|
||||
id: 3,
|
||||
is_archived: true,
|
||||
}, false)
|
||||
|
||||
// Initial
|
||||
cy.visit('/namespaces')
|
||||
cy.get('.namespace')
|
||||
.should('not.contain', 'Archived')
|
||||
|
||||
// Show archived
|
||||
cy.get('[data-cy="show-archived-check"] label.check span')
|
||||
.should('be.visible')
|
||||
.click()
|
||||
cy.get('[data-cy="show-archived-check"] input')
|
||||
.should('be.checked')
|
||||
cy.get('.namespace')
|
||||
.should('contain', 'Archived')
|
||||
|
||||
// Don't show archived
|
||||
cy.get('[data-cy="show-archived-check"] label.check span')
|
||||
.should('be.visible')
|
||||
.click()
|
||||
cy.get('[data-cy="show-archived-check"] input')
|
||||
.should('not.be.checked')
|
||||
|
||||
// Second time visiting after unchecking
|
||||
cy.visit('/namespaces')
|
||||
cy.get('[data-cy="show-archived-check"] input')
|
||||
.should('not.be.checked')
|
||||
cy.get('.namespace')
|
||||
.should('not.contain', 'Archived')
|
||||
})
|
||||
})
|
|
@ -0,0 +1,21 @@
|
|||
import {ListFactory} from '../../factories/list'
|
||||
import {UserFactory} from '../../factories/user'
|
||||
import {NamespaceFactory} from '../../factories/namespace'
|
||||
import {TaskFactory} from '../../factories/task'
|
||||
|
||||
export function createLists() {
|
||||
UserFactory.create(1)
|
||||
NamespaceFactory.create(1)
|
||||
const lists = ListFactory.create(1, {
|
||||
title: 'First List'
|
||||
})
|
||||
TaskFactory.truncate()
|
||||
return lists
|
||||
}
|
||||
|
||||
export function prepareLists(setLists = () => {}) {
|
||||
beforeEach(() => {
|
||||
const lists = createLists()
|
||||
setLists(lists)
|
||||
})
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
import {TaskFactory} from '../../factories/task'
|
||||
import {ListFactory} from '../../factories/list'
|
||||
import {NamespaceFactory} from '../../factories/namespace'
|
||||
import {UserListFactory} from '../../factories/users_list'
|
||||
|
||||
import '../../support/authenticateUser'
|
||||
|
||||
describe('Editor', () => {
|
||||
beforeEach(() => {
|
||||
NamespaceFactory.create(1)
|
||||
const lists = ListFactory.create(1)
|
||||
TaskFactory.truncate()
|
||||
UserListFactory.truncate()
|
||||
})
|
||||
|
||||
it('Has a preview with checkable checkboxes', () => {
|
||||
const tasks = TaskFactory.create(1, {
|
||||
description: `# Test Heading
|
||||
* Bullet 1
|
||||
* Bullet 2
|
||||
|
||||
* [ ] Checklist
|
||||
* [x] Checklist checked
|
||||
`,
|
||||
})
|
||||
|
||||
cy.visit(`/tasks/${tasks[0].id}`)
|
||||
cy.get('input[type=checkbox][data-checkbox-num=0]')
|
||||
.click()
|
||||
|
||||
cy.get('.task-view .details.content.description h3 span.is-small.has-text-success')
|
||||
.contains('Saved!')
|
||||
.should('exist')
|
||||
cy.get('.preview.content')
|
||||
.should('contain', 'Test Heading')
|
||||
})
|
||||
})
|
|
@ -1,27 +1,21 @@
|
|||
import {createFakeUserAndLogin} from '../../support/authenticateUser'
|
||||
import '../../support/authenticateUser'
|
||||
|
||||
describe('The Menu', () => {
|
||||
createFakeUserAndLogin()
|
||||
|
||||
beforeEach(() => {
|
||||
cy.visit('/')
|
||||
})
|
||||
|
||||
it('Is visible by default on desktop', () => {
|
||||
cy.get('.menu-container')
|
||||
cy.get('.namespace-container')
|
||||
.should('have.class', 'is-active')
|
||||
})
|
||||
|
||||
it('Can be hidden on desktop', () => {
|
||||
cy.get('button.menu-show-button:visible')
|
||||
.click()
|
||||
cy.get('.menu-container')
|
||||
cy.get('.namespace-container')
|
||||
.should('not.have.class', 'is-active')
|
||||
})
|
||||
|
||||
it('Is hidden by default on mobile', () => {
|
||||
cy.viewport('iphone-8')
|
||||
cy.get('.menu-container')
|
||||
cy.get('.namespace-container')
|
||||
.should('not.have.class', 'is-active')
|
||||
})
|
||||
|
||||
|
@ -29,7 +23,7 @@ describe('The Menu', () => {
|
|||
cy.viewport('iphone-8')
|
||||
cy.get('button.menu-show-button:visible')
|
||||
.click()
|
||||
cy.get('.menu-container')
|
||||
cy.get('.namespace-container')
|
||||
.should('have.class', 'is-active')
|
||||
})
|
||||
})
|
||||
|
|
|
@ -1,17 +0,0 @@
|
|||
import {ProjectFactory} from '../../factories/project'
|
||||
import {TaskFactory} from '../../factories/task'
|
||||
|
||||
export function createProjects() {
|
||||
const projects = ProjectFactory.create(1, {
|
||||
title: 'First Project'
|
||||
})
|
||||
TaskFactory.truncate()
|
||||
return projects
|
||||
}
|
||||
|
||||
export function prepareProjects(setProjects = (...args: any[]) => {}) {
|
||||
beforeEach(() => {
|
||||
const projects = createProjects()
|
||||
setProjects(projects)
|
||||
})
|
||||
}
|
|
@ -1,50 +0,0 @@
|
|||
import {createFakeUserAndLogin} from '../../support/authenticateUser'
|
||||
|
||||
import {ProjectFactory} from '../../factories/project'
|
||||
import {prepareProjects} from './prepareProjects'
|
||||
|
||||
describe('Project History', () => {
|
||||
createFakeUserAndLogin()
|
||||
prepareProjects()
|
||||
|
||||
it('should show a project history on the home page', () => {
|
||||
cy.intercept(Cypress.env('API_URL') + '/projects*').as('loadProjectArray')
|
||||
cy.intercept(Cypress.env('API_URL') + '/projects/*').as('loadProject')
|
||||
|
||||
const projects = ProjectFactory.create(6)
|
||||
|
||||
cy.visit('/')
|
||||
cy.wait('@loadProjectArray')
|
||||
cy.get('body')
|
||||
.should('not.contain', 'Last viewed')
|
||||
|
||||
cy.visit(`/projects/${projects[0].id}`)
|
||||
cy.wait('@loadProject')
|
||||
cy.visit(`/projects/${projects[1].id}`)
|
||||
cy.wait('@loadProject')
|
||||
cy.visit(`/projects/${projects[2].id}`)
|
||||
cy.wait('@loadProject')
|
||||
cy.visit(`/projects/${projects[3].id}`)
|
||||
cy.wait('@loadProject')
|
||||
cy.visit(`/projects/${projects[4].id}`)
|
||||
cy.wait('@loadProject')
|
||||
cy.visit(`/projects/${projects[5].id}`)
|
||||
cy.wait('@loadProject')
|
||||
|
||||
// cy.visit('/')
|
||||
// Not using cy.visit here to work around the redirect issue fixed in #1337
|
||||
cy.get('nav.menu.top-menu a')
|
||||
.contains('Overview')
|
||||
.click()
|
||||
|
||||
cy.get('body')
|
||||
.should('contain', 'Last viewed')
|
||||
cy.get('[data-cy="projectCardGrid"]')
|
||||
.should('not.contain', projects[0].title)
|
||||
.should('contain', projects[1].title)
|
||||
.should('contain', projects[2].title)
|
||||
.should('contain', projects[3].title)
|
||||
.should('contain', projects[4].title)
|
||||
.should('contain', projects[5].title)
|
||||
})
|
||||
})
|
|
@ -1,171 +0,0 @@
|
|||
import {createFakeUserAndLogin} from '../../support/authenticateUser'
|
||||
|
||||
import {TaskFactory} from '../../factories/task'
|
||||
import {ProjectFactory} from '../../factories/project'
|
||||
import {prepareProjects} from './prepareProjects'
|
||||
|
||||
describe('Projects', () => {
|
||||
createFakeUserAndLogin()
|
||||
|
||||
let projects
|
||||
prepareProjects((newProjects) => (projects = newProjects))
|
||||
|
||||
it('Should create a new project', () => {
|
||||
cy.visit('/projects')
|
||||
cy.get('.project-header [data-cy=new-project]')
|
||||
.click()
|
||||
cy.url()
|
||||
.should('contain', '/projects/new')
|
||||
cy.get('.card-header-title')
|
||||
.contains('New project')
|
||||
cy.get('input[name=projectTitle]')
|
||||
.type('New Project')
|
||||
cy.get('.button')
|
||||
.contains('Create')
|
||||
.click()
|
||||
|
||||
cy.get('.global-notification', {timeout: 1000}) // Waiting until the request to create the new project is done
|
||||
.should('contain', 'Success')
|
||||
cy.url()
|
||||
.should('contain', '/projects/')
|
||||
cy.get('.project-title')
|
||||
.should('contain', 'New Project')
|
||||
})
|
||||
|
||||
it('Should redirect to a specific project view after visited', () => {
|
||||
cy.intercept(Cypress.env('API_URL') + '/projects/*/buckets*').as('loadBuckets')
|
||||
cy.visit('/projects/1/kanban')
|
||||
cy.url()
|
||||
.should('contain', '/projects/1/kanban')
|
||||
cy.wait('@loadBuckets')
|
||||
cy.visit('/projects/1')
|
||||
cy.url()
|
||||
.should('contain', '/projects/1/kanban')
|
||||
})
|
||||
|
||||
it('Should rename the project in all places', () => {
|
||||
TaskFactory.create(5, {
|
||||
id: '{increment}',
|
||||
project_id: 1,
|
||||
})
|
||||
const newProjectName = 'New project name'
|
||||
|
||||
cy.visit('/projects/1')
|
||||
cy.get('.project-title')
|
||||
.should('contain', 'First Project')
|
||||
|
||||
cy.get('.menu-container .menu-list li:first-child .dropdown .menu-list-dropdown-trigger')
|
||||
.click()
|
||||
cy.get('.menu-container .menu-list li:first-child .dropdown .dropdown-content')
|
||||
.contains('Edit')
|
||||
.click()
|
||||
cy.get('#title')
|
||||
.type(`{selectall}${newProjectName}`)
|
||||
cy.get('footer.card-footer .button')
|
||||
.contains('Save')
|
||||
.click()
|
||||
|
||||
cy.get('.global-notification')
|
||||
.should('contain', 'Success')
|
||||
cy.get('.project-title')
|
||||
.should('contain', newProjectName)
|
||||
.should('not.contain', projects[0].title)
|
||||
cy.get('.menu-container .menu-list li:first-child')
|
||||
.should('contain', newProjectName)
|
||||
.should('not.contain', projects[0].title)
|
||||
cy.visit('/')
|
||||
cy.get('.project-grid')
|
||||
.should('contain', newProjectName)
|
||||
.should('not.contain', projects[0].title)
|
||||
})
|
||||
|
||||
it('Should remove a project when deleting it', () => {
|
||||
cy.visit(`/projects/${projects[0].id}`)
|
||||
|
||||
cy.get('.menu-container .menu-list li:first-child .dropdown .menu-list-dropdown-trigger')
|
||||
.click()
|
||||
cy.get('.menu-container .menu-list li:first-child .dropdown .dropdown-content')
|
||||
.contains('Delete')
|
||||
.click()
|
||||
cy.url()
|
||||
.should('contain', '/settings/delete')
|
||||
cy.get('[data-cy="modalPrimary"]')
|
||||
.contains('Do it')
|
||||
.click()
|
||||
|
||||
cy.get('.global-notification')
|
||||
.should('contain', 'Success')
|
||||
cy.get('.menu-container .menu-list')
|
||||
.should('not.contain', projects[0].title)
|
||||
cy.location('pathname')
|
||||
.should('equal', '/')
|
||||
})
|
||||
|
||||
it('Should archive a project', () => {
|
||||
cy.visit(`/projects/${projects[0].id}`)
|
||||
|
||||
cy.get('.project-title-dropdown')
|
||||
.click()
|
||||
cy.get('.project-title-dropdown .dropdown-menu .dropdown-item')
|
||||
.contains('Archive')
|
||||
.click()
|
||||
cy.get('.modal-content')
|
||||
.should('contain.text', 'Archive this project')
|
||||
cy.get('.modal-content [data-cy=modalPrimary]')
|
||||
.click()
|
||||
|
||||
cy.get('.menu-container .menu-list')
|
||||
.should('not.contain', projects[0].title)
|
||||
cy.get('main.app-content')
|
||||
.should('contain.text', 'This project is archived. It is not possible to create new or edit tasks for it.')
|
||||
})
|
||||
|
||||
it('Should show all projects on the projects page', () => {
|
||||
const projects = ProjectFactory.create(10)
|
||||
|
||||
cy.visit('/projects')
|
||||
|
||||
projects.forEach(p => {
|
||||
cy.get('[data-cy="projects-list"]')
|
||||
.should('contain', p.title)
|
||||
})
|
||||
})
|
||||
|
||||
it('Should not show archived projects if the filter is not checked', () => {
|
||||
ProjectFactory.create(1, {
|
||||
id: 2,
|
||||
}, false)
|
||||
ProjectFactory.create(1, {
|
||||
id: 3,
|
||||
is_archived: true,
|
||||
}, false)
|
||||
|
||||
// Initial
|
||||
cy.visit('/projects')
|
||||
cy.get('.project-grid')
|
||||
.should('not.contain', 'Archived')
|
||||
|
||||
// Show archived
|
||||
cy.get('[data-cy="show-archived-check"] label span')
|
||||
.should('be.visible')
|
||||
.click()
|
||||
cy.get('[data-cy="show-archived-check"] input')
|
||||
.should('be.checked')
|
||||
cy.get('.project-grid')
|
||||
.should('contain', 'Archived')
|
||||
|
||||
// Don't show archived
|
||||
cy.get('[data-cy="show-archived-check"] label span')
|
||||
.should('be.visible')
|
||||
.click()
|
||||
cy.get('[data-cy="show-archived-check"] input')
|
||||
.should('not.be.checked')
|
||||
|
||||
// Second time visiting after unchecking
|
||||
cy.visit('/projects')
|
||||
cy.get('[data-cy="show-archived-check"] input')
|
||||
.should('not.be.checked')
|
||||
cy.get('.project-grid')
|
||||
.should('not.contain', 'Archived')
|
||||
})
|
||||
})
|
|
@ -1,59 +1,25 @@
|
|||
import {LinkShareFactory} from '../../factories/link_sharing'
|
||||
import {ProjectFactory} from '../../factories/project'
|
||||
import {ListFactory} from '../../factories/list'
|
||||
import {TaskFactory} from '../../factories/task'
|
||||
|
||||
function prepareLinkShare() {
|
||||
const projects = ProjectFactory.create(1)
|
||||
const tasks = TaskFactory.create(10, {
|
||||
project_id: projects[0].id
|
||||
})
|
||||
const linkShares = LinkShareFactory.create(1, {
|
||||
project_id: projects[0].id,
|
||||
right: 0,
|
||||
})
|
||||
|
||||
return {
|
||||
share: linkShares[0],
|
||||
project: projects[0],
|
||||
tasks,
|
||||
}
|
||||
}
|
||||
|
||||
describe('Link shares', () => {
|
||||
it('Can view a link share', () => {
|
||||
const {share, project, tasks} = prepareLinkShare()
|
||||
const lists = ListFactory.create(1)
|
||||
const tasks = TaskFactory.create(10, {
|
||||
list_id: lists[0].id
|
||||
})
|
||||
const linkShares = LinkShareFactory.create(1, {
|
||||
list_id: lists[0].id,
|
||||
right: 0,
|
||||
})
|
||||
|
||||
cy.visit(`/share/${share.hash}/auth`)
|
||||
cy.visit(`/share/${linkShares[0].hash}/auth`)
|
||||
|
||||
cy.get('h1.title')
|
||||
.should('contain', project.title)
|
||||
.should('contain', lists[0].title)
|
||||
cy.get('input.input[placeholder="Add a new task..."')
|
||||
.should('not.exist')
|
||||
cy.get('.tasks')
|
||||
.should('contain', tasks[0].title)
|
||||
|
||||
cy.url().should('contain', `/projects/${project.id}/list#share-auth-token=${share.hash}`)
|
||||
})
|
||||
|
||||
it('Should work when directly viewing a project with share hash present', () => {
|
||||
const {share, project, tasks} = prepareLinkShare()
|
||||
|
||||
cy.visit(`/projects/${project.id}/list#share-auth-token=${share.hash}`)
|
||||
|
||||
cy.get('h1.title')
|
||||
.should('contain', project.title)
|
||||
cy.get('input.input[placeholder="Add a new task..."')
|
||||
.should('not.exist')
|
||||
cy.get('.tasks')
|
||||
.should('contain', tasks[0].title)
|
||||
})
|
||||
|
||||
it('Should work when directly viewing a task with share hash present', () => {
|
||||
const {share, project, tasks} = prepareLinkShare()
|
||||
|
||||
cy.visit(`/tasks/${tasks[0].id}#share-auth-token=${share.hash}`)
|
||||
|
||||
cy.get('h1.title')
|
||||
.should('contain', tasks[0].title)
|
||||
})
|
||||
})
|
||||
|
|
|
@ -1,12 +1,9 @@
|
|||
import {createFakeUserAndLogin} from '../../support/authenticateUser'
|
||||
|
||||
import {TeamFactory} from '../../factories/team'
|
||||
import {TeamMemberFactory} from '../../factories/team_member'
|
||||
import {UserFactory} from '../../factories/user'
|
||||
import '../../support/authenticateUser'
|
||||
|
||||
describe('Team', () => {
|
||||
createFakeUserAndLogin()
|
||||
|
||||
it('Creates a new team', () => {
|
||||
TeamFactory.truncate()
|
||||
cy.visit('/teams')
|
||||
|
|
|
@ -1,43 +1,45 @@
|
|||
import {createFakeUserAndLogin} from '../../support/authenticateUser'
|
||||
|
||||
import {ProjectFactory} from '../../factories/project'
|
||||
import {ListFactory} from '../../factories/list'
|
||||
import {seed} from '../../support/seed'
|
||||
import {TaskFactory} from '../../factories/task'
|
||||
import {formatISO} from 'date-fns'
|
||||
import {UserFactory} from '../../factories/user'
|
||||
import {NamespaceFactory} from '../../factories/namespace'
|
||||
import {BucketFactory} from '../../factories/bucket'
|
||||
import {updateUserSettings} from '../../support/updateUserSettings'
|
||||
|
||||
function seedTasks(numberOfTasks = 50, startDueDate = new Date()) {
|
||||
const project = ProjectFactory.create()[0]
|
||||
import '../../support/authenticateUser'
|
||||
|
||||
function seedTasks(numberOfTasks = 100, startDueDate = new Date()) {
|
||||
UserFactory.create(1)
|
||||
NamespaceFactory.create(1)
|
||||
const list = ListFactory.create()[0]
|
||||
BucketFactory.create(1, {
|
||||
project_id: project.id,
|
||||
list_id: list.id,
|
||||
})
|
||||
const tasks = []
|
||||
let dueDate = startDueDate
|
||||
for (let i = 0; i < numberOfTasks; i++) {
|
||||
const now = new Date()
|
||||
dueDate = new Date(new Date(dueDate).setDate(dueDate.getDate() + 2))
|
||||
dueDate = (new Date(dueDate.valueOf())).setDate((new Date(dueDate.valueOf())).getDate() + 2)
|
||||
tasks.push({
|
||||
id: i + 1,
|
||||
project_id: project.id,
|
||||
list_id: list.id,
|
||||
done: false,
|
||||
created_by_id: 1,
|
||||
title: 'Test Task ' + i,
|
||||
index: i + 1,
|
||||
due_date: dueDate.toISOString(),
|
||||
created: now.toISOString(),
|
||||
updated: now.toISOString(),
|
||||
due_date: formatISO(dueDate),
|
||||
created: formatISO(now),
|
||||
updated: formatISO(now),
|
||||
})
|
||||
}
|
||||
seed(TaskFactory.table, tasks)
|
||||
return {tasks, project}
|
||||
return {tasks, list}
|
||||
}
|
||||
|
||||
describe('Home Page Task Overview', () => {
|
||||
createFakeUserAndLogin()
|
||||
|
||||
it('Should show tasks with a near due date first on the home page overview', () => {
|
||||
const taskCount = 50
|
||||
const {tasks} = seedTasks(taskCount)
|
||||
const {tasks} = seedTasks()
|
||||
|
||||
cy.visit('/')
|
||||
cy.get('[data-cy="showTasks"] .card .task')
|
||||
|
@ -47,10 +49,8 @@ describe('Home Page Task Overview', () => {
|
|||
})
|
||||
|
||||
it('Should show overdue tasks first, then show other tasks', () => {
|
||||
const now = new Date()
|
||||
const oldDate = new Date(new Date(now).setDate(now.getDate() - 14))
|
||||
const taskCount = 50
|
||||
const {tasks} = seedTasks(taskCount, oldDate)
|
||||
const oldDate = (new Date()).setDate((new Date()).getDate() - 14)
|
||||
const {tasks} = seedTasks(100, oldDate)
|
||||
|
||||
cy.visit('/')
|
||||
cy.get('[data-cy="showTasks"] .card .task')
|
||||
|
@ -68,10 +68,10 @@ describe('Home Page Task Overview', () => {
|
|||
TaskFactory.create(1, {
|
||||
id: 999,
|
||||
title: newTaskTitle,
|
||||
due_date: new Date().toISOString(),
|
||||
due_date: formatISO(new Date()),
|
||||
}, false)
|
||||
|
||||
cy.visit(`/projects/${tasks[0].project_id}/list`)
|
||||
cy.visit(`/lists/${tasks[0].list_id}/list`)
|
||||
cy.get('.tasks .task')
|
||||
.first()
|
||||
.should('contain.text', newTaskTitle)
|
||||
|
@ -83,12 +83,12 @@ describe('Home Page Task Overview', () => {
|
|||
|
||||
it('Should not show a new task without a date at the bottom when there are > 50 tasks', () => {
|
||||
// We're not using the api here to create the task in order to verify the flow
|
||||
const {tasks} = seedTasks(100)
|
||||
const {tasks} = seedTasks()
|
||||
const newTaskTitle = 'New Task'
|
||||
|
||||
cy.visit('/')
|
||||
|
||||
cy.visit(`/projects/${tasks[0].project_id}/list`)
|
||||
cy.visit(`/lists/${tasks[0].list_id}/list`)
|
||||
cy.get('.task-add textarea')
|
||||
.type(newTaskTitle+'{enter}')
|
||||
cy.visit('/')
|
||||
|
@ -111,10 +111,10 @@ describe('Home Page Task Overview', () => {
|
|||
.should('contain.text', newTaskTitle)
|
||||
})
|
||||
|
||||
it('Should show a task without a due date added via default project at the bottom', () => {
|
||||
const {project} = seedTasks(40)
|
||||
it('Should show a task without a due date added via default list at the bottom', () => {
|
||||
const {list} = seedTasks(40)
|
||||
updateUserSettings({
|
||||
default_project_id: project.id,
|
||||
default_list_id: list.id,
|
||||
overdue_tasks_reminders_time: '9:00',
|
||||
})
|
||||
|
||||
|
@ -129,22 +129,23 @@ describe('Home Page Task Overview', () => {
|
|||
.should('contain.text', newTaskTitle)
|
||||
})
|
||||
|
||||
it('Should show the cta buttons for new project when there are no tasks', () => {
|
||||
it('Should show the cta buttons for new list when there are no tasks', () => {
|
||||
TaskFactory.truncate()
|
||||
|
||||
cy.visit('/')
|
||||
|
||||
cy.get('.home.app-content .content')
|
||||
.should('contain.text', 'Import your projects and tasks from other services into Vikunja:')
|
||||
.should('contain.text', 'You can create a new list for your new tasks:')
|
||||
.should('contain.text', 'Or import your lists and tasks from other services into Vikunja:')
|
||||
})
|
||||
|
||||
it('Should not show the cta buttons for new project when there are tasks', () => {
|
||||
it('Should not show the cta buttons for new list when there are tasks', () => {
|
||||
seedTasks()
|
||||
|
||||
cy.visit('/')
|
||||
|
||||
cy.get('.home.app-content .content')
|
||||
.should('not.contain.text', 'You can create a new project for your new tasks:')
|
||||
.should('not.contain.text', 'Or import your projects and tasks from other services into Vikunja:')
|
||||
.should('not.contain.text', 'You can create a new list for your new tasks:')
|
||||
.should('not.contain.text', 'Or import your lists and tasks from other services into Vikunja:')
|
||||
})
|
||||
})
|
||||
|
|
|
@ -1,17 +1,18 @@
|
|||
import {createFakeUserAndLogin} from '../../support/authenticateUser'
|
||||
import {formatISO} from 'date-fns'
|
||||
|
||||
import {TaskFactory} from '../../factories/task'
|
||||
import {ProjectFactory} from '../../factories/project'
|
||||
import {ListFactory} from '../../factories/list'
|
||||
import {TaskCommentFactory} from '../../factories/task_comment'
|
||||
import {UserFactory} from '../../factories/user'
|
||||
import {UserProjectFactory} from '../../factories/users_project'
|
||||
import {NamespaceFactory} from '../../factories/namespace'
|
||||
import {UserListFactory} from '../../factories/users_list'
|
||||
import {TaskAssigneeFactory} from '../../factories/task_assignee'
|
||||
import {LabelFactory} from '../../factories/labels'
|
||||
import {LabelTaskFactory} from '../../factories/label_task'
|
||||
import {BucketFactory} from '../../factories/bucket'
|
||||
|
||||
import '../../support/authenticateUser'
|
||||
import {TaskAttachmentFactory} from '../../factories/task_attachments'
|
||||
import {TaskReminderFactory} from '../../factories/task_reminders'
|
||||
|
||||
function addLabelToTaskAndVerify(labelTitle: string) {
|
||||
cy.get('.task-view .action-buttons .button')
|
||||
|
@ -24,7 +25,7 @@ function addLabelToTaskAndVerify(labelTitle: string) {
|
|||
.first()
|
||||
.click()
|
||||
|
||||
cy.get('.global-notification', {timeout: 4000})
|
||||
cy.get('.global-notification', { timeout: 4000 })
|
||||
.should('contain', 'Success')
|
||||
cy.get('.task-view .details.labels-list .multiselect .input-wrapper span.tag')
|
||||
.should('exist')
|
||||
|
@ -36,7 +37,7 @@ function uploadAttachmentAndVerify(taskId: number) {
|
|||
cy.get('.task-view .action-buttons .button')
|
||||
.contains('Add Attachments')
|
||||
.click()
|
||||
cy.get('input[type=file]#files', {timeout: 1000})
|
||||
cy.get('input[type=file]', {timeout: 1000})
|
||||
.selectFile('cypress/fixtures/image.jpg', {force: true}) // The input is not visible, but on purpose
|
||||
cy.wait('@uploadAttachment')
|
||||
|
||||
|
@ -45,23 +46,23 @@ function uploadAttachmentAndVerify(taskId: number) {
|
|||
}
|
||||
|
||||
describe('Task', () => {
|
||||
createFakeUserAndLogin()
|
||||
|
||||
let projects
|
||||
let namespaces
|
||||
let lists
|
||||
let buckets
|
||||
|
||||
beforeEach(() => {
|
||||
// UserFactory.create(1)
|
||||
projects = ProjectFactory.create(1)
|
||||
UserFactory.create(1)
|
||||
namespaces = NamespaceFactory.create(1)
|
||||
lists = ListFactory.create(1)
|
||||
buckets = BucketFactory.create(1, {
|
||||
project_id: projects[0].id,
|
||||
list_id: lists[0].id,
|
||||
})
|
||||
TaskFactory.truncate()
|
||||
UserProjectFactory.truncate()
|
||||
UserListFactory.truncate()
|
||||
})
|
||||
|
||||
it('Should be created new', () => {
|
||||
cy.visit('/projects/1/list')
|
||||
cy.visit('/lists/1/list')
|
||||
cy.get('.input[placeholder="Add a new task…"')
|
||||
.type('New Task')
|
||||
cy.get('.button')
|
||||
|
@ -72,11 +73,11 @@ describe('Task', () => {
|
|||
.should('contain', 'New Task')
|
||||
})
|
||||
|
||||
it('Inserts new tasks at the top of the project', () => {
|
||||
it('Inserts new tasks at the top of the list', () => {
|
||||
TaskFactory.create(1)
|
||||
|
||||
cy.visit('/projects/1/list')
|
||||
cy.get('.project-is-empty-notice')
|
||||
cy.visit('/lists/1/list')
|
||||
cy.get('.list-is-empty-notice')
|
||||
.should('not.exist')
|
||||
cy.get('.input[placeholder="Add a new task…"')
|
||||
.type('New Task')
|
||||
|
@ -93,8 +94,8 @@ describe('Task', () => {
|
|||
it('Marks a task as done', () => {
|
||||
TaskFactory.create(1)
|
||||
|
||||
cy.visit('/projects/1/list')
|
||||
cy.get('.tasks .task .fancycheckbox')
|
||||
cy.visit('/lists/1/list')
|
||||
cy.get('.tasks .task .fancycheckbox label.check')
|
||||
.first()
|
||||
.click()
|
||||
cy.get('.global-notification')
|
||||
|
@ -104,65 +105,25 @@ describe('Task', () => {
|
|||
it('Can add a task to favorites', () => {
|
||||
TaskFactory.create(1)
|
||||
|
||||
cy.visit('/projects/1/list')
|
||||
cy.visit('/lists/1/list')
|
||||
cy.get('.tasks .task .favorite')
|
||||
.first()
|
||||
.click()
|
||||
cy.get('.menu-container')
|
||||
cy.get('.menu.namespaces-lists')
|
||||
.should('contain', 'Favorites')
|
||||
})
|
||||
|
||||
it('Should show a task description icon if the task has a description', () => {
|
||||
cy.intercept(Cypress.env('API_URL') + '/projects/1/tasks**').as('loadTasks')
|
||||
TaskFactory.create(1, {
|
||||
description: 'Lorem Ipsum',
|
||||
})
|
||||
|
||||
cy.visit('/projects/1/list')
|
||||
cy.wait('@loadTasks')
|
||||
|
||||
cy.get('.tasks .task .project-task-icon')
|
||||
.should('exist')
|
||||
})
|
||||
|
||||
it('Should not show a task description icon if the task has an empty description', () => {
|
||||
cy.intercept(Cypress.env('API_URL') + '/projects/1/tasks**').as('loadTasks')
|
||||
TaskFactory.create(1, {
|
||||
description: '',
|
||||
})
|
||||
|
||||
cy.visit('/projects/1/list')
|
||||
cy.wait('@loadTasks')
|
||||
|
||||
cy.get('.tasks .task .project-task-icon')
|
||||
.should('not.exist')
|
||||
})
|
||||
|
||||
it('Should not show a task description icon if the task has a description containing only an empty p tag', () => {
|
||||
cy.intercept(Cypress.env('API_URL') + '/projects/1/tasks**').as('loadTasks')
|
||||
TaskFactory.create(1, {
|
||||
description: '<p></p>',
|
||||
})
|
||||
|
||||
cy.visit('/projects/1/list')
|
||||
cy.wait('@loadTasks')
|
||||
|
||||
cy.get('.tasks .task .project-task-icon')
|
||||
.should('not.exist')
|
||||
})
|
||||
|
||||
describe('Task Detail View', () => {
|
||||
beforeEach(() => {
|
||||
TaskCommentFactory.truncate()
|
||||
LabelTaskFactory.truncate()
|
||||
TaskAttachmentFactory.truncate()
|
||||
})
|
||||
|
||||
it('Shows all task details', () => {
|
||||
const tasks = TaskFactory.create(1, {
|
||||
id: 1,
|
||||
index: 1,
|
||||
description: 'Lorem ipsum dolor sit amet.',
|
||||
description: 'Lorem ipsum dolor sit amet.'
|
||||
})
|
||||
cy.visit(`/tasks/${tasks[0].id}`)
|
||||
|
||||
|
@ -171,7 +132,8 @@ describe('Task', () => {
|
|||
cy.get('.task-view h1.title.task-id')
|
||||
.should('contain', '#1')
|
||||
cy.get('.task-view h6.subtitle')
|
||||
.should('contain', projects[0].title)
|
||||
.should('contain', namespaces[0].title)
|
||||
.should('contain', lists[0].title)
|
||||
cy.get('.task-view .details.content.description')
|
||||
.should('contain', tasks[0].description)
|
||||
cy.get('.task-view .action-buttons p.created')
|
||||
|
@ -183,7 +145,7 @@ describe('Task', () => {
|
|||
id: 1,
|
||||
index: 1,
|
||||
done: true,
|
||||
done_at: new Date().toISOString(),
|
||||
done_at: formatISO(new Date())
|
||||
})
|
||||
cy.visit(`/tasks/${tasks[0].id}`)
|
||||
|
||||
|
@ -216,33 +178,33 @@ describe('Task', () => {
|
|||
.should('contain', 'Mark as undone')
|
||||
})
|
||||
|
||||
it('Shows a task identifier since the project has one', () => {
|
||||
const projects = ProjectFactory.create(1, {
|
||||
it('Shows a task identifier since the list has one', () => {
|
||||
const lists = ListFactory.create(1, {
|
||||
id: 1,
|
||||
identifier: 'TEST',
|
||||
})
|
||||
const tasks = TaskFactory.create(1, {
|
||||
id: 1,
|
||||
project_id: projects[0].id,
|
||||
list_id: lists[0].id,
|
||||
index: 1,
|
||||
})
|
||||
|
||||
cy.visit(`/tasks/${tasks[0].id}`)
|
||||
|
||||
cy.get('.task-view h1.title.task-id')
|
||||
.should('contain', `${projects[0].identifier}-${tasks[0].index}`)
|
||||
.should('contain', `${lists[0].identifier}-${tasks[0].index}`)
|
||||
})
|
||||
|
||||
it('Can edit the description', () => {
|
||||
const tasks = TaskFactory.create(1, {
|
||||
id: 1,
|
||||
description: 'Lorem ipsum dolor sit amet.',
|
||||
description: 'Lorem ipsum dolor sit amet.'
|
||||
})
|
||||
cy.visit(`/tasks/${tasks[0].id}`)
|
||||
|
||||
cy.get('.task-view .details.content.description .tiptap button.done-edit')
|
||||
cy.get('.task-view .details.content.description .editor button')
|
||||
.click()
|
||||
cy.get('.task-view .details.content.description .tiptap__editor .tiptap.ProseMirror')
|
||||
cy.get('.task-view .details.content.description .editor .vue-easymde .EasyMDEContainer .CodeMirror-scroll')
|
||||
.type('{selectall}New Description')
|
||||
cy.get('[data-cy="saveEditor"]')
|
||||
.contains('Save')
|
||||
|
@ -253,52 +215,13 @@ describe('Task', () => {
|
|||
.should('exist')
|
||||
})
|
||||
|
||||
it('Shows an empty editor when the description of a task is empty', () => {
|
||||
const tasks = TaskFactory.create(1, {
|
||||
id: 1,
|
||||
description: '',
|
||||
})
|
||||
cy.visit(`/tasks/${tasks[0].id}`)
|
||||
|
||||
cy.get('.task-view .details.content.description .tiptap.ProseMirror p')
|
||||
.should('have.attr', 'data-placeholder')
|
||||
cy.get('.task-view .details.content.description .tiptap button.done-edit')
|
||||
.should('not.exist')
|
||||
})
|
||||
|
||||
it('Shows a preview editor when the description of a task is not empty', () => {
|
||||
const tasks = TaskFactory.create(1, {
|
||||
id: 1,
|
||||
description: 'Lorem Ipsum dolor sit amet',
|
||||
})
|
||||
cy.visit(`/tasks/${tasks[0].id}`)
|
||||
|
||||
cy.get('.task-view .details.content.description .tiptap.ProseMirror p')
|
||||
.should('not.have.attr', 'data-placeholder')
|
||||
cy.get('.task-view .details.content.description .tiptap button.done-edit')
|
||||
.should('exist')
|
||||
})
|
||||
|
||||
it('Shows a preview editor when the description of a task contains html', () => {
|
||||
const tasks = TaskFactory.create(1, {
|
||||
id: 1,
|
||||
description: '<p>Lorem Ipsum dolor sit amet</p>',
|
||||
})
|
||||
cy.visit(`/tasks/${tasks[0].id}`)
|
||||
|
||||
cy.get('.task-view .details.content.description .tiptap.ProseMirror p')
|
||||
.should('not.have.attr', 'data-placeholder')
|
||||
cy.get('.task-view .details.content.description .tiptap button.done-edit')
|
||||
.should('exist')
|
||||
})
|
||||
|
||||
it('Can add a new comment', () => {
|
||||
const tasks = TaskFactory.create(1, {
|
||||
id: 1,
|
||||
})
|
||||
cy.visit(`/tasks/${tasks[0].id}`)
|
||||
|
||||
cy.get('.task-view .comments .media.comment .tiptap__editor .tiptap.ProseMirror')
|
||||
cy.get('.task-view .comments .media.comment .editor .vue-easymde .EasyMDEContainer .CodeMirror-scroll')
|
||||
.should('be.visible')
|
||||
.type('{selectall}New Comment')
|
||||
cy.get('.task-view .comments .media.comment .button:not([disabled])')
|
||||
|
@ -306,20 +229,20 @@ describe('Task', () => {
|
|||
.should('be.visible')
|
||||
.click()
|
||||
|
||||
cy.get('.task-view .comments .media.comment .tiptap__editor')
|
||||
cy.get('.task-view .comments .media.comment .editor')
|
||||
.should('contain', 'New Comment')
|
||||
cy.get('.global-notification')
|
||||
.should('contain', 'Success')
|
||||
})
|
||||
|
||||
it('Can move a task to another project', () => {
|
||||
const projects = ProjectFactory.create(2)
|
||||
it('Can move a task to another list', () => {
|
||||
const lists = ListFactory.create(2)
|
||||
BucketFactory.create(2, {
|
||||
project_id: '{increment}',
|
||||
list_id: '{increment}'
|
||||
})
|
||||
const tasks = TaskFactory.create(1, {
|
||||
id: 1,
|
||||
project_id: projects[0].id,
|
||||
list_id: lists[0].id,
|
||||
})
|
||||
cy.visit(`/tasks/${tasks[0].id}`)
|
||||
|
||||
|
@ -327,7 +250,7 @@ describe('Task', () => {
|
|||
.contains('Move')
|
||||
.click()
|
||||
cy.get('.task-view .content.details .field .multiselect.control .input-wrapper input')
|
||||
.type(`${projects[1].title}{enter}`)
|
||||
.type(`${lists[1].title}{enter}`)
|
||||
// The requests happen with a 200ms timeout. Because of that, the results are not yet there when cypress
|
||||
// presses enter and we can't simulate pressing on enter to select the item.
|
||||
cy.get('.task-view .content.details .field .multiselect.control .search-results')
|
||||
|
@ -336,7 +259,8 @@ describe('Task', () => {
|
|||
.click()
|
||||
|
||||
cy.get('.task-view h6.subtitle')
|
||||
.should('contain', projects[1].title)
|
||||
.should('contain', namespaces[0].title)
|
||||
.should('contain', lists[1].title)
|
||||
cy.get('.global-notification')
|
||||
.should('contain', 'Success')
|
||||
})
|
||||
|
@ -344,7 +268,7 @@ describe('Task', () => {
|
|||
it('Can delete a task', () => {
|
||||
const tasks = TaskFactory.create(1, {
|
||||
id: 1,
|
||||
project_id: 1,
|
||||
list_id: 1,
|
||||
})
|
||||
cy.visit(`/tasks/${tasks[0].id}`)
|
||||
|
||||
|
@ -361,17 +285,17 @@ describe('Task', () => {
|
|||
cy.get('.global-notification')
|
||||
.should('contain', 'Success')
|
||||
cy.url()
|
||||
.should('contain', `/projects/${tasks[0].project_id}/`)
|
||||
.should('contain', `/lists/${tasks[0].list_id}/`)
|
||||
})
|
||||
|
||||
it('Can add an assignee to a task', () => {
|
||||
const users = UserFactory.create(5)
|
||||
const tasks = TaskFactory.create(1, {
|
||||
id: 1,
|
||||
project_id: 1,
|
||||
list_id: 1,
|
||||
})
|
||||
UserProjectFactory.create(5, {
|
||||
project_id: 1,
|
||||
UserListFactory.create(5, {
|
||||
list_id: 1,
|
||||
user_id: '{increment}',
|
||||
})
|
||||
|
||||
|
@ -396,10 +320,10 @@ describe('Task', () => {
|
|||
const users = UserFactory.create(2)
|
||||
const tasks = TaskFactory.create(1, {
|
||||
id: 1,
|
||||
project_id: 1,
|
||||
list_id: 1,
|
||||
})
|
||||
UserProjectFactory.create(5, {
|
||||
project_id: 1,
|
||||
UserListFactory.create(5, {
|
||||
list_id: 1,
|
||||
user_id: '{increment}',
|
||||
})
|
||||
TaskAssigneeFactory.create(1, {
|
||||
|
@ -422,7 +346,7 @@ describe('Task', () => {
|
|||
it('Can add a new label to a task', () => {
|
||||
const tasks = TaskFactory.create(1, {
|
||||
id: 1,
|
||||
project_id: 1,
|
||||
list_id: 1,
|
||||
})
|
||||
LabelFactory.truncate()
|
||||
const newLabelText = 'some new label'
|
||||
|
@ -450,7 +374,7 @@ describe('Task', () => {
|
|||
it('Can add an existing label to a task', () => {
|
||||
const tasks = TaskFactory.create(1, {
|
||||
id: 1,
|
||||
project_id: 1,
|
||||
list_id: 1,
|
||||
})
|
||||
const labels = LabelFactory.create(1)
|
||||
LabelTaskFactory.truncate()
|
||||
|
@ -459,27 +383,27 @@ describe('Task', () => {
|
|||
|
||||
addLabelToTaskAndVerify(labels[0].title)
|
||||
})
|
||||
|
||||
|
||||
it('Can add a label to a task and it shows up on the kanban board afterwards', () => {
|
||||
const tasks = TaskFactory.create(1, {
|
||||
id: 1,
|
||||
project_id: projects[0].id,
|
||||
list_id: lists[0].id,
|
||||
bucket_id: buckets[0].id,
|
||||
})
|
||||
const labels = LabelFactory.create(1)
|
||||
LabelTaskFactory.truncate()
|
||||
|
||||
cy.visit(`/projects/${projects[0].id}/kanban`)
|
||||
|
||||
|
||||
cy.visit(`/lists/${lists[0].id}/kanban`)
|
||||
|
||||
cy.get('.bucket .task')
|
||||
.contains(tasks[0].title)
|
||||
.click()
|
||||
|
||||
|
||||
addLabelToTaskAndVerify(labels[0].title)
|
||||
|
||||
|
||||
cy.get('.modal-content .close')
|
||||
.click()
|
||||
|
||||
|
||||
cy.get('.bucket .task')
|
||||
.should('contain.text', labels[0].title)
|
||||
})
|
||||
|
@ -487,7 +411,7 @@ describe('Task', () => {
|
|||
it('Can remove a label from a task', () => {
|
||||
const tasks = TaskFactory.create(1, {
|
||||
id: 1,
|
||||
project_id: 1,
|
||||
list_id: 1,
|
||||
})
|
||||
const labels = LabelFactory.create(1)
|
||||
LabelTaskFactory.create(1, {
|
||||
|
@ -497,10 +421,10 @@ describe('Task', () => {
|
|||
|
||||
cy.visit(`/tasks/${tasks[0].id}`)
|
||||
|
||||
cy.get('.task-view .details.labels-list .multiselect .input-wrapper')
|
||||
cy.getSettled('.task-view .details.labels-list .multiselect .input-wrapper')
|
||||
.should('be.visible')
|
||||
.should('contain', labels[0].title)
|
||||
cy.get('.task-view .details.labels-list .multiselect .input-wrapper')
|
||||
cy.getSettled('.task-view .details.labels-list .multiselect .input-wrapper')
|
||||
.children()
|
||||
.first()
|
||||
.get('[data-cy="taskDetail.removeLabel"]')
|
||||
|
@ -540,235 +464,7 @@ describe('Task', () => {
|
|||
cy.get('.global-notification')
|
||||
.should('contain', 'Success')
|
||||
})
|
||||
|
||||
it('Can set a due date to a specific date for a task', () => {
|
||||
const tasks = TaskFactory.create(1, {
|
||||
id: 1,
|
||||
done: false,
|
||||
})
|
||||
cy.visit(`/tasks/${tasks[0].id}`)
|
||||
|
||||
cy.get('.task-view .action-buttons .button')
|
||||
.contains('Set Due Date')
|
||||
.click()
|
||||
cy.get('.task-view .columns.details .column')
|
||||
.contains('Due Date')
|
||||
.get('.date-input .datepicker .show')
|
||||
.click()
|
||||
cy.get('.datepicker-popup .flatpickr-innerContainer .flatpickr-days .flatpickr-day.today')
|
||||
.click()
|
||||
cy.get('[data-cy="closeDatepicker"]')
|
||||
.contains('Confirm')
|
||||
.click()
|
||||
|
||||
const today = new Date()
|
||||
const day = today.toLocaleString('default', {day: 'numeric'})
|
||||
const month = today.toLocaleString('default', {month: 'short'})
|
||||
const year = today.toLocaleString('default', {year: 'numeric'})
|
||||
const date = `${day} ${month} ${year}, 12:00:00`
|
||||
cy.get('.task-view .columns.details .column')
|
||||
.contains('Due Date')
|
||||
.get('.date-input .datepicker-popup')
|
||||
.should('not.exist')
|
||||
cy.get('.task-view .columns.details .column')
|
||||
.contains('Due Date')
|
||||
.get('.date-input')
|
||||
.should('contain.text', date)
|
||||
cy.get('.global-notification')
|
||||
.should('contain', 'Success')
|
||||
})
|
||||
|
||||
it('Can change a due date to a specific date for a task', () => {
|
||||
const dueDate = new Date()
|
||||
dueDate.setHours(12)
|
||||
dueDate.setMinutes(0)
|
||||
dueDate.setSeconds(0)
|
||||
dueDate.setDate(1)
|
||||
const tasks = TaskFactory.create(1, {
|
||||
id: 1,
|
||||
done: false,
|
||||
due_date: dueDate.toISOString(),
|
||||
})
|
||||
cy.visit(`/tasks/${tasks[0].id}`)
|
||||
|
||||
cy.get('.task-view .action-buttons .button')
|
||||
.contains('Set Due Date')
|
||||
.click()
|
||||
cy.get('.task-view .columns.details .column')
|
||||
.contains('Due Date')
|
||||
.get('.date-input .datepicker .show')
|
||||
.click()
|
||||
cy.get('.datepicker-popup .flatpickr-innerContainer .flatpickr-days .flatpickr-day.today')
|
||||
.click()
|
||||
cy.get('[data-cy="closeDatepicker"]')
|
||||
.contains('Confirm')
|
||||
.click()
|
||||
|
||||
const today = new Date()
|
||||
const day = today.toLocaleString('default', {day: 'numeric'})
|
||||
const month = today.toLocaleString('default', {month: 'short'})
|
||||
const year = today.toLocaleString('default', {year: 'numeric'})
|
||||
const date = `${day} ${month} ${year}, 12:00:00`
|
||||
cy.get('.task-view .columns.details .column')
|
||||
.contains('Due Date')
|
||||
.get('.date-input .datepicker-popup')
|
||||
.should('not.exist')
|
||||
cy.get('.task-view .columns.details .column')
|
||||
.contains('Due Date')
|
||||
.get('.date-input')
|
||||
.should('contain.text', date)
|
||||
cy.get('.global-notification')
|
||||
.should('contain', 'Success')
|
||||
})
|
||||
|
||||
it('Can set a reminder', () => {
|
||||
TaskReminderFactory.truncate()
|
||||
const tasks = TaskFactory.create(1, {
|
||||
id: 1,
|
||||
done: false,
|
||||
})
|
||||
cy.visit(`/tasks/${tasks[0].id}`)
|
||||
|
||||
cy.get('.task-view .action-buttons .button')
|
||||
.contains('Set Reminders')
|
||||
.click()
|
||||
cy.get('.task-view .columns.details .column button')
|
||||
.contains('Add a new reminder')
|
||||
.click()
|
||||
cy.get('.datepicker__quick-select-date')
|
||||
.contains('Tomorrow')
|
||||
.click()
|
||||
|
||||
cy.get('.reminder-options-popup')
|
||||
.should('not.be.visible')
|
||||
cy.get('.global-notification')
|
||||
.should('contain', 'Success')
|
||||
})
|
||||
|
||||
it('Allows to set a relative reminder when the task already has a due date', () => {
|
||||
TaskReminderFactory.truncate()
|
||||
const tasks = TaskFactory.create(1, {
|
||||
id: 1,
|
||||
done: false,
|
||||
due_date: (new Date()).toISOString(),
|
||||
})
|
||||
cy.visit(`/tasks/${tasks[0].id}`)
|
||||
|
||||
cy.get('.task-view .action-buttons .button')
|
||||
.contains('Set Reminders')
|
||||
.click()
|
||||
cy.get('.task-view .columns.details .column button')
|
||||
.contains('Add a new reminder')
|
||||
.click()
|
||||
cy.get('.datepicker__quick-select-date')
|
||||
.should('not.exist')
|
||||
cy.get('.reminder-options-popup .card-content')
|
||||
.should('contain', '1 day before Due Date')
|
||||
cy.get('.reminder-options-popup .card-content')
|
||||
.contains('1 day before Due Date')
|
||||
.click()
|
||||
|
||||
cy.get('.reminder-options-popup')
|
||||
.should('not.be.visible')
|
||||
cy.get('.global-notification')
|
||||
.should('contain', 'Success')
|
||||
})
|
||||
|
||||
it('Allows to set a relative reminder when the task already has a start date', () => {
|
||||
TaskReminderFactory.truncate()
|
||||
const tasks = TaskFactory.create(1, {
|
||||
id: 1,
|
||||
done: false,
|
||||
start_date: (new Date()).toISOString(),
|
||||
})
|
||||
cy.visit(`/tasks/${tasks[0].id}`)
|
||||
|
||||
cy.get('.task-view .action-buttons .button')
|
||||
.contains('Set Reminders')
|
||||
.click()
|
||||
cy.get('.task-view .columns.details .column button')
|
||||
.contains('Add a new reminder')
|
||||
.click()
|
||||
cy.get('.datepicker__quick-select-date')
|
||||
.should('not.exist')
|
||||
cy.get('.reminder-options-popup .card-content')
|
||||
.should('contain', '1 day before Start Date')
|
||||
cy.get('.reminder-options-popup .card-content')
|
||||
.contains('1 day before Start Date')
|
||||
.click()
|
||||
|
||||
cy.get('.reminder-options-popup')
|
||||
.should('not.be.visible')
|
||||
cy.get('.global-notification')
|
||||
.should('contain', 'Success')
|
||||
})
|
||||
|
||||
it('Allows to set a custom relative reminder when the task already has a due date', () => {
|
||||
TaskReminderFactory.truncate()
|
||||
const tasks = TaskFactory.create(1, {
|
||||
id: 1,
|
||||
done: false,
|
||||
due_date: (new Date()).toISOString(),
|
||||
})
|
||||
cy.visit(`/tasks/${tasks[0].id}`)
|
||||
|
||||
cy.get('.task-view .action-buttons .button')
|
||||
.contains('Set Reminders')
|
||||
.click()
|
||||
cy.get('.task-view .columns.details .column button')
|
||||
.contains('Add a new reminder')
|
||||
.click()
|
||||
cy.get('.datepicker__quick-select-date')
|
||||
.should('not.exist')
|
||||
cy.get('.reminder-options-popup .card-content')
|
||||
.contains('Custom')
|
||||
.click()
|
||||
cy.get('.reminder-options-popup .card-content .reminder-period input')
|
||||
.first()
|
||||
.type('{selectall}10')
|
||||
cy.get('.reminder-options-popup .card-content .reminder-period select')
|
||||
.first()
|
||||
.select('days')
|
||||
cy.get('.reminder-options-popup .card-content button')
|
||||
.contains('Confirm')
|
||||
.click()
|
||||
|
||||
cy.get('.reminder-options-popup')
|
||||
.should('not.be.visible')
|
||||
cy.get('.global-notification')
|
||||
.should('contain', 'Success')
|
||||
})
|
||||
|
||||
it('Allows to set a fixed reminder when the task already has a due date', () => {
|
||||
TaskReminderFactory.truncate()
|
||||
const tasks = TaskFactory.create(1, {
|
||||
id: 1,
|
||||
done: false,
|
||||
due_date: (new Date()).toISOString(),
|
||||
})
|
||||
cy.visit(`/tasks/${tasks[0].id}`)
|
||||
|
||||
cy.get('.task-view .action-buttons .button')
|
||||
.contains('Set Reminders')
|
||||
.click()
|
||||
cy.get('.task-view .columns.details .column button')
|
||||
.contains('Add a new reminder')
|
||||
.click()
|
||||
cy.get('.datepicker__quick-select-date')
|
||||
.should('not.exist')
|
||||
cy.get('.reminder-options-popup .card-content')
|
||||
.contains('Date and time')
|
||||
.click()
|
||||
cy.get('.datepicker__quick-select-date')
|
||||
.contains('Tomorrow')
|
||||
.click()
|
||||
|
||||
cy.get('.reminder-options-popup')
|
||||
.should('not.be.visible')
|
||||
cy.get('.global-notification')
|
||||
.should('contain', 'Success')
|
||||
})
|
||||
|
||||
|
||||
it('Can set a priority for a task', () => {
|
||||
const tasks = TaskFactory.create(1, {
|
||||
id: 1,
|
||||
|
@ -806,7 +502,7 @@ describe('Task', () => {
|
|||
.select('50%')
|
||||
cy.get('.global-notification')
|
||||
.should('contain', 'Success')
|
||||
|
||||
|
||||
cy.wait(200)
|
||||
|
||||
cy.get('.task-view .columns.details .column')
|
||||
|
@ -815,7 +511,7 @@ describe('Task', () => {
|
|||
.should('be.visible')
|
||||
.should('have.value', '0.5')
|
||||
})
|
||||
|
||||
|
||||
it('Can add an attachment to a task', () => {
|
||||
TaskAttachmentFactory.truncate()
|
||||
const tasks = TaskFactory.create(1, {
|
||||
|
@ -830,13 +526,13 @@ describe('Task', () => {
|
|||
TaskAttachmentFactory.truncate()
|
||||
const tasks = TaskFactory.create(1, {
|
||||
id: 1,
|
||||
project_id: projects[0].id,
|
||||
list_id: lists[0].id,
|
||||
bucket_id: buckets[0].id,
|
||||
})
|
||||
const labels = LabelFactory.create(1)
|
||||
LabelTaskFactory.truncate()
|
||||
|
||||
cy.visit(`/projects/${projects[0].id}/kanban`)
|
||||
cy.visit(`/lists/${lists[0].id}/kanban`)
|
||||
|
||||
cy.get('.bucket .task')
|
||||
.contains(tasks[0].title)
|
||||
|
@ -850,119 +546,35 @@ describe('Task', () => {
|
|||
cy.get('.bucket .task .footer .icon svg.fa-paperclip')
|
||||
.should('exist')
|
||||
})
|
||||
|
||||
|
||||
it('Can check items off a checklist', () => {
|
||||
const tasks = TaskFactory.create(1, {
|
||||
id: 1,
|
||||
description: `
|
||||
<ul data-type="taskList">
|
||||
<li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label>
|
||||
<div><p>First Item</p></div>
|
||||
</li>
|
||||
<li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label>
|
||||
<div><p>Second Item</p></div>
|
||||
</li>
|
||||
<li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label>
|
||||
<div><p>Third Item</p></div>
|
||||
</li>
|
||||
<li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label>
|
||||
<div><p>Fourth Item</p></div>
|
||||
</li>
|
||||
<li data-checked="true" data-type="taskItem"><label><input type="checkbox"><span></span></label>
|
||||
<div><p>Fifth Item</p></div>
|
||||
</li>
|
||||
</ul>`,
|
||||
This is a checklist:
|
||||
|
||||
* [ ] one item
|
||||
* [ ] another item
|
||||
* [ ] third item
|
||||
* [ ] fourth item
|
||||
* [x] and this one is already done
|
||||
`,
|
||||
})
|
||||
cy.visit(`/tasks/${tasks[0].id}`)
|
||||
|
||||
|
||||
cy.get('.task-view .checklist-summary')
|
||||
.should('contain.text', '1 of 5 tasks')
|
||||
cy.get('.tiptap__editor ul > li input[type=checkbox]')
|
||||
cy.get('.editor .content ul > li input[type=checkbox]')
|
||||
.eq(2)
|
||||
.click()
|
||||
|
||||
cy.get('.task-view .details.content.description h3 span.is-small.has-text-success')
|
||||
.contains('Saved!')
|
||||
.should('exist')
|
||||
cy.get('.tiptap__editor ul > li input[type=checkbox]')
|
||||
|
||||
cy.get('.editor .content ul > li input[type=checkbox]')
|
||||
.eq(2)
|
||||
.should('be.checked')
|
||||
cy.get('.tiptap__editor input[type=checkbox]')
|
||||
cy.get('.editor .content input[type=checkbox]')
|
||||
.should('have.length', 5)
|
||||
cy.get('.task-view .checklist-summary')
|
||||
.should('contain.text', '2 of 5 tasks')
|
||||
})
|
||||
|
||||
it('Should use the editor to render description', () => {
|
||||
const tasks = TaskFactory.create(1, {
|
||||
id: 1,
|
||||
description: `
|
||||
<h1>Lorem Ipsum</h1>
|
||||
<p>Dolor sit amet</p>
|
||||
<ul data-type="taskList">
|
||||
<li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label>
|
||||
<div><p>First Item</p></div>
|
||||
</li>
|
||||
<li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label>
|
||||
<div><p>Second Item</p></div>
|
||||
</li>
|
||||
</ul>`,
|
||||
})
|
||||
cy.visit(`/tasks/${tasks[0].id}`)
|
||||
|
||||
cy.get('.tiptap__editor ul > li input[type=checkbox]')
|
||||
.should('exist')
|
||||
cy.get('.tiptap__editor h1')
|
||||
.contains('Lorem Ipsum')
|
||||
.should('exist')
|
||||
cy.get('.tiptap__editor p')
|
||||
.contains('Dolor sit amet')
|
||||
.should('exist')
|
||||
})
|
||||
|
||||
it('Should render an image from attachment', async () => {
|
||||
|
||||
TaskAttachmentFactory.truncate()
|
||||
|
||||
const tasks = TaskFactory.create(1, {
|
||||
id: 1,
|
||||
description: '',
|
||||
})
|
||||
|
||||
cy.readFile('cypress/fixtures/image.jpg', null).then(file => {
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('files', new Blob([file]), 'image.jpg')
|
||||
|
||||
cy.request({
|
||||
method: 'PUT',
|
||||
url: `${Cypress.env('API_URL')}/tasks/${tasks[0].id}/attachments`,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${window.localStorage.getItem('token')}`,
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
body: formData,
|
||||
})
|
||||
.then(({body}) => {
|
||||
const dec = new TextDecoder('utf-8')
|
||||
const {success} = JSON.parse(dec.decode(body))
|
||||
|
||||
TaskFactory.create(1, {
|
||||
id: 1,
|
||||
description: `<img src="${Cypress.env('API_URL')}/tasks/${tasks[0].id}/attachments/${success[0].id}" alt="test image">`,
|
||||
})
|
||||
|
||||
cy.visit(`/tasks/${tasks[0].id}`)
|
||||
|
||||
cy.get('.tiptap__editor img')
|
||||
.should('be.visible')
|
||||
.and(($img) => {
|
||||
// "naturalWidth" and "naturalHeight" are set when the image loads
|
||||
expect($img[0].naturalWidth).to.be.greaterThan(0)
|
||||
})
|
||||
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -1,12 +0,0 @@
|
|||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"include": ["./**/*", "../support/**/*", "../factories/**/*"],
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"isolatedModules": false,
|
||||
"target": "ES2015",
|
||||
"lib": ["ESNext", "dom"],
|
||||
"types": ["cypress"],
|
||||
"ignoreDeprecations": "5.0"
|
||||
}
|
||||
}
|
|
@ -1,41 +1,41 @@
|
|||
import {UserFactory} from '../../factories/user'
|
||||
import {ProjectFactory} from '../../factories/project'
|
||||
|
||||
const testAndAssertFailed = fixture => {
|
||||
cy.intercept(Cypress.env('API_URL') + '/login*').as('login')
|
||||
|
||||
cy.visit('/login')
|
||||
cy.get('input[id=username]').type(fixture.username)
|
||||
cy.get('input[id=password]').type(fixture.password)
|
||||
cy.get('.button').contains('Login').click()
|
||||
|
||||
cy.wait('@login')
|
||||
cy.wait(5000) // It can take waaaayy too long to log the user in
|
||||
cy.url().should('include', '/')
|
||||
cy.get('div.message.danger').contains('Wrong username or password.')
|
||||
}
|
||||
|
||||
const credentials = {
|
||||
username: 'test',
|
||||
password: '1234',
|
||||
}
|
||||
|
||||
function login() {
|
||||
cy.get('input[id=username]').type(credentials.username)
|
||||
cy.get('input[id=password]').type(credentials.password)
|
||||
cy.get('.button').contains('Login').click()
|
||||
cy.url().should('include', '/')
|
||||
}
|
||||
|
||||
context('Login', () => {
|
||||
beforeEach(() => {
|
||||
UserFactory.create(1, {username: credentials.username})
|
||||
UserFactory.create(1, {
|
||||
username: 'test',
|
||||
})
|
||||
cy.visit('/', {
|
||||
onBeforeLoad(win) {
|
||||
win.localStorage.removeItem('token')
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('Should log in with the right credentials', () => {
|
||||
const fixture = {
|
||||
username: 'test',
|
||||
password: '1234',
|
||||
}
|
||||
|
||||
cy.visit('/login')
|
||||
login()
|
||||
cy.get('input[id=username]').type(fixture.username)
|
||||
cy.get('input[id=password]').type(fixture.password)
|
||||
cy.get('.button').contains('Login').click()
|
||||
cy.url().should('include', '/')
|
||||
cy.clock(1625656161057) // 13:00
|
||||
cy.get('h2').should('contain', `Hi ${credentials.username}!`)
|
||||
cy.get('h2').should('contain', `Hi ${fixture.username}!`)
|
||||
})
|
||||
|
||||
it('Should fail with a bad password', () => {
|
||||
|
@ -60,15 +60,4 @@ context('Login', () => {
|
|||
cy.visit('/')
|
||||
cy.url().should('include', '/login')
|
||||
})
|
||||
|
||||
it('Should redirect to the previous route after logging in', () => {
|
||||
const projects = ProjectFactory.create(1)
|
||||
cy.visit(`/projects/${projects[0].id}/list`)
|
||||
|
||||
cy.url().should('include', '/login')
|
||||
|
||||
login()
|
||||
|
||||
cy.url().should('include', `/projects/${projects[0].id}/list`)
|
||||
})
|
||||
})
|
||||
|
|
|
@ -1,17 +1,15 @@
|
|||
import {createFakeUserAndLogin} from '../../support/authenticateUser'
|
||||
import {createProjects} from '../project/prepareProjects'
|
||||
import '../../support/authenticateUser'
|
||||
import {createLists} from '../list/prepareLists'
|
||||
|
||||
function logout() {
|
||||
cy.get('.navbar .username-dropdown-trigger')
|
||||
cy.get('.navbar .user .username')
|
||||
.click()
|
||||
cy.get('.navbar .dropdown-item')
|
||||
cy.get('.navbar .user .dropdown-menu .dropdown-item')
|
||||
.contains('Logout')
|
||||
.click()
|
||||
}
|
||||
|
||||
describe('Log out', () => {
|
||||
createFakeUserAndLogin()
|
||||
|
||||
it('Logs the user out', () => {
|
||||
cy.visit('/')
|
||||
|
||||
|
@ -26,21 +24,21 @@ describe('Log out', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it.skip('Should clear the project history after logging the user out', () => {
|
||||
const projects = createProjects()
|
||||
cy.visit(`/projects/${projects[0].id}`)
|
||||
it.skip('Should clear the list history after logging the user out', () => {
|
||||
const lists = createLists()
|
||||
cy.visit(`/lists/${lists[0].id}`)
|
||||
.then(() => {
|
||||
expect(localStorage.getItem('projectHistory')).to.not.eq(null)
|
||||
expect(localStorage.getItem('listHistory')).to.not.eq(null)
|
||||
})
|
||||
|
||||
logout()
|
||||
|
||||
cy.wait(1000) // This makes re-loading of the project and associated entities (and the resulting error) visible
|
||||
cy.wait(1000) // This makes re-loading of the list and associated entities (and the resulting error) visible
|
||||
|
||||
cy.url()
|
||||
.should('contain', '/login')
|
||||
.then(() => {
|
||||
expect(localStorage.getItem('projectHistory')).to.eq(null)
|
||||
expect(localStorage.getItem('listHistory')).to.eq(null)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -17,7 +17,7 @@ context('Registration', () => {
|
|||
it('Should work without issues', () => {
|
||||
const fixture = {
|
||||
username: 'testuser',
|
||||
password: '12345678',
|
||||
password: '123456',
|
||||
email: 'testuser@example.com',
|
||||
}
|
||||
|
||||
|
@ -31,10 +31,10 @@ context('Registration', () => {
|
|||
cy.get('h2').should('contain', `Hi ${fixture.username}!`)
|
||||
})
|
||||
|
||||
it('Should fail', () => {
|
||||
it.only('Should fail', () => {
|
||||
const fixture = {
|
||||
username: 'test',
|
||||
password: '12345678',
|
||||
password: '123456',
|
||||
email: 'testuser@example.com',
|
||||
}
|
||||
|
||||
|
|
|
@ -1,7 +1,11 @@
|
|||
import {createFakeUserAndLogin} from '../../support/authenticateUser'
|
||||
import {UserFactory} from '../../factories/user'
|
||||
|
||||
import '../../support/authenticateUser'
|
||||
|
||||
describe('User Settings', () => {
|
||||
createFakeUserAndLogin()
|
||||
beforeEach(() => {
|
||||
UserFactory.create(1)
|
||||
})
|
||||
|
||||
it('Changes the user avatar', () => {
|
||||
cy.intercept(`${Cypress.env('API_URL')}/user/settings/avatar/upload`).as('uploadAvatar')
|
||||
|
@ -37,7 +41,7 @@ describe('User Settings', () => {
|
|||
|
||||
cy.get('.global-notification')
|
||||
.should('contain', 'Success')
|
||||
cy.get('.navbar .username-dropdown-trigger .username')
|
||||
cy.get('.navbar .user .username')
|
||||
.should('contain', 'Lorem Ipsum')
|
||||
})
|
||||
})
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import {faker} from '@faker-js/faker'
|
||||
import {Factory} from '../support/factory'
|
||||
import {formatISO} from 'date-fns'
|
||||
|
||||
export class BucketFactory extends Factory {
|
||||
static table = 'buckets'
|
||||
|
@ -10,10 +11,10 @@ export class BucketFactory extends Factory {
|
|||
return {
|
||||
id: '{increment}',
|
||||
title: faker.lorem.words(3),
|
||||
project_id: 1,
|
||||
list_id: 1,
|
||||
created_by_id: 1,
|
||||
created: now.toISOString(),
|
||||
updated: now.toISOString(),
|
||||
created: formatISO(now),
|
||||
updated: formatISO(now)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import {Factory} from '../support/factory'
|
||||
import {formatISO} from 'date-fns'
|
||||
|
||||
export class LabelTaskFactory extends Factory {
|
||||
static table = 'label_tasks'
|
||||
|
@ -10,7 +11,7 @@ export class LabelTaskFactory extends Factory {
|
|||
id: '{increment}',
|
||||
task_id: 1,
|
||||
label_id: 1,
|
||||
created: now.toISOString(),
|
||||
created: formatISO(now),
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
import {faker} from '@faker-js/faker'
|
||||
|
||||
import {Factory} from '../support/factory'
|
||||
import {formatISO} from 'date-fns'
|
||||
|
||||
export class LabelFactory extends Factory {
|
||||
static table = 'labels'
|
||||
|
@ -14,8 +15,8 @@ export class LabelFactory extends Factory {
|
|||
description: faker.lorem.text(10),
|
||||
hex_color: (Math.random()*0xFFFFFF<<0).toString(16), // random 6-digit hex number
|
||||
created_by_id: 1,
|
||||
created: now.toISOString(),
|
||||
updated: now.toISOString(),
|
||||
created: formatISO(now),
|
||||
updated: formatISO(now),
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,4 +1,5 @@
|
|||
import {Factory} from '../support/factory'
|
||||
import {formatISO} from "date-fns"
|
||||
import {faker} from '@faker-js/faker'
|
||||
|
||||
export class LinkShareFactory extends Factory {
|
||||
|
@ -10,12 +11,12 @@ export class LinkShareFactory extends Factory {
|
|||
return {
|
||||
id: '{increment}',
|
||||
hash: faker.random.word(32),
|
||||
project_id: 1,
|
||||
list_id: 1,
|
||||
right: 0,
|
||||
sharing_type: 0,
|
||||
shared_by_id: 1,
|
||||
created: now.toISOString(),
|
||||
updated: now.toISOString(),
|
||||
created: formatISO(now),
|
||||
updated: formatISO(now)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
import {Factory} from '../support/factory'
|
||||
import {formatISO} from "date-fns"
|
||||
import {faker} from '@faker-js/faker'
|
||||
|
||||
export class ListFactory extends Factory {
|
||||
static table = 'lists'
|
||||
|
||||
static factory() {
|
||||
const now = new Date()
|
||||
|
||||
return {
|
||||
id: '{increment}',
|
||||
title: faker.lorem.words(3),
|
||||
owner_id: 1,
|
||||
namespace_id: 1,
|
||||
created: formatISO(now),
|
||||
updated: formatISO(now)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,8 +1,9 @@
|
|||
import {Factory} from '../support/factory'
|
||||
import {faker} from '@faker-js/faker'
|
||||
import {Factory} from '../support/factory'
|
||||
import {formatISO} from 'date-fns'
|
||||
|
||||
export class ProjectFactory extends Factory {
|
||||
static table = 'projects'
|
||||
export class NamespaceFactory extends Factory {
|
||||
static table = 'namespaces'
|
||||
|
||||
static factory() {
|
||||
const now = new Date()
|
||||
|
@ -11,8 +12,8 @@ export class ProjectFactory extends Factory {
|
|||
id: '{increment}',
|
||||
title: faker.lorem.words(3),
|
||||
owner_id: 1,
|
||||
created: now.toISOString(),
|
||||
updated: now.toISOString(),
|
||||
created: formatISO(now),
|
||||
updated: formatISO(now)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
import {faker} from '@faker-js/faker'
|
||||
import {Factory} from '../support/factory'
|
||||
import {formatISO} from 'date-fns'
|
||||
|
||||
export class TaskFactory extends Factory {
|
||||
static table = 'tasks'
|
||||
|
@ -11,12 +12,12 @@ export class TaskFactory extends Factory {
|
|||
id: '{increment}',
|
||||
title: faker.lorem.words(3),
|
||||
done: false,
|
||||
project_id: 1,
|
||||
list_id: 1,
|
||||
created_by_id: 1,
|
||||
index: '{increment}',
|
||||
position: '{increment}',
|
||||
created: now.toISOString(),
|
||||
updated: now.toISOString()
|
||||
created: formatISO(now),
|
||||
updated: formatISO(now)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import {Factory} from '../support/factory'
|
||||
import {formatISO} from 'date-fns'
|
||||
|
||||
export class TaskAssigneeFactory extends Factory {
|
||||
static table = 'task_assignees'
|
||||
|
@ -10,7 +11,7 @@ export class TaskAssigneeFactory extends Factory {
|
|||
id: '{increment}',
|
||||
task_id: 1,
|
||||
user_id: 1,
|
||||
created: now.toISOString(),
|
||||
created: formatISO(now),
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,4 +1,5 @@
|
|||
import {Factory} from '../support/factory'
|
||||
import {formatISO} from 'date-fns'
|
||||
|
||||
export class TaskAttachmentFactory extends Factory {
|
||||
static table = 'task_attachments'
|
||||
|
@ -10,7 +11,7 @@ export class TaskAttachmentFactory extends Factory {
|
|||
id: '{increment}',
|
||||
task_id: 1,
|
||||
file_id: 1,
|
||||
created: now.toISOString(),
|
||||
created: formatISO(now),
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
import {faker} from '@faker-js/faker'
|
||||
|
||||
import {Factory} from '../support/factory'
|
||||
import {formatISO} from "date-fns"
|
||||
|
||||
export class TaskCommentFactory extends Factory {
|
||||
static table = 'task_comments'
|
||||
|
@ -13,8 +14,8 @@ export class TaskCommentFactory extends Factory {
|
|||
comment: faker.lorem.text(3),
|
||||
author_id: 1,
|
||||
task_id: 1,
|
||||
created: now.toISOString(),
|
||||
updated: now.toISOString()
|
||||
created: formatISO(now),
|
||||
updated: formatISO(now)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,18 +0,0 @@
|
|||
import {Factory} from '../support/factory'
|
||||
|
||||
export class TaskReminderFactory extends Factory {
|
||||
static table = 'task_reminders'
|
||||
|
||||
static factory() {
|
||||
const now = new Date()
|
||||
|
||||
return {
|
||||
id: '{increment}',
|
||||
task_id: 1,
|
||||
reminder: now.toISOString(),
|
||||
created: now.toISOString(),
|
||||
relative_to: '',
|
||||
relative_period: 0,
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
import {faker} from '@faker-js/faker'
|
||||
import {Factory} from '../support/factory'
|
||||
import {formatISO} from 'date-fns'
|
||||
|
||||
export class TeamFactory extends Factory {
|
||||
static table = 'teams'
|
||||
|
@ -10,8 +11,8 @@ export class TeamFactory extends Factory {
|
|||
return {
|
||||
name: faker.lorem.words(3),
|
||||
created_by_id: 1,
|
||||
created: now.toISOString(),
|
||||
updated: now.toISOString(),
|
||||
created: formatISO(now),
|
||||
updated: formatISO(now)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import {Factory} from '../support/factory'
|
||||
import {formatISO} from 'date-fns'
|
||||
|
||||
export class TeamMemberFactory extends Factory {
|
||||
static table = 'team_members'
|
||||
|
@ -8,7 +9,7 @@ export class TeamMemberFactory extends Factory {
|
|||
team_id: 1,
|
||||
user_id: 1,
|
||||
admin: false,
|
||||
created: new Date().toISOString(),
|
||||
created: formatISO(new Date()),
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
import {faker} from '@faker-js/faker'
|
||||
|
||||
import {Factory} from '../support/factory'
|
||||
import {formatISO} from "date-fns"
|
||||
|
||||
export class UserFactory extends Factory {
|
||||
static table = 'users'
|
||||
|
@ -14,8 +15,8 @@ export class UserFactory extends Factory {
|
|||
password: '$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.', // 1234
|
||||
status: 0,
|
||||
issuer: 'local',
|
||||
created: now.toISOString(),
|
||||
updated: now.toISOString(),
|
||||
created: formatISO(now),
|
||||
updated: formatISO(now)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
import {Factory} from '../support/factory'
|
||||
import {formatISO} from "date-fns"
|
||||
|
||||
export class UserListFactory extends Factory {
|
||||
static table = 'users_lists'
|
||||
|
||||
static factory() {
|
||||
const now = new Date()
|
||||
|
||||
return {
|
||||
id: '{increment}',
|
||||
list_id: 1,
|
||||
user_id: 1,
|
||||
right: 0,
|
||||
created: formatISO(now),
|
||||
updated: formatISO(now)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,18 +0,0 @@
|
|||
import {Factory} from '../support/factory'
|
||||
|
||||
export class UserProjectFactory extends Factory {
|
||||
static table = 'users_projects'
|
||||
|
||||
static factory() {
|
||||
const now = new Date()
|
||||
|
||||
return {
|
||||
id: '{increment}',
|
||||
project_id: 1,
|
||||
user_id: 1,
|
||||
right: 0,
|
||||
created: now.toISOString(),
|
||||
updated: now.toISOString(),
|
||||
}
|
||||
}
|
||||
}
|
|
@ -4,32 +4,26 @@
|
|||
|
||||
import {UserFactory} from '../factories/user'
|
||||
|
||||
export function login(user, cacheAcrossSpecs = false) {
|
||||
if (!user) {
|
||||
throw new Error('Needs user')
|
||||
}
|
||||
// Caching session when logging in via page visit
|
||||
cy.session(`user__${user.username}`, () => {
|
||||
cy.request('POST', `${Cypress.env('API_URL')}/login`, {
|
||||
username: user.username,
|
||||
password: '1234',
|
||||
}).then(({ body }) => {
|
||||
window.localStorage.setItem('token', body.token)
|
||||
let token
|
||||
|
||||
before(() => {
|
||||
const users = UserFactory.create(1)
|
||||
|
||||
cy.request('POST', `${Cypress.env('API_URL')}/login`, {
|
||||
username: users[0].username,
|
||||
password: '1234',
|
||||
})
|
||||
.its('body')
|
||||
.then(r => {
|
||||
token = r.token
|
||||
})
|
||||
}, {
|
||||
cacheAcrossSpecs,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export function createFakeUserAndLogin() {
|
||||
let user
|
||||
before(() => {
|
||||
user = UserFactory.create(1)[0]
|
||||
beforeEach(() => {
|
||||
cy.log(`Using token ${token} to make authenticated requests`)
|
||||
cy.visit('/', {
|
||||
onBeforeLoad(win) {
|
||||
win.localStorage.setItem('token', token)
|
||||
},
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
login(user, true)
|
||||
})
|
||||
|
||||
return user
|
||||
}
|
||||
})
|
||||
|
|
|
@ -34,4 +34,38 @@
|
|||
// visit(originalFn: CommandOriginalFn, url: string, options: Partial<VisitOptions>): Chainable<Element>
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
/**
|
||||
* Recursively gets an element, returning only after it's determined to be attached to the DOM for good.
|
||||
*
|
||||
* Source: https://github.com/cypress-io/cypress/issues/7306#issuecomment-850621378
|
||||
*/
|
||||
Cypress.Commands.add('getSettled', (selector, opts = {}) => {
|
||||
const retries = opts.retries || 3
|
||||
const delay = opts.delay || 100
|
||||
|
||||
const isAttached = (resolve, count = 0) => {
|
||||
const el = Cypress.$(selector)
|
||||
|
||||
// is element attached to the DOM?
|
||||
count = Cypress.dom.isAttached(el) ? count + 1 : 0
|
||||
|
||||
// hit our base case, return the element
|
||||
if (count >= retries) {
|
||||
return resolve(el)
|
||||
}
|
||||
|
||||
// retry after a bit of a delay
|
||||
setTimeout(() => isAttached(resolve, count), delay)
|
||||
}
|
||||
|
||||
// wrap, so we can chain cypress commands off the result
|
||||
return cy.wrap(null).then(() => {
|
||||
return new Cypress.Promise((resolve) => {
|
||||
return isAttached(resolve, 0)
|
||||
}).then((el) => {
|
||||
return cy.wrap(el)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -4,7 +4,7 @@ import {seed} from './seed'
|
|||
* A factory makes it easy to seed the database with data.
|
||||
*/
|
||||
export class Factory {
|
||||
static table: string | null = null
|
||||
static table = null
|
||||
|
||||
static factory() {
|
||||
return {}
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.web.json",
|
||||
"include": ["./integration/**/*", "./support/**/*"],
|
||||
"compilerOptions": {
|
||||
"isolatedModules": false,
|
||||
"target": "es5",
|
||||
"lib": ["es5", "dom"],
|
||||
"types": ["cypress"]
|
||||
}
|
||||
}
|
|
@ -1,18 +0,0 @@
|
|||
#!/usr/bin/env sh
|
||||
set -e
|
||||
|
||||
echo "info: API URL is $VIKUNJA_API_URL"
|
||||
echo "info: Sentry enabled: $VIKUNJA_SENTRY_ENABLED"
|
||||
|
||||
# Escape the variable to prevent sed from complaining
|
||||
VIKUNJA_API_URL="$(echo "$VIKUNJA_API_URL" | sed -r 's/([:;])/\\\1/g')"
|
||||
VIKUNJA_SENTRY_DSN="$(echo "$VIKUNJA_SENTRY_DSN" | sed -r 's/([:;])/\\\1/g')"
|
||||
|
||||
sed -ri "s:^(\s*window.API_URL\s*=)\s*.+:\1 '${VIKUNJA_API_URL}':g" /usr/share/nginx/html/index.html
|
||||
sed -ri "s:^(\s*window.SENTRY_ENABLED\s*=)\s*.+:\1 ${VIKUNJA_SENTRY_ENABLED}:g" /usr/share/nginx/html/index.html
|
||||
sed -ri "s:^(\s*window.SENTRY_DSN\s*=)\s*.+:\1 '${VIKUNJA_SENTRY_DSN}':g" /usr/share/nginx/html/index.html
|
||||
sed -ri "s:^(\s*window.PROJECT_INFINITE_NESTING_ENABLED\s*=)\s*.+:\1 '${VIKUNJA_PROJECT_INFINITE_NESTING_ENABLED}':g" /usr/share/nginx/html/index.html
|
||||
sed -ri "s:^(\s*window.ALLOW_ICON_CHANGES\s*=)\s*.+:\1 ${VIKUNJA_ALLOW_ICON_CHANGES}:g" /usr/share/nginx/html/index.html
|
||||
sed -ri "s:^(\s*window.CUSTOM_LOGO_URL\s*=)\s*.+:\1 ${VIKUNJA_CUSTOM_LOGO_URL}:g" /usr/share/nginx/html/index.html
|
||||
|
||||
date -uIseconds | xargs echo 'info: started at'
|
|
@ -1,19 +0,0 @@
|
|||
#!/usr/bin/env sh
|
||||
set -e
|
||||
|
||||
if [ ! -f "/proc/net/if_inet6" ]; then
|
||||
echo "info: IPv6 is not available! Removing IPv6 listen configuration"
|
||||
find /etc/nginx/conf.d -name '*.conf' -type f | \
|
||||
while IFS= read -r CONFIG; do
|
||||
sed -r '/^\s*listen\s*\[::\]:.+$/d' "$CONFIG" > "$CONFIG.temp"
|
||||
if ! diff -U 5 "$CONFIG" "$CONFIG.temp" > "$CONFIG.diff"; then
|
||||
echo "info: Removing IPv6 lines from $CONFIG" | \
|
||||
cat - "$CONFIG.diff"
|
||||
echo "# IPv6 is disabled because /proc/net/if_inet6 was not found" | \
|
||||
cat - "$CONFIG.temp" > "$CONFIG"
|
||||
else
|
||||
echo "info: Skipping $CONFIG because it does not have IPv6 listen"
|
||||
fi
|
||||
rm -f "$CONFIG.temp" "$CONFIG.diff"
|
||||
done
|
||||
fi
|
|
@ -1,111 +0,0 @@
|
|||
# Generated by nginxconfig.io
|
||||
# https://www.digitalocean.com/community/tools/nginx?domains.0.server.domain=localhost&domains.0.server.documentRoot=%2Fusr%2Fshare%2Fnginx%2Fhtml&domains.0.server.cdnSubdomain=true&domains.0.https.https=false&domains.0.php.php=false&domains.0.routing.index=index.html&domains.0.routing.fallbackHtml=true&domains.0.routing.fallbackPhp=false&global.performance.assetsExpiration=1d&global.performance.mediaExpiration=1d&global.performance.svgExpiration=1d&global.performance.fontsExpiration=1d&global.logging.accessLog=%2Fdev%2Fstdout&global.logging.errorLog=%2Fdev%2Fstderr%20warn&global.logging.logNotFound=true&global.nginx.user=nginx&global.nginx.pid=%2Fvar%2Frun%2Fnginx.pid&global.nginx.clientMaxBodySize=50&global.docker.dockerfile=true&global.tools.modularizedStructure=false&global.tools.symlinkVhost=false
|
||||
# and then edited manually ;)
|
||||
|
||||
pid /tmp/nginx.pid;
|
||||
worker_processes auto;
|
||||
|
||||
events {
|
||||
multi_accept on;
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
charset utf-8;
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
tcp_nodelay on;
|
||||
server_tokens off;
|
||||
types_hash_max_size 2048;
|
||||
types_hash_bucket_size 64;
|
||||
|
||||
# rootless
|
||||
client_body_temp_path /tmp/client_temp;
|
||||
proxy_temp_path /tmp/proxy_temp_path;
|
||||
fastcgi_temp_path /tmp/fastcgi_temp;
|
||||
uwsgi_temp_path /tmp/uwsgi_temp;
|
||||
scgi_temp_path /tmp/scgi_temp;
|
||||
|
||||
# MIME
|
||||
include mime.types;
|
||||
default_type application/octet-stream;
|
||||
types {
|
||||
application/manifest+json webmanifest;
|
||||
}
|
||||
|
||||
# Logging
|
||||
log_format json escape=json
|
||||
'{'
|
||||
'"bytes_sent": "$bytes_sent",'
|
||||
'"http_user_agent": "$http_user_agent",'
|
||||
'"nginx_version": "$nginx_version",'
|
||||
'"query_string": "$query_string",'
|
||||
'"realip_remote_addr": "$realip_remote_addr",'
|
||||
'"remote_addr": "$remote_addr",'
|
||||
'"remote_user": "$remote_user",'
|
||||
'"request_length": "$request_length",'
|
||||
'"request_method": "$request_method",'
|
||||
'"request_time": "$request_time",'
|
||||
'"server_addr": "$server_addr",'
|
||||
'"server_port": "$server_port",'
|
||||
'"server_protocol": "$server_protocol",'
|
||||
'"status": "$status",'
|
||||
'"time_local": "$time_local",'
|
||||
'"uri": "$uri"'
|
||||
'}';
|
||||
|
||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||
|
||||
access_log /dev/stdout main;
|
||||
error_log /dev/stderr warn;
|
||||
|
||||
keepalive_timeout 65;
|
||||
|
||||
# compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_buffers 16 8k;
|
||||
gzip_http_version 1.1;
|
||||
gzip_min_length 256;
|
||||
gzip_types
|
||||
text/plain
|
||||
text/css
|
||||
application/json
|
||||
application/x-javascript
|
||||
application/javascript
|
||||
text/xml
|
||||
application/xml
|
||||
application/xml+rss
|
||||
text/javascript
|
||||
application/vnd.ms-fontobject
|
||||
application/x-font-ttf
|
||||
font/opentype
|
||||
image/svg+xml
|
||||
image/x-icon
|
||||
audio/wav;
|
||||
|
||||
map_hash_max_size 128;
|
||||
map_hash_bucket_size 128;
|
||||
|
||||
map $sent_http_content_type $expires {
|
||||
default off;
|
||||
text/css max;
|
||||
application/javascript max;
|
||||
text/javascript max;
|
||||
application/vnd.ms-fontobject max;
|
||||
application/x-font-ttf max;
|
||||
font/opentype max;
|
||||
font/woff2 max;
|
||||
image/svg+xml max;
|
||||
image/x-icon max;
|
||||
audio/wav max;
|
||||
~images/ max;
|
||||
~font/ max;
|
||||
}
|
||||
|
||||
include /etc/nginx/conf.d/*.conf;
|
||||
}
|
|
@ -1,85 +0,0 @@
|
|||
server {
|
||||
listen ${VIKUNJA_HTTP_PORT};
|
||||
listen [::]:${VIKUNJA_HTTP_PORT};
|
||||
## Needed when behind HAProxy with SSL termination + HTTP/2 support
|
||||
listen ${VIKUNJA_HTTP2_PORT} default_server http2 proxy_protocol;
|
||||
listen [::]:${VIKUNJA_HTTP2_PORT} default_server http2 proxy_protocol;
|
||||
|
||||
server_name _;
|
||||
expires $expires;
|
||||
root /usr/share/nginx/html;
|
||||
access_log /dev/stdout ${VIKUNJA_LOG_FORMAT};
|
||||
# security headers
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "no-referrer-when-downgrade" always;
|
||||
add_header Content-Security-Policy "default-src 'self' http: https: ws: wss: data: blob: 'unsafe-inline'; frame-ancestors 'self';" always;
|
||||
add_header Permissions-Policy "interest-cohort=()" always;
|
||||
|
||||
# . files
|
||||
location ~ /\.(?!well-known) {
|
||||
deny all;
|
||||
}
|
||||
|
||||
# assume that everything else is handled by the application router, by injecting the index.html.
|
||||
location / {
|
||||
autoindex off;
|
||||
expires off;
|
||||
add_header Cache-Control "public, max-age=0, s-maxage=0, must-revalidate" always;
|
||||
try_files $uri /index.html =404;
|
||||
}
|
||||
|
||||
# Disable caching for sw
|
||||
location = /sw.js {
|
||||
autoindex off;
|
||||
expires off;
|
||||
add_header Cache-Control "public, max-age=0, s-maxage=0, must-revalidate" always;
|
||||
}
|
||||
|
||||
# Disable caching for webmanifest
|
||||
location = /manifest.webmanifest {
|
||||
autoindex off;
|
||||
expires off;
|
||||
add_header Cache-Control "public, max-age=0, s-maxage=0, must-revalidate" always;
|
||||
}
|
||||
|
||||
# favicon.ico
|
||||
location = /favicon.ico {
|
||||
log_not_found off;
|
||||
access_log off;
|
||||
}
|
||||
|
||||
# robots.txt
|
||||
location = /robots.txt {
|
||||
log_not_found off;
|
||||
access_log off;
|
||||
expires -1; # no-cache
|
||||
}
|
||||
|
||||
location = /ready {
|
||||
return 200 "";
|
||||
access_log off;
|
||||
expires -1; # no-cache
|
||||
}
|
||||
|
||||
# all assets contain hash in filename, cache forever
|
||||
location ^~ /assets/ {
|
||||
add_header Cache-Control "public, max-age=31536000, s-maxage=31536000, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# all workbox scripts are compiled with hash in filename, cache forever3
|
||||
location ^~ /workbox- {
|
||||
add_header Cache-Control "public, max-age=31536000, s-maxage=31536000, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# assets, media
|
||||
location ~* .(txt|webmanifest|css|js|mjs|map|svg|jpg|jpeg|png|ico|ttf|woff|woff2|wav)$ {
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location = /50x.html { }
|
||||
|
||||
}
|
|
@ -30,21 +30,21 @@ A basic service can look like this:
|
|||
|
||||
```javascript
|
||||
import AbstractService from './abstractService'
|
||||
import ProjectModel from '../models/project'
|
||||
import ListModel from '../models/list'
|
||||
|
||||
export default class ProjectService extends AbstractService {
|
||||
export default class ListService extends AbstractService {
|
||||
constructor() {
|
||||
super({
|
||||
getAll: '/projects',
|
||||
get: '/projects/{id}',
|
||||
create: '/namespaces/{namespaceID}/projects',
|
||||
update: '/projects/{id}',
|
||||
delete: '/projects/{id}',
|
||||
getAll: '/lists',
|
||||
get: '/lists/{id}',
|
||||
create: '/namespaces/{namespaceID}/lists',
|
||||
update: '/lists/{id}',
|
||||
delete: '/lists/{id}',
|
||||
})
|
||||
}
|
||||
|
||||
modelFactory(data) {
|
||||
return new ProjectModel(data)
|
||||
return new ListModel(data)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
@ -132,7 +132,7 @@ import AbstractModel from './abstractModel'
|
|||
import TaskModel from './task'
|
||||
import UserModel from './user'
|
||||
|
||||
export default class ProjectModel extends AbstractModel {
|
||||
export default class ListModel extends AbstractModel {
|
||||
|
||||
constructor(data) {
|
||||
// The constructor of AbstractModel handles all the default parsing.
|
||||
|
|
|
@ -1,9 +0,0 @@
|
|||
declare module 'postcss-easings' {
|
||||
import postcssEasings from 'postcss-easings'
|
||||
export default postcssEasings
|
||||
}
|
||||
|
||||
declare module 'postcss-easing-gradients' {
|
||||
import postcssEasingGradients from 'postcss-easing-gradients'
|
||||
export default postcssEasingGradients
|
||||
}
|
|
@ -1,33 +1,3 @@
|
|||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite-svg-loader" />
|
||||
/// <reference types="cypress" />
|
||||
/// <reference types="@histoire/plugin-vue/components" />
|
||||
|
||||
declare module 'postcss-focus-within/browser' {
|
||||
import focusWithinInit from 'postcss-focus-within/browser'
|
||||
export default focusWithinInit
|
||||
}
|
||||
|
||||
declare module 'css-has-pseudo/browser' {
|
||||
import cssHasPseudo from 'css-has-pseudo/browser'
|
||||
export default cssHasPseudo
|
||||
}
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VIKUNJA_API_URL?: string
|
||||
readonly VIKUNJA_HTTP_PORT?: number
|
||||
readonly VIKUNJA_HTTPS_PORT?: number
|
||||
|
||||
readonly VIKUNJA_SENTRY_ENABLED?: boolean
|
||||
readonly VIKUNJA_SENTRY_DSN?: string
|
||||
|
||||
readonly SENTRY_AUTH_TOKEN?: string
|
||||
readonly SENTRY_ORG?: string
|
||||
readonly SENTRY_PROJECT?: string
|
||||
|
||||
readonly VITE_IS_ONLINE: boolean
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
/// <reference types="cypress" />
|
|
@ -2,11 +2,11 @@
|
|||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1701336116,
|
||||
"narHash": "sha256-kEmpezCR/FpITc6yMbAh4WrOCiT2zg5pSjnKrq51h5Y=",
|
||||
"lastModified": 1664753041,
|
||||
"narHash": "sha256-0ogaD8PaGHluARFeupofvk1Nq9gpVeZdlFM0Kcwguys=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "f5c27c6136db4d76c30e533c20517df6864c46ee",
|
||||
"rev": "a62844b302507c7531ad68a86cb7aa54704c9cb4",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
|
|
@ -1,34 +0,0 @@
|
|||
import {defineConfig, defaultColors} from 'histoire'
|
||||
import {HstVue} from '@histoire/plugin-vue'
|
||||
import {HstScreenshot} from '@histoire/plugin-screenshot'
|
||||
|
||||
export default defineConfig({
|
||||
setupFile: './src/histoire.setup.ts',
|
||||
storyIgnored: [
|
||||
'**/node_modules/**',
|
||||
'**/dist/**',
|
||||
// see https://kolaente.dev/vikunja/frontend/pulls/2724#issuecomment-42012
|
||||
'**/.direnv/**',
|
||||
],
|
||||
plugins: [
|
||||
HstVue(),
|
||||
HstScreenshot({
|
||||
// Options here
|
||||
}),
|
||||
],
|
||||
theme: {
|
||||
title: 'Vikunja',
|
||||
colors: {
|
||||
// https://histoire.dev/guide/config.html#builtin-colors
|
||||
gray: defaultColors.zinc,
|
||||
primary: defaultColors.cyan,
|
||||
},
|
||||
// logo: {
|
||||
// square: './img/square.png',
|
||||
// light: './img/light.png',
|
||||
// dark: './img/dark.png',
|
||||
// },
|
||||
logoHref: 'https://vikunja.io',
|
||||
// favicon: './favicon.ico',
|
||||
},
|
||||
})
|
15
index.html
15
index.html
|
@ -9,7 +9,9 @@
|
|||
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<link rel="apple-touch-icon" href="/images/icons/apple-touch-icon-180x180.png"/>
|
||||
<!--__vite-plugin-inject-preload__-->
|
||||
<link rel="preload" crossorigin="anonymous" href="/src/assets/fonts/OpenSans[wght].woff2" as="font">
|
||||
<link rel="preload" crossorigin="anonymous" href="/src/assets/fonts/OpenSans-Italic[wght].woff2" as="font">
|
||||
<link rel="preload" crossorigin="anonymous" href="/src/assets/fonts/Quicksand[wght].woff2" as="font">
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
|
@ -18,22 +20,15 @@
|
|||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
<script>
|
||||
//
|
||||
//
|
||||
// This variable points the frontend to the api.
|
||||
// It has to be the full url, including the last /api/v1 part and port.
|
||||
// You can change this if your api is not reachable on the same port as the frontend.
|
||||
window.API_URL = 'http://localhost:3456/api/v1'
|
||||
// Enable error tracking with sentry. If this is set to true, will send anonymized data to
|
||||
// Enable error tracking with sentry. If this is set to true, will send anonymized data to
|
||||
// our sentry instance to notify us of potential problems.
|
||||
window.SENTRY_ENABLED = false
|
||||
window.SENTRY_DSN = 'https://85694a2d757547cbbc90cd4b55c5a18d@o1047380.ingest.sentry.io/6024480'
|
||||
// If enabled, allows the user to nest projects infinitely, instead of the default 2 levels.
|
||||
// This setting might change in the future or be removed completely.
|
||||
window.PROJECT_INFINITE_NESTING_ENABLED = false
|
||||
// Allow changing the logo and other icons based on various occasions throughout the year.
|
||||
window.ALLOW_ICON_CHANGES = true
|
||||
// Allow using a custom logo via external URL.
|
||||
window.CUSTOM_LOGO_URL = ''
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -0,0 +1,115 @@
|
|||
user nginx;
|
||||
worker_processes 1;
|
||||
|
||||
error_log /var/log/nginx/error.log warn;
|
||||
pid /var/run/nginx.pid;
|
||||
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
types {
|
||||
application/manifest+json webmanifest;
|
||||
}
|
||||
|
||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||
|
||||
access_log /var/log/nginx/access.log main;
|
||||
|
||||
sendfile on;
|
||||
#tcp_nopush on;
|
||||
|
||||
keepalive_timeout 65;
|
||||
|
||||
gzip on;
|
||||
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_buffers 16 8k;
|
||||
gzip_http_version 1.1;
|
||||
gzip_min_length 256;
|
||||
gzip_types
|
||||
text/plain
|
||||
text/css
|
||||
application/json
|
||||
application/x-javascript
|
||||
application/javascript
|
||||
text/xml
|
||||
application/xml
|
||||
application/xml+rss
|
||||
text/javascript
|
||||
application/vnd.ms-fontobject
|
||||
application/x-font-ttf
|
||||
font/opentype
|
||||
image/svg+xml
|
||||
image/x-icon
|
||||
audio/wav;
|
||||
|
||||
map_hash_max_size 128;
|
||||
map_hash_bucket_size 128;
|
||||
|
||||
# Expires map
|
||||
map $sent_http_content_type $expires {
|
||||
default off;
|
||||
text/css max;
|
||||
application/javascript max;
|
||||
text/javascript max;
|
||||
application/vnd.ms-fontobject max;
|
||||
application/x-font-ttf max;
|
||||
font/opentype max;
|
||||
font/woff2 max;
|
||||
image/svg+xml max;
|
||||
image/x-icon max;
|
||||
audio/wav max;
|
||||
~images/ max;
|
||||
~font/ max;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen 81 default_server http2 proxy_protocol; ## Needed when behind HAProxy with SSL termination + HTTP/2 support
|
||||
|
||||
server_name _;
|
||||
|
||||
expires $expires;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
|
||||
# all assets contain hash in filename, cache forever
|
||||
location ^~ /assets/ {
|
||||
add_header Cache-Control "public, max-age=31536000, s-maxage=31536000, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# all workbox scripts are compiled with hash in filename, cache forever3
|
||||
location ^~ /workbox- {
|
||||
add_header Cache-Control "public, max-age=31536000, s-maxage=31536000, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# assume that everything else is handled by the application router, by injecting the index.html.
|
||||
location / {
|
||||
autoindex off;
|
||||
expires off;
|
||||
add_header Cache-Control "public, max-age=0, s-maxage=0, must-revalidate" always;
|
||||
try_files $uri /index.html =404;
|
||||
}
|
||||
|
||||
location ~* .(txt|webmanifest|css|js|mjs|map|svg|jpg|jpeg|png|ico|ttf|woff|woff2|wav)$ {
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location = /50x.html {
|
||||
}
|
||||
}
|
||||
}
|
Binary file not shown.
Binary file not shown.
251
package.json
251
package.json
|
@ -1,187 +1,116 @@
|
|||
{
|
||||
"name": "vikunja-frontend",
|
||||
"description": "The todo app to organize your life.",
|
||||
"private": true,
|
||||
"version": "0.10.0",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://kolaente.dev/vikunja/frontend"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://kolaente.dev/vikunja/frontend/issues"
|
||||
},
|
||||
"homepage": "https://vikunja.io/",
|
||||
"funding": "https://opencollective.com/vikunja",
|
||||
"packageManager": "pnpm@8.15.1",
|
||||
"keywords": [
|
||||
"todo",
|
||||
"productivity",
|
||||
"task management",
|
||||
"organisation",
|
||||
"gantt",
|
||||
"kanban"
|
||||
],
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"serve": "vite",
|
||||
"preview": "vite preview --port 4173",
|
||||
"preview:dev": "vite preview --outDir dist-dev --mode development --port 4173",
|
||||
"serve:dist": "vite preview --port 4173",
|
||||
"serve:dist:dev": "vite preview --mode development --port 4173",
|
||||
"build": "vite build && workbox copyLibraries dist/",
|
||||
"build:modern-only": "BUILD_MODERN_ONLY=true vite build && workbox copyLibraries dist/",
|
||||
"build:dev": "vite build --mode development --outDir dist-dev/",
|
||||
"lint": "eslint 'src/**/*.{js,ts,vue}'",
|
||||
"lint:fix": "pnpm run lint --fix",
|
||||
"test:e2e": "start-server-and-test preview http://127.0.0.1:4173 'cypress run --e2e --browser chrome'",
|
||||
"test:e2e-record": "start-server-and-test preview http://127.0.0.1:4173 'cypress run --e2e --browser chrome --record'",
|
||||
"test:e2e-dev-dev": "start-server-and-test preview:dev http://127.0.0.1:4173 'cypress open --e2e'",
|
||||
"test:e2e-dev": "start-server-and-test preview http://127.0.0.1:4173 'cypress open --e2e'",
|
||||
"test:unit": "vitest --dir ./src",
|
||||
"lint": "eslint --ignore-pattern '*.test.*' ./src --ext .vue,.js,.ts",
|
||||
"cypress:open": "cypress open",
|
||||
"test:unit": "vitest --run",
|
||||
"test:unit-watch": "vitest watch",
|
||||
"test:frontend": "cypress run",
|
||||
"typecheck": "vue-tsc --noEmit && vue-tsc --noEmit -p tsconfig.vitest.json --composite false",
|
||||
"browserslist:update": "pnpm dlx browserslist@latest --update-db",
|
||||
"fonts:update": "pnpm fonts:download && pnpm fonts:subset",
|
||||
"browserslist:update": "npx browserslist@latest --update-db",
|
||||
"fonts:update": "pnpm run fonts:download && pnpm run fonts:subset",
|
||||
"fonts:download": "./scripts/fonts-download.sh",
|
||||
"fonts:subset": "./scripts/fonts-subset.sh",
|
||||
"story:dev": "histoire dev",
|
||||
"story:build": "histoire build",
|
||||
"story:preview": "histoire preview"
|
||||
"fonts:subset": "./scripts/fonts-subset.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "6.5.1",
|
||||
"@fortawesome/free-regular-svg-icons": "6.5.1",
|
||||
"@fortawesome/free-solid-svg-icons": "6.5.1",
|
||||
"@fortawesome/vue-fontawesome": "3.0.6",
|
||||
"@github/hotkey": "3.1.0",
|
||||
"@infectoone/vue-ganttastic": "2.2.0",
|
||||
"@intlify/unplugin-vue-i18n": "2.0.0",
|
||||
"@kyvg/vue3-notification": "3.1.4",
|
||||
"@sentry/tracing": "7.100.1",
|
||||
"@sentry/vue": "7.100.1",
|
||||
"@tiptap/core": "2.2.1",
|
||||
"@tiptap/extension-blockquote": "2.2.1",
|
||||
"@tiptap/extension-bold": "2.2.1",
|
||||
"@tiptap/extension-bullet-list": "2.2.1",
|
||||
"@tiptap/extension-code": "2.2.1",
|
||||
"@tiptap/extension-code-block-lowlight": "2.2.1",
|
||||
"@tiptap/extension-document": "2.2.1",
|
||||
"@tiptap/extension-dropcursor": "2.2.1",
|
||||
"@tiptap/extension-gapcursor": "2.2.1",
|
||||
"@tiptap/extension-hard-break": "2.2.1",
|
||||
"@tiptap/extension-heading": "2.2.1",
|
||||
"@tiptap/extension-history": "2.2.1",
|
||||
"@tiptap/extension-horizontal-rule": "2.2.1",
|
||||
"@tiptap/extension-image": "2.2.1",
|
||||
"@tiptap/extension-italic": "2.2.1",
|
||||
"@tiptap/extension-link": "2.2.1",
|
||||
"@tiptap/extension-list-item": "2.2.1",
|
||||
"@tiptap/extension-ordered-list": "2.2.1",
|
||||
"@tiptap/extension-paragraph": "2.2.1",
|
||||
"@tiptap/extension-placeholder": "2.2.1",
|
||||
"@tiptap/extension-strike": "2.2.1",
|
||||
"@tiptap/extension-table": "2.2.1",
|
||||
"@tiptap/extension-table-cell": "2.2.1",
|
||||
"@tiptap/extension-table-header": "2.2.1",
|
||||
"@tiptap/extension-table-row": "2.2.1",
|
||||
"@tiptap/extension-task-item": "2.2.1",
|
||||
"@tiptap/extension-task-list": "2.2.1",
|
||||
"@tiptap/extension-text": "2.2.1",
|
||||
"@tiptap/extension-typography": "2.2.1",
|
||||
"@tiptap/extension-underline": "2.2.1",
|
||||
"@tiptap/pm": "2.2.1",
|
||||
"@tiptap/suggestion": "2.2.1",
|
||||
"@tiptap/vue-3": "2.2.1",
|
||||
"@types/is-touch-device": "1.0.2",
|
||||
"@types/lodash.clonedeep": "4.5.9",
|
||||
"@vueuse/core": "10.7.2",
|
||||
"@vueuse/router": "10.7.2",
|
||||
"axios": "1.6.7",
|
||||
"blurhash": "2.0.5",
|
||||
"@fortawesome/fontawesome-svg-core": "6.2.1",
|
||||
"@fortawesome/free-regular-svg-icons": "6.2.1",
|
||||
"@fortawesome/free-solid-svg-icons": "6.2.1",
|
||||
"@fortawesome/vue-fontawesome": "3.0.2",
|
||||
"@github/hotkey": "2.0.1",
|
||||
"@infectoone/vue-ganttastic": "2.1.3",
|
||||
"@intlify/unplugin-vue-i18n": "0.8.1",
|
||||
"@kyvg/vue3-notification": "2.7.0",
|
||||
"@sentry/tracing": "7.28.1",
|
||||
"@sentry/vue": "7.28.1",
|
||||
"@types/is-touch-device": "1.0.0",
|
||||
"@types/lodash.clonedeep": "4.5.7",
|
||||
"@types/sortablejs": "1.15.0",
|
||||
"@vueuse/core": "9.9.0",
|
||||
"axios": "0.27.2",
|
||||
"blurhash": "2.0.4",
|
||||
"bulma-css-variables": "0.9.33",
|
||||
"camel-case": "4.1.2",
|
||||
"date-fns": "3.3.1",
|
||||
"dayjs": "1.11.10",
|
||||
"dompurify": "3.0.8",
|
||||
"codemirror": "5.65.11",
|
||||
"date-fns": "2.29.3",
|
||||
"dayjs": "1.11.7",
|
||||
"dompurify": "2.4.1",
|
||||
"easymde": "2.18.0",
|
||||
"fast-deep-equal": "3.1.3",
|
||||
"flatpickr": "4.6.13",
|
||||
"flexsearch": "0.7.31",
|
||||
"floating-vue": "5.2.2",
|
||||
"flexsearch": "0.7.21",
|
||||
"floating-vue": "2.0.0-beta.20",
|
||||
"highlight.js": "11.7.0",
|
||||
"is-touch-device": "1.0.1",
|
||||
"klona": "2.0.6",
|
||||
"lodash.clonedeep": "4.5.0",
|
||||
"lodash.debounce": "4.0.8",
|
||||
"lowlight": "2.9.0",
|
||||
"pinia": "2.1.7",
|
||||
"marked": "4.2.5",
|
||||
"minimist": "1.2.7",
|
||||
"pinia": "2.0.28",
|
||||
"register-service-worker": "1.7.2",
|
||||
"snake-case": "3.0.4",
|
||||
"sortablejs": "1.15.2",
|
||||
"tippy.js": "6.3.7",
|
||||
"ufo": "1.4.0",
|
||||
"vue": "3.4.15",
|
||||
"vue-advanced-cropper": "2.8.8",
|
||||
"vue-flatpickr-component": "11.0.3",
|
||||
"vue-i18n": "9.9.1",
|
||||
"vue-router": "4.2.5",
|
||||
"workbox-precaching": "7.0.0",
|
||||
"sortablejs": "1.15.0",
|
||||
"ufo": "1.0.1",
|
||||
"vue": "3.2.45",
|
||||
"vue-advanced-cropper": "2.8.6",
|
||||
"vue-flatpickr-component": "11.0.1",
|
||||
"vue-i18n": "9.2.2",
|
||||
"vue-router": "4.1.6",
|
||||
"workbox-precaching": "6.5.4",
|
||||
"zhyswan-vuedraggable": "4.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@4tw/cypress-drag-drop": "2.2.5",
|
||||
"@cypress/vite-dev-server": "5.0.7",
|
||||
"@cypress/vue": "6.0.0",
|
||||
"@faker-js/faker": "8.4.0",
|
||||
"@histoire/plugin-screenshot": "0.17.8",
|
||||
"@histoire/plugin-vue": "0.17.9",
|
||||
"@rushstack/eslint-patch": "1.7.2",
|
||||
"@tsconfig/node18": "18.2.2",
|
||||
"@types/codemirror": "5.60.15",
|
||||
"@types/dompurify": "3.0.5",
|
||||
"@types/flexsearch": "0.7.6",
|
||||
"@types/is-touch-device": "1.0.2",
|
||||
"@types/lodash.debounce": "4.0.9",
|
||||
"@types/marked": "5.0.2",
|
||||
"@types/node": "20.11.10",
|
||||
"@4tw/cypress-drag-drop": "2.2.3",
|
||||
"@cypress/vite-dev-server": "5.0.2",
|
||||
"@cypress/vue": "5.0.3",
|
||||
"@faker-js/faker": "7.6.0",
|
||||
"@rushstack/eslint-patch": "1.2.0",
|
||||
"@types/codemirror": "5.60.6",
|
||||
"@types/dompurify": "2.4.0",
|
||||
"@types/flexsearch": "0.7.3",
|
||||
"@types/lodash.debounce": "4.0.7",
|
||||
"@types/marked": "4.0.8",
|
||||
"@types/node": "18.11.18",
|
||||
"@types/postcss-preset-env": "7.7.0",
|
||||
"@types/sortablejs": "1.15.7",
|
||||
"@typescript-eslint/eslint-plugin": "6.20.0",
|
||||
"@typescript-eslint/parser": "6.20.0",
|
||||
"@vitejs/plugin-legacy": "5.3.0",
|
||||
"@vitejs/plugin-vue": "5.0.3",
|
||||
"@vue/eslint-config-typescript": "12.0.0",
|
||||
"@vue/test-utils": "2.4.4",
|
||||
"@vue/tsconfig": "0.5.1",
|
||||
"autoprefixer": "10.4.17",
|
||||
"browserslist": "4.22.3",
|
||||
"caniuse-lite": "1.0.30001581",
|
||||
"css-has-pseudo": "6.0.1",
|
||||
"csstype": "3.1.3",
|
||||
"cypress": "13.6.3",
|
||||
"esbuild": "0.20.0",
|
||||
"eslint": "8.56.0",
|
||||
"eslint-plugin-vue": "9.20.1",
|
||||
"happy-dom": "13.3.5",
|
||||
"histoire": "0.17.9",
|
||||
"postcss": "8.4.33",
|
||||
"postcss-easing-gradients": "3.0.1",
|
||||
"postcss-easings": "4.0.0",
|
||||
"postcss-focus-within": "8.0.1",
|
||||
"postcss-preset-env": "9.3.0",
|
||||
"rollup": "4.9.6",
|
||||
"rollup-plugin-visualizer": "5.12.0",
|
||||
"sass": "1.70.0",
|
||||
"start-server-and-test": "2.0.3",
|
||||
"typescript": "5.3.3",
|
||||
"vite": "5.0.12",
|
||||
"vite-plugin-inject-preload": "1.3.3",
|
||||
"vite-plugin-pwa": "0.17.5",
|
||||
"vite-plugin-sentry": "1.3.0",
|
||||
"vite-svg-loader": "5.1.0",
|
||||
"vitest": "1.2.2",
|
||||
"vue-tsc": "1.8.27",
|
||||
"wait-on": "7.2.0",
|
||||
"workbox-cli": "7.0.0"
|
||||
"@typescript-eslint/eslint-plugin": "5.47.1",
|
||||
"@typescript-eslint/parser": "5.47.1",
|
||||
"@vitejs/plugin-legacy": "3.0.1",
|
||||
"@vitejs/plugin-vue": "4.0.0",
|
||||
"@vue/eslint-config-typescript": "11.0.2",
|
||||
"@vue/test-utils": "2.2.6",
|
||||
"@vue/tsconfig": "0.1.3",
|
||||
"autoprefixer": "10.4.13",
|
||||
"browserslist": "4.21.4",
|
||||
"caniuse-lite": "1.0.30001441",
|
||||
"csstype": "3.1.1",
|
||||
"cypress": "12.2.0",
|
||||
"esbuild": "0.16.12",
|
||||
"eslint": "8.31.0",
|
||||
"eslint-plugin-vue": "9.8.0",
|
||||
"happy-dom": "8.1.1",
|
||||
"netlify-cli": "12.5.0",
|
||||
"postcss": "8.4.20",
|
||||
"postcss-preset-env": "7.8.3",
|
||||
"rollup": "3.9.1",
|
||||
"rollup-plugin-visualizer": "5.9.0",
|
||||
"sass": "1.57.1",
|
||||
"typescript": "4.9.4",
|
||||
"vite": "4.0.3",
|
||||
"vite-plugin-pwa": "0.14.0",
|
||||
"vite-svg-loader": "4.0.0",
|
||||
"vitest": "0.26.3",
|
||||
"vue-tsc": "1.0.19",
|
||||
"wait-on": "7.0.1",
|
||||
"workbox-cli": "6.5.4"
|
||||
},
|
||||
"pnpm": {
|
||||
"patchedDependencies": {
|
||||
"flexsearch@0.7.31": "patches/flexsearch@0.7.31.patch"
|
||||
}
|
||||
}
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"packageManager": "pnpm@7.21.0"
|
||||
}
|
||||
|
|
|
@ -1,16 +0,0 @@
|
|||
diff --git a/index.d.ts b/index.d.ts
|
||||
deleted file mode 100644
|
||||
index 9f39f41073864b83968bdaa242ac4e3c3149685a..0000000000000000000000000000000000000000
|
||||
diff --git a/package.json b/package.json
|
||||
index 8968f5bf8010ff194240591c8b83299f7328e79d..6d84b6f590a841b129ed8b3860cb786df5a185c0 100644
|
||||
--- a/package.json
|
||||
+++ b/package.json
|
||||
@@ -22,8 +22,6 @@
|
||||
},
|
||||
"main": "dist/flexsearch.bundle.js",
|
||||
"browser": "dist/flexsearch.bundle.js",
|
||||
- "module": "dist/module/index.js",
|
||||
- "types": "./index.d.ts",
|
||||
"preferGlobal": false,
|
||||
"repository": {
|
||||
"type": "git",
|
16044
pnpm-lock.yaml
16044
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
|
@ -2,16 +2,11 @@
|
|||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"labels": ["dependencies"],
|
||||
"extends": [
|
||||
"config:js-app"
|
||||
],
|
||||
"hostRules": [
|
||||
{
|
||||
"timeout": 600000
|
||||
}
|
||||
"config:base"
|
||||
],
|
||||
"packageRules": [
|
||||
{
|
||||
"matchPackageNames": ["happy-dom"],
|
||||
"matchPackageNames": ["netlify-cli", "happy-dom"],
|
||||
"extends": ["schedule:weekly"]
|
||||
},
|
||||
{
|
||||
|
@ -25,24 +20,11 @@
|
|||
"@vueuse/"
|
||||
]
|
||||
},
|
||||
{
|
||||
"groupName": "histoire",
|
||||
"matchPackagePrefixes": [
|
||||
"@histoire/",
|
||||
"histoire"
|
||||
]
|
||||
},
|
||||
{
|
||||
"groupName": "tiptap",
|
||||
"matchPackagePrefixes": [
|
||||
"@tiptap/",
|
||||
"tiptap"
|
||||
]
|
||||
},
|
||||
{
|
||||
"matchDepTypes": ["devDependencies"],
|
||||
"groupName": "dev-dependencies",
|
||||
"extends": ["schedule:daily"]
|
||||
"automerge": true,
|
||||
"automergeStrategy": "squash",
|
||||
"automergeType": "pr"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,18 +1,14 @@
|
|||
import { exec } from 'node:child_process'
|
||||
|
||||
function createSlug(string) {
|
||||
return String(string)
|
||||
.trim()
|
||||
.normalize('NFKD')
|
||||
.toLowerCase()
|
||||
.replace(/[.\s/]/g, '-')
|
||||
.replace(/[^A-Za-z\d-]/g, '')
|
||||
}
|
||||
const { exec } = require('child_process')
|
||||
|
||||
const BOT_USER_ID = 513
|
||||
const giteaToken = process.env.GITEA_TOKEN
|
||||
const siteId = process.env.NETLIFY_SITE_ID
|
||||
const branchSlug = createSlug(process.env.DRONE_SOURCE_BRANCH)
|
||||
const branchSlug = String(process.env.DRONE_SOURCE_BRANCH)
|
||||
.trim()
|
||||
.normalize('NFKD')
|
||||
.toLowerCase()
|
||||
.replace(/[.\s/]/g, '-')
|
||||
.replace(/[^A-Za-z\d-]/g, '')
|
||||
const prNumber = process.env.DRONE_PULL_REQUEST
|
||||
|
||||
const prIssueCommentsUrl = `https://kolaente.dev/api/v1/repos/vikunja/frontend/issues/${prNumber}/comments`
|
||||
|
@ -33,9 +29,9 @@ const promiseExec = cmd => {
|
|||
}
|
||||
|
||||
(async function () {
|
||||
let stdout = await promiseExec(`/home/node/docker-netlify-cli/node_modules/.bin/netlify link --id ${siteId}`)
|
||||
let stdout = await promiseExec(`./node_modules/.bin/netlify link --id ${siteId}`)
|
||||
console.log(stdout)
|
||||
stdout = await promiseExec(`/home/node/docker-netlify-cli/node_modules/.bin/netlify deploy --alias ${alias}`)
|
||||
stdout = await promiseExec(`./node_modules/.bin/netlify deploy --alias ${alias}`)
|
||||
console.log(stdout)
|
||||
|
||||
const data = await fetch(prIssueCommentsUrl).then(response => response.json())
|
|
@ -0,0 +1 @@
|
|||
05c69e5323a4d4bac041ade830735becd52c230277396d1f72be8fde83683a75dc095f6678804083b2ca66f27cc7995f ./scripts/deploy-preview-netlify.js
|
|
@ -1 +0,0 @@
|
|||
4a7c1293c7b12e9ab476cdf35251a407c6a1cd005d22c06df994222cccfb25cde5f47d15866a098c9d739778fee4dc19 ./scripts/deploy-preview-netlify.mjs
|
|
@ -0,0 +1,28 @@
|
|||
#!/bin/bash
|
||||
|
||||
# This shell script sets the api url based on an environment variable and starts nginx in foreground.
|
||||
|
||||
VIKUNJA_API_URL="${VIKUNJA_API_URL:-"/api/v1"}"
|
||||
VIKUNJA_SENTRY_ENABLED="${VIKUNJA_SENTRY_ENABLED:-"false"}"
|
||||
VIKUNJA_SENTRY_DSN="${VIKUNJA_SENTRY_DSN:-"https://85694a2d757547cbbc90cd4b55c5a18d@o1047380.ingest.sentry.io/6024480"}"
|
||||
VIKUNJA_HTTP_PORT="${VIKUNJA_HTTP_PORT:-80}"
|
||||
VIKUNJA_HTTPS_PORT="${VIKUNJA_HTTPS_PORT:-443}"
|
||||
|
||||
echo "Using $VIKUNJA_API_URL as default api url"
|
||||
|
||||
# Escape the variable to prevent sed from complaining
|
||||
VIKUNJA_API_URL=$(echo $VIKUNJA_API_URL |sed 's/\//\\\//g')
|
||||
|
||||
sed -i "s/http\:\/\/localhost\:3456//g" /usr/share/nginx/html/index.html # replacing in two steps to make sure api urls from releases are properly replaced as well
|
||||
sed -i "s/'\/api\/v1/'$VIKUNJA_API_URL/g" /usr/share/nginx/html/index.html
|
||||
sed -i "s/\.SENTRY_ENABLED = false/\.SENTRY_ENABLED = $VIKUNJA_SENTRY_ENABLED/g" /usr/share/nginx/html/index.html
|
||||
sed -i "s|\.SENTRY_DSN = '.*'|\.SENTRY_DSN = '$VIKUNJA_SENTRY_DSN'|g" /usr/share/nginx/html/index.html
|
||||
|
||||
sed -i "s/listen 80/listen $VIKUNJA_HTTP_PORT/g" /etc/nginx/nginx.conf
|
||||
sed -i "s/listen 443/listen $VIKUNJA_HTTPS_PORT/g" /etc/nginx/nginx.conf
|
||||
|
||||
# Set the uid and gid of the nginx run user
|
||||
usermod --non-unique --uid ${PUID} nginx
|
||||
groupmod --non-unique --gid ${PGID} nginx
|
||||
|
||||
nginx -g "daemon off;"
|
53
src/App.vue
53
src/App.vue
|
@ -1,23 +1,17 @@
|
|||
<template>
|
||||
<Ready>
|
||||
<ready>
|
||||
<template v-if="authUser">
|
||||
<TheNavigation />
|
||||
<ContentAuth />
|
||||
<TheNavigation/>
|
||||
<content-auth/>
|
||||
</template>
|
||||
<ContentLinkShare v-else-if="authLinkShare" />
|
||||
<NoAuthWrapper v-else>
|
||||
<router-view />
|
||||
</NoAuthWrapper>
|
||||
|
||||
<KeyboardShortcuts v-if="keyboardShortcutsActive" />
|
||||
|
||||
<Teleport to="body">
|
||||
<AddToHomeScreen />
|
||||
<UpdateNotification />
|
||||
<Notification />
|
||||
<DemoMode />
|
||||
</Teleport>
|
||||
</Ready>
|
||||
<content-link-share v-else-if="authLinkShare"/>
|
||||
<no-auth-wrapper v-else>
|
||||
<router-view/>
|
||||
</no-auth-wrapper>
|
||||
<Notification/>
|
||||
|
||||
<keyboard-shortcuts v-if="keyboardShortcutsActive"/>
|
||||
</ready>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
|
@ -25,29 +19,23 @@ import {computed, watch} from 'vue'
|
|||
import {useRoute, useRouter} from 'vue-router'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
import isTouchDevice from 'is-touch-device'
|
||||
import {success} from '@/message'
|
||||
|
||||
import Notification from '@/components/misc/notification.vue'
|
||||
import UpdateNotification from '@/components/home/UpdateNotification.vue'
|
||||
import KeyboardShortcuts from '@/components/misc/keyboard-shortcuts/index.vue'
|
||||
|
||||
import KeyboardShortcuts from './components/misc/keyboard-shortcuts/index.vue'
|
||||
import TheNavigation from '@/components/home/TheNavigation.vue'
|
||||
import ContentAuth from '@/components/home/contentAuth.vue'
|
||||
import ContentLinkShare from '@/components/home/contentLinkShare.vue'
|
||||
import ContentAuth from './components/home/contentAuth.vue'
|
||||
import ContentLinkShare from './components/home/contentLinkShare.vue'
|
||||
import NoAuthWrapper from '@/components/misc/no-auth-wrapper.vue'
|
||||
import Ready from '@/components/misc/ready.vue'
|
||||
|
||||
import {setLanguage} from '@/i18n'
|
||||
import {setLanguage} from './i18n'
|
||||
import AccountDeleteService from '@/services/accountDelete'
|
||||
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
|
||||
import {useColorScheme} from '@/composables/useColorScheme'
|
||||
import {useBodyClass} from '@/composables/useBodyClass'
|
||||
import AddToHomeScreen from '@/components/home/AddToHomeScreen.vue'
|
||||
import DemoMode from '@/components/home/DemoMode.vue'
|
||||
|
||||
const importAccountDeleteService = () => import('@/services/accountDelete')
|
||||
const importMessage = () => import('@/message')
|
||||
import {useAuthStore} from './stores/auth'
|
||||
|
||||
const baseStore = useBaseStore()
|
||||
const authStore = useAuthStore()
|
||||
|
@ -69,11 +57,8 @@ watch(accountDeletionConfirm, async (accountDeletionConfirm) => {
|
|||
return
|
||||
}
|
||||
|
||||
const messageP = importMessage()
|
||||
const AccountDeleteService = (await importAccountDeleteService()).default
|
||||
const accountDeletionService = new AccountDeleteService()
|
||||
await accountDeletionService.confirm(accountDeletionConfirm)
|
||||
const {success} = await messageP
|
||||
success({message: t('user.deletion.confirmSuccess')})
|
||||
authStore.refreshUserInfo()
|
||||
}, { immediate: true })
|
||||
|
@ -100,7 +85,7 @@ watch(userEmailConfirm, (userEmailConfirm) => {
|
|||
router.push({name: 'user.login'})
|
||||
}, { immediate: true })
|
||||
|
||||
setLanguage(authStore.settings.language)
|
||||
setLanguage()
|
||||
useColorScheme()
|
||||
</script>
|
||||
|
||||
|
|
Binary file not shown.
|
@ -1,4 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="18px" height="18px" viewBox="0 0 18 18" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5">
|
||||
<path d="M1,9 L1,3.5 C1,2 2,1 3.5,1 L14.5,1 C16,1 17,2 17,3.5 L17,14.5 C17,16 16,17 14.5,17 L3.5,17 C2,17 1,16 1,14.5 L1,9 Z" stroke-dasharray="60"></path>
|
||||
<polyline points="1 9 7 14 15 4" stroke-dasharray="22" stroke-dashoffset="66"></polyline>
|
||||
</svg>
|
Before Width: | Height: | Size: 420 B |
Binary file not shown.
Before Width: | Height: | Size: 313 KiB After Width: | Height: | Size: 519 KiB |
|
@ -1,64 +0,0 @@
|
|||
<script lang="ts" setup>
|
||||
import {logEvent} from 'histoire/client'
|
||||
import {reactive} from 'vue'
|
||||
import {createRouter, createMemoryHistory} from 'vue-router'
|
||||
import BaseButton from './BaseButton.vue'
|
||||
|
||||
function setupApp({ app }) {
|
||||
// Router mock
|
||||
app.use(createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/', name: 'home', component: { render: () => null } },
|
||||
],
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
const state = reactive({
|
||||
disabled: false,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
:setup-app="setupApp"
|
||||
:layout="{ type: 'grid', width: '200px' }"
|
||||
>
|
||||
<Variant title="custom">
|
||||
<template #controls>
|
||||
<HstCheckbox
|
||||
v-model="state.disabled"
|
||||
title="Disabled"
|
||||
/>
|
||||
</template>
|
||||
<BaseButton :disabled="state.disabled">
|
||||
Hello!
|
||||
</BaseButton>
|
||||
</Variant>
|
||||
|
||||
<Variant title="disabled">
|
||||
<BaseButton disabled>
|
||||
Hello!
|
||||
</BaseButton>
|
||||
</Variant>
|
||||
|
||||
<Variant title="router link">
|
||||
<BaseButton :to="'home'">
|
||||
Hello!
|
||||
</BaseButton>
|
||||
</Variant>
|
||||
|
||||
<Variant title="external link">
|
||||
<BaseButton href="https://vikunja.io">
|
||||
Hello!
|
||||
</BaseButton>
|
||||
</Variant>
|
||||
|
||||
<Variant title="button">
|
||||
<BaseButton @click="logEvent('Click', $event)">
|
||||
Hello!
|
||||
</BaseButton>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
|
@ -6,39 +6,38 @@
|
|||
<template>
|
||||
<div
|
||||
v-if="disabled === true && (to !== undefined || href !== undefined)"
|
||||
ref="button"
|
||||
class="base-button"
|
||||
:aria-disabled="disabled || undefined"
|
||||
ref="button"
|
||||
>
|
||||
<slot />
|
||||
<slot/>
|
||||
</div>
|
||||
<router-link
|
||||
v-else-if="to !== undefined"
|
||||
ref="button"
|
||||
:to="to"
|
||||
class="base-button"
|
||||
>
|
||||
<slot />
|
||||
</router-link>
|
||||
<a
|
||||
v-else-if="href !== undefined"
|
||||
ref="button"
|
||||
>
|
||||
<slot/>
|
||||
</router-link>
|
||||
<a v-else-if="href !== undefined"
|
||||
class="base-button"
|
||||
:href="href"
|
||||
rel="noreferrer noopener nofollow"
|
||||
target="_blank"
|
||||
ref="button"
|
||||
>
|
||||
<slot />
|
||||
<slot/>
|
||||
</a>
|
||||
<button
|
||||
v-else
|
||||
ref="button"
|
||||
:type="type"
|
||||
class="base-button base-button--type-button"
|
||||
:disabled="disabled || undefined"
|
||||
ref="button"
|
||||
@click="(event: MouseEvent) => emit('click', event)"
|
||||
>
|
||||
<slot />
|
||||
<slot/>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
|
@ -62,12 +61,12 @@ export type BaseButtonTypes = typeof BASE_BUTTON_TYPES_MAP[keyof typeof BASE_BUT
|
|||
|
||||
import {unrefElement} from '@vueuse/core'
|
||||
import {ref, type HTMLAttributes} from 'vue'
|
||||
import type {RouteLocationRaw} from 'vue-router'
|
||||
import type {RouteLocationNamedRaw} from 'vue-router'
|
||||
|
||||
export interface BaseButtonProps extends /* @vue-ignore */ HTMLAttributes {
|
||||
export interface BaseButtonProps extends HTMLAttributes {
|
||||
type?: BaseButtonTypes
|
||||
disabled?: boolean
|
||||
to?: RouteLocationRaw
|
||||
to?: RouteLocationNamedRaw
|
||||
href?: string
|
||||
}
|
||||
|
||||
|
|
|
@ -1,63 +0,0 @@
|
|||
<template>
|
||||
<div
|
||||
v-cy="'checkbox'"
|
||||
class="base-checkbox"
|
||||
>
|
||||
<input
|
||||
:id="checkboxId"
|
||||
type="checkbox"
|
||||
class="is-sr-only"
|
||||
:checked="modelValue"
|
||||
:disabled="disabled || undefined"
|
||||
@change="(event) => emit('update:modelValue', (event.target as HTMLInputElement).checked)"
|
||||
>
|
||||
|
||||
<slot
|
||||
name="label"
|
||||
:checkbox-id="checkboxId"
|
||||
>
|
||||
<label
|
||||
:for="checkboxId"
|
||||
class="base-checkbox__label"
|
||||
>
|
||||
<slot />
|
||||
</label>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref} from 'vue'
|
||||
import {createRandomID} from '@/helpers/randomId'
|
||||
|
||||
defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: boolean): void
|
||||
}>()
|
||||
|
||||
const checkboxId = ref(`fancycheckbox_${createRandomID()}`)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.base-checkbox__label {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.base-checkbox:has(input:disabled) .base-checkbox__label {
|
||||
cursor:not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
|
@ -1,182 +0,0 @@
|
|||
<template>
|
||||
<transition
|
||||
name="expandable-slide"
|
||||
@beforeEnter="beforeEnter"
|
||||
@enter="enter"
|
||||
@afterEnter="afterEnter"
|
||||
@enterCancelled="enterCancelled"
|
||||
@beforeLeave="beforeLeave"
|
||||
@leave="leave"
|
||||
@afterLeave="afterLeave"
|
||||
@leaveCancelled="leaveCancelled"
|
||||
>
|
||||
<div
|
||||
v-if="initialHeight"
|
||||
class="expandable-initial-height"
|
||||
:style="{ maxHeight: `${initialHeight}px` }"
|
||||
:class="{ 'expandable-initial-height--expanded': open }"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="open"
|
||||
class="expandable"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// the logic of this component is loosly based on this article
|
||||
// https://gomakethings.com/how-to-add-transition-animations-to-vanilla-javascript-show-and-hide-methods/#putting-it-all-together
|
||||
|
||||
import {computed, ref} from 'vue'
|
||||
import {getInheritedBackgroundColor} from '@/helpers/getInheritedBackgroundColor'
|
||||
|
||||
const props = defineProps({
|
||||
/** Whether the Expandable is open or not */
|
||||
open: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
/** If there is too much content, content will be cut of here. */
|
||||
initialHeight: {
|
||||
type: Number,
|
||||
default: undefined,
|
||||
},
|
||||
/** The hidden content is indicated by a gradient. This is the color that the gradient fades to.
|
||||
* Makes only sense if `initialHeight` is set. */
|
||||
backgroundColor: {
|
||||
type: String,
|
||||
},
|
||||
})
|
||||
|
||||
const wrapper = ref<HTMLElement | null>(null)
|
||||
|
||||
const computedBackgroundColor = computed(() => {
|
||||
if (wrapper.value === null) {
|
||||
return props.backgroundColor || '#fff'
|
||||
}
|
||||
return props.backgroundColor || getInheritedBackgroundColor(wrapper.value)
|
||||
})
|
||||
|
||||
/**
|
||||
* Get the natural height of the element
|
||||
*/
|
||||
function getHeight(el: HTMLElement) {
|
||||
const { display } = el.style // save display property
|
||||
el.style.display = 'block' // Make it visible
|
||||
const height = `${el.scrollHeight}px` // Get its height
|
||||
el.style.display = display // revert to original display property
|
||||
return height
|
||||
}
|
||||
|
||||
/**
|
||||
* force layout of element changes
|
||||
* https://gist.github.com/paulirish/5d52fb081b3570c81e3a
|
||||
*/
|
||||
function forceLayout(el: HTMLElement) {
|
||||
el.offsetTop
|
||||
}
|
||||
|
||||
/* ######################################################################
|
||||
# The following functions are called by the js hooks of the transitions.
|
||||
# They follow the orignal hook order of the vue transition component
|
||||
# see: https://vuejs.org/guide/built-ins/transition.html#javascript-hooks
|
||||
###################################################################### */
|
||||
|
||||
function beforeEnter(el: HTMLElement) {
|
||||
el.style.height = '0'
|
||||
el.style.willChange = 'height'
|
||||
el.style.backfaceVisibility = 'hidden'
|
||||
forceLayout(el)
|
||||
}
|
||||
|
||||
// the done callback is optional when
|
||||
// used in combination with CSS
|
||||
function enter(el: HTMLElement) {
|
||||
const height = getHeight(el) // Get the natural height
|
||||
el.style.height = height // Update the height
|
||||
}
|
||||
|
||||
function afterEnter(el: HTMLElement) {
|
||||
removeHeight(el)
|
||||
}
|
||||
|
||||
function enterCancelled(el: HTMLElement) {
|
||||
removeHeight(el)
|
||||
}
|
||||
|
||||
function beforeLeave(el: HTMLElement) {
|
||||
// Give the element a height to change from
|
||||
el.style.height = `${el.scrollHeight}px`
|
||||
forceLayout(el)
|
||||
}
|
||||
|
||||
function leave(el: HTMLElement) {
|
||||
// Set the height back to 0
|
||||
el.style.height = '0'
|
||||
el.style.willChange = ''
|
||||
el.style.backfaceVisibility = ''
|
||||
}
|
||||
|
||||
function afterLeave(el: HTMLElement) {
|
||||
removeHeight(el)
|
||||
}
|
||||
|
||||
function leaveCancelled(el: HTMLElement) {
|
||||
removeHeight(el)
|
||||
}
|
||||
|
||||
function removeHeight(el: HTMLElement) {
|
||||
el.style.height = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
$transition-time: 300ms;
|
||||
|
||||
.expandable-slide-enter-active,
|
||||
.expandable-slide-leave-active {
|
||||
transition:
|
||||
opacity $transition-time ease-in-quint,
|
||||
height $transition-time ease-in-out-quint;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.expandable-slide-enter,
|
||||
.expandable-slide-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.expandable-initial-height {
|
||||
padding: 5px;
|
||||
margin: -5px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
display: block;
|
||||
background-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent,
|
||||
ease-in-out
|
||||
v-bind(computedBackgroundColor)
|
||||
);
|
||||
position: absolute;
|
||||
height: 40px;
|
||||
width: 100%;
|
||||
bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.expandable-initial-height--expanded {
|
||||
height: 100% !important;
|
||||
|
||||
&::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -1,11 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import datemathHelp from './datemathHelp.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story>
|
||||
<Variant title="Default">
|
||||
<datemathHelp />
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
|
@ -1,136 +1,122 @@
|
|||
<template>
|
||||
<card
|
||||
class="has-no-shadow how-it-works-modal"
|
||||
:title="$t('input.datemathHelp.title')"
|
||||
>
|
||||
:title="$t('input.datemathHelp.title')">
|
||||
<p>
|
||||
{{ $t('input.datemathHelp.intro') }}
|
||||
</p>
|
||||
<p>
|
||||
<i18n-t
|
||||
keypath="input.datemathHelp.expression"
|
||||
scope="global"
|
||||
>
|
||||
<i18n-t keypath="input.datemathHelp.expression" scope="global">
|
||||
<code>now</code>
|
||||
<code>||</code>
|
||||
</i18n-t>
|
||||
</p>
|
||||
<p>
|
||||
<i18n-t
|
||||
keypath="input.datemathHelp.similar"
|
||||
scope="global"
|
||||
>
|
||||
<i18n-t keypath="input.datemathHelp.similar" scope="global">
|
||||
<BaseButton
|
||||
href="https://grafana.com/docs/grafana/latest/dashboards/time-range-controls/"
|
||||
target="_blank"
|
||||
>
|
||||
target="_blank">
|
||||
Grafana
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
href="https://www.elastic.co/guide/en/elasticsearch/reference/7.3/common-options.html#date-math"
|
||||
target="_blank"
|
||||
>
|
||||
target="_blank">
|
||||
Elasticsearch
|
||||
</BaseButton>
|
||||
</i18n-t>
|
||||
</p>
|
||||
<p>{{ $t('misc.forExample') }}</p>
|
||||
<ul>
|
||||
<li><code>+1d</code> {{ $t('input.datemathHelp.add1Day') }}</li>
|
||||
<li><code>-1d</code> {{ $t('input.datemathHelp.minus1Day') }}</li>
|
||||
<li><code>/d</code> {{ $t('input.datemathHelp.roundDay') }}</li>
|
||||
<li><code>+1d</code>{{ $t('input.datemathHelp.add1Day') }}</li>
|
||||
<li><code>-1d</code>{{ $t('input.datemathHelp.minus1Day') }}</li>
|
||||
<li><code>/d</code>{{ $t('input.datemathHelp.roundDay') }}</li>
|
||||
</ul>
|
||||
<h3>{{ $t('input.datemathHelp.supportedUnits') }}</h3>
|
||||
<p>{{ $t('input.datemathHelp.supportedUnits') }}</p>
|
||||
<table class="table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>s</code></td>
|
||||
<td>{{ $t('input.datemathHelp.units.seconds') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>m</code></td>
|
||||
<td>{{ $t('input.datemathHelp.units.minutes') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>h</code></td>
|
||||
<td>{{ $t('input.datemathHelp.units.hours') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>H</code></td>
|
||||
<td>{{ $t('input.datemathHelp.units.hours') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>d</code></td>
|
||||
<td>{{ $t('input.datemathHelp.units.days') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>w</code></td>
|
||||
<td>{{ $t('input.datemathHelp.units.weeks') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>M</code></td>
|
||||
<td>{{ $t('input.datemathHelp.units.months') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>y</code></td>
|
||||
<td>{{ $t('input.datemathHelp.units.years') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>s</code></td>
|
||||
<td>{{ $t('input.datemathHelp.units.seconds') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>m</code></td>
|
||||
<td>{{ $t('input.datemathHelp.units.minutes') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>h</code></td>
|
||||
<td>{{ $t('input.datemathHelp.units.hours') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>H</code></td>
|
||||
<td>{{ $t('input.datemathHelp.units.hours') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>d</code></td>
|
||||
<td>{{ $t('input.datemathHelp.units.days') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>w</code></td>
|
||||
<td>{{ $t('input.datemathHelp.units.weeks') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>M</code></td>
|
||||
<td>{{ $t('input.datemathHelp.units.months') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>y</code></td>
|
||||
<td>{{ $t('input.datemathHelp.units.years') }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>{{ $t('input.datemathHelp.someExamples') }}</h3>
|
||||
<p>{{ $t('input.datemathHelp.someExamples') }}</p>
|
||||
<table class="table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>now</code></td>
|
||||
<td>{{ $t('input.datemathHelp.examples.now') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>now+24h</code></td>
|
||||
<td>{{ $t('input.datemathHelp.examples.in24h') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>now/d</code></td>
|
||||
<td>{{ $t('input.datemathHelp.examples.today') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>now/w</code></td>
|
||||
<td>{{ $t('input.datemathHelp.examples.beginningOfThisWeek') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>now/w+1w</code></td>
|
||||
<td>{{ $t('input.datemathHelp.examples.endOfThisWeek') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>now+30d</code></td>
|
||||
<td>{{ $t('input.datemathHelp.examples.in30Days') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>{{ exampleDate }}||+1M/d</code></td>
|
||||
<td>
|
||||
<i18n-t
|
||||
keypath="input.datemathHelp.examples.datePlusMonth"
|
||||
scope="global"
|
||||
>
|
||||
<strong>{{ exampleDate }}</strong>
|
||||
</i18n-t>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>now</code></td>
|
||||
<td>{{ $t('input.datemathHelp.examples.now') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>now+24h</code></td>
|
||||
<td>{{ $t('input.datemathHelp.examples.in24h') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>now/d</code></td>
|
||||
<td>{{ $t('input.datemathHelp.examples.today') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>now/w</code></td>
|
||||
<td>{{ $t('input.datemathHelp.examples.beginningOfThisWeek') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>now/w+1w</code></td>
|
||||
<td>{{ $t('input.datemathHelp.examples.endOfThisWeek') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>now+30d</code></td>
|
||||
<td>{{ $t('input.datemathHelp.examples.in30Days') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>{{ exampleDate }}||+1M/d</code></td>
|
||||
<td>
|
||||
<i18n-t keypath="input.datemathHelp.examples.datePlusMonth" scope="global">
|
||||
<code>{{ exampleDate }}</code>
|
||||
</i18n-t>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</card>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {formatDateShort} from '@/helpers/time/formatDate'
|
||||
|
||||
import {formatDate} from '@/helpers/time/formatDate'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
|
||||
const exampleDate = formatDateShort(new Date())
|
||||
const exampleDate = formatDate(new Date(), 'yyyy-MM-dd')
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
// FIXME: Remove style overwrites
|
||||
.how-it-works-modal {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
|
|
@ -1,31 +1,20 @@
|
|||
<template>
|
||||
<div class="datepicker-with-range-container">
|
||||
<Popup>
|
||||
<popup>
|
||||
<template #trigger="{toggle}">
|
||||
<slot
|
||||
name="trigger"
|
||||
:toggle="toggle"
|
||||
:button-text="buttonText"
|
||||
/>
|
||||
<slot name="trigger" :toggle="toggle" :buttonText="buttonText"></slot>
|
||||
</template>
|
||||
<template #content="{isOpen}">
|
||||
<div
|
||||
class="datepicker-with-range"
|
||||
:class="{'is-open': isOpen}"
|
||||
>
|
||||
<div class="datepicker-with-range" :class="{'is-open': isOpen}">
|
||||
<div class="selections">
|
||||
<BaseButton
|
||||
:class="{'is-active': customRangeActive}"
|
||||
@click="setDateRange(null)"
|
||||
>
|
||||
<BaseButton @click="setDateRange(null)" :class="{'is-active': customRangeActive}">
|
||||
{{ $t('misc.custom') }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-for="(value, text) in DATE_RANGES"
|
||||
:key="text"
|
||||
:class="{'is-active': from === value[0] && to === value[1]}"
|
||||
@click="setDateRange(value)"
|
||||
>
|
||||
:class="{'is-active': from === value[0] && to === value[1]}">
|
||||
{{ $t(`input.datepickerRange.ranges.${text}`) }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
@ -34,18 +23,10 @@
|
|||
{{ $t('input.datepickerRange.from') }}
|
||||
<div class="field has-addons">
|
||||
<div class="control is-fullwidth">
|
||||
<input
|
||||
v-model="from"
|
||||
class="input"
|
||||
type="text"
|
||||
>
|
||||
<input class="input" type="text" v-model="from"/>
|
||||
</div>
|
||||
<div class="control">
|
||||
<x-button
|
||||
icon="calendar"
|
||||
variant="secondary"
|
||||
data-toggle
|
||||
/>
|
||||
<x-button icon="calendar" variant="secondary" data-toggle/>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
@ -53,49 +34,38 @@
|
|||
{{ $t('input.datepickerRange.to') }}
|
||||
<div class="field has-addons">
|
||||
<div class="control is-fullwidth">
|
||||
<input
|
||||
v-model="to"
|
||||
class="input"
|
||||
type="text"
|
||||
>
|
||||
<input class="input" type="text" v-model="to"/>
|
||||
</div>
|
||||
<div class="control">
|
||||
<x-button
|
||||
icon="calendar"
|
||||
variant="secondary"
|
||||
data-toggle
|
||||
/>
|
||||
<x-button icon="calendar" variant="secondary" data-toggle/>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<flat-pickr
|
||||
v-model="flatpickrRange"
|
||||
:config="flatPickerConfig"
|
||||
v-model="flatpickrRange"
|
||||
/>
|
||||
|
||||
<p>
|
||||
{{ $t('input.datemathHelp.canuse') }}
|
||||
<BaseButton
|
||||
class="has-text-primary"
|
||||
@click="showHowItWorks = true"
|
||||
>
|
||||
<BaseButton class="has-text-primary" @click="showHowItWorks = true">
|
||||
{{ $t('input.datemathHelp.learnhow') }}
|
||||
</BaseButton>
|
||||
</p>
|
||||
|
||||
<modal
|
||||
:enabled="showHowItWorks"
|
||||
@close="() => showHowItWorks = false"
|
||||
transition-name="fade"
|
||||
:overflow="true"
|
||||
variant="hint-modal"
|
||||
@close="() => showHowItWorks = false"
|
||||
>
|
||||
<DatemathHelp />
|
||||
<DatemathHelp/>
|
||||
</modal>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Popup>
|
||||
</popup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -105,24 +75,25 @@ import {useI18n} from 'vue-i18n'
|
|||
|
||||
import flatPickr from 'vue-flatpickr-component'
|
||||
import 'flatpickr/dist/flatpickr.css'
|
||||
import {parseDateOrString} from '@/helpers/time/parseDateOrString'
|
||||
|
||||
import Popup from '@/components/misc/popup.vue'
|
||||
import {DATE_RANGES} from '@/components/date/dateRanges'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import DatemathHelp from '@/components/date/datemathHelp.vue'
|
||||
import { getFlatpickrLanguage } from '@/helpers/flatpickrLanguage'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
required: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
|
||||
// FIXME: This seems to always contain the default value - that breaks the picker
|
||||
const weekStart = computed(() => authStore.settings.weekStart ?? 0)
|
||||
const flatPickerConfig = computed(() => ({
|
||||
altFormat: t('date.altFormatLong'),
|
||||
altInput: true,
|
||||
|
@ -130,7 +101,9 @@ const flatPickerConfig = computed(() => ({
|
|||
enableTime: false,
|
||||
wrap: true,
|
||||
mode: 'range',
|
||||
locale: getFlatpickrLanguage(),
|
||||
locale: {
|
||||
firstDayOf7Days: weekStart.value,
|
||||
},
|
||||
}))
|
||||
|
||||
const showHowItWorks = ref(false)
|
||||
|
@ -147,9 +120,9 @@ watch(
|
|||
to.value = newValue.dateTo
|
||||
// Only set the date back to flatpickr when it's an actual date.
|
||||
// Otherwise flatpickr runs in an endless loop and slows down the browser.
|
||||
const dateFrom = parseDateOrString(from.value, false)
|
||||
const dateTo = parseDateOrString(to.value, false)
|
||||
if (dateFrom instanceof Date && dateTo instanceof Date) {
|
||||
const dateFrom = new Date(from.value)
|
||||
const dateTo = new Date(to.value)
|
||||
if (dateTo.getTime() === dateTo.getTime() && dateFrom.getTime() === dateFrom.getTime()) {
|
||||
flatpickrRange.value = `${from.value} to ${to.value}`
|
||||
}
|
||||
},
|
||||
|
|
|
@ -1,86 +0,0 @@
|
|||
<template>
|
||||
<div
|
||||
v-if="shouldShowMessage"
|
||||
class="add-to-home-screen"
|
||||
:class="{'has-update-available': hasUpdateAvailable}"
|
||||
>
|
||||
<icon
|
||||
icon="arrow-up-from-bracket"
|
||||
class="add-icon"
|
||||
/>
|
||||
<p>
|
||||
{{ $t('home.addToHomeScreen') }}
|
||||
</p>
|
||||
<BaseButton
|
||||
class="hide-button"
|
||||
@click="() => hideMessage = true"
|
||||
>
|
||||
<icon icon="x" />
|
||||
</BaseButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import {useLocalStorage} from '@vueuse/core'
|
||||
import {computed} from 'vue'
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
|
||||
const baseStore = useBaseStore()
|
||||
|
||||
const hideMessage = useLocalStorage('hideAddToHomeScreenMessage', false)
|
||||
const hasUpdateAvailable = computed(() => baseStore.updateAvailable)
|
||||
|
||||
const shouldShowMessage = computed(() => {
|
||||
if (hideMessage.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined' && window.matchMedia('(display-mode: standalone)').matches) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.add-to-home-screen {
|
||||
position: fixed;
|
||||
// FIXME: We should prevent usage of z-index or
|
||||
// at least define it centrally
|
||||
// the highest z-index of a modal is .hint-modal with 4500
|
||||
z-index: 5000;
|
||||
bottom: 1rem;
|
||||
inset-inline: 1rem;
|
||||
max-width: max-content;
|
||||
margin-inline: auto;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: .5rem 1rem;
|
||||
background: var(--grey-900);
|
||||
border-radius: $radius;
|
||||
font-size: .9rem;
|
||||
color: var(--grey-200);
|
||||
|
||||
@media screen and (min-width: $tablet) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&.has-update-available {
|
||||
bottom: 5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.add-icon {
|
||||
color: var(--primary-light);
|
||||
}
|
||||
|
||||
.hide-button {
|
||||
padding: .25rem .5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
|
@ -1,52 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import {computed, ref} from 'vue'
|
||||
import {useConfigStore} from '@/stores/config'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
|
||||
const configStore = useConfigStore()
|
||||
const hide = ref(false)
|
||||
const enabled = computed(() => configStore.demoModeEnabled && !hide.value)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="enabled"
|
||||
class="demo-mode-banner"
|
||||
>
|
||||
<p>
|
||||
{{ $t('demo.title') }}
|
||||
<strong class="is-uppercase">{{ $t('demo.everythingWillBeDeleted') }}</strong>
|
||||
</p>
|
||||
<BaseButton
|
||||
class="hide-button"
|
||||
@click="() => hide = true"
|
||||
>
|
||||
<icon icon="times" />
|
||||
</BaseButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.demo-mode-banner {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--danger);
|
||||
z-index: 100;
|
||||
padding: .5rem;
|
||||
text-align: center;
|
||||
|
||||
&, strong {
|
||||
color: hsl(220, 13%, 91%) !important; // --grey-200 in light mode, hardcoded because the color should not change
|
||||
}
|
||||
}
|
||||
|
||||
.hide-button {
|
||||
padding: .25rem .5rem;
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
right: .5rem;
|
||||
top: .25rem;
|
||||
}
|
||||
</style>
|
|
@ -4,35 +4,17 @@ import { useNow } from '@vueuse/core'
|
|||
|
||||
import LogoFull from '@/assets/logo-full.svg?component'
|
||||
import LogoFullPride from '@/assets/logo-full-pride.svg?component'
|
||||
import {MILLISECONDS_A_HOUR} from '@/constants/date'
|
||||
|
||||
const now = useNow({
|
||||
interval: MILLISECONDS_A_HOUR,
|
||||
})
|
||||
const Logo = computed(() => window.ALLOW_ICON_CHANGES && now.value.getMonth() === 6 ? LogoFullPride : LogoFull)
|
||||
const CustomLogo = computed(() => window.CUSTOM_LOGO_URL)
|
||||
const now = useNow()
|
||||
const Logo = computed(() => now.value.getMonth() === 5 ? LogoFullPride : LogoFull)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Logo
|
||||
v-if="!CustomLogo"
|
||||
alt="Vikunja"
|
||||
class="logo"
|
||||
/>
|
||||
<img
|
||||
v-show="CustomLogo"
|
||||
:src="CustomLogo"
|
||||
alt="Vikunja"
|
||||
class="logo"
|
||||
>
|
||||
</div>
|
||||
<Logo alt="Vikunja" class="logo" />
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.logo {
|
||||
color: var(--logo-text-color);
|
||||
max-width: 168px;
|
||||
max-height: 48px;
|
||||
}
|
||||
</style>
|
|
@ -1,11 +1,11 @@
|
|||
<template>
|
||||
<BaseButton
|
||||
v-shortcut="'Mod+e'"
|
||||
class="menu-show-button"
|
||||
:title="$t('keyboardShortcuts.toggleMenu')"
|
||||
:aria-label="menuActive ? $t('misc.hideMenu') : $t('misc.showMenu')"
|
||||
@click="baseStore.toggleMenu()"
|
||||
@shortkey="() => baseStore.toggleMenu()"
|
||||
v-shortcut="'Control+e'"
|
||||
:title="$t('keyboardShortcuts.toggleMenu')"
|
||||
:aria-label="menuActive ? $t('misc.hideMenu') : $t('misc.showMenu')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
|
|
|
@ -1,11 +1,7 @@
|
|||
<template>
|
||||
<BaseButton
|
||||
class="menu-bottom-link"
|
||||
:href="poweredByUrl"
|
||||
target="_blank"
|
||||
>
|
||||
{{ $t('misc.poweredBy') }}
|
||||
</BaseButton>
|
||||
<BaseButton class="menu-bottom-link" :href="poweredByUrl" target="_blank">
|
||||
{{ $t('misc.poweredBy') }}
|
||||
</BaseButton>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
|
|
@ -1,109 +0,0 @@
|
|||
<template>
|
||||
<draggable
|
||||
v-model="availableProjects"
|
||||
animation="100"
|
||||
ghost-class="ghost"
|
||||
group="projects"
|
||||
handle=".handle"
|
||||
tag="menu"
|
||||
item-key="id"
|
||||
:disabled="!canEditOrder"
|
||||
filter=".drag-disabled"
|
||||
:component-data="{
|
||||
type: 'transition-group',
|
||||
name: !drag ? 'flip-list' : null,
|
||||
class: [
|
||||
'menu-list can-be-hidden',
|
||||
{ 'dragging-disabled': !canEditOrder }
|
||||
],
|
||||
}"
|
||||
@start="() => drag = true"
|
||||
@end="saveProjectPosition"
|
||||
>
|
||||
<template #item="{element: project}">
|
||||
<ProjectsNavigationItem
|
||||
:class="{'drag-disabled': project.id < 0}"
|
||||
:project="project"
|
||||
:is-loading="projectUpdating[project.id]"
|
||||
:can-collapse="canCollapse"
|
||||
:level="level"
|
||||
:data-project-id="project.id"
|
||||
/>
|
||||
</template>
|
||||
</draggable>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {ref, watch} from 'vue'
|
||||
import draggable from 'zhyswan-vuedraggable'
|
||||
import type {SortableEvent} from 'sortablejs'
|
||||
|
||||
import ProjectsNavigationItem from '@/components/home/ProjectsNavigationItem.vue'
|
||||
|
||||
import {calculateItemPosition} from '@/helpers/calculateItemPosition'
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue?: IProject[],
|
||||
canEditOrder: boolean,
|
||||
canCollapse?: boolean,
|
||||
level?: number,
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', projects: IProject[]): void
|
||||
}>()
|
||||
|
||||
const drag = ref(false)
|
||||
|
||||
const projectStore = useProjectStore()
|
||||
|
||||
// Vue draggable will modify the projects list as it changes their position which will not work on a prop.
|
||||
// Hence, we'll clone the prop and work on the clone.
|
||||
const availableProjects = ref<IProject[]>([])
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
projects => {
|
||||
availableProjects.value = projects || []
|
||||
},
|
||||
{immediate: true},
|
||||
)
|
||||
|
||||
const projectUpdating = ref<{ [id: IProject['id']]: boolean }>({})
|
||||
|
||||
async function saveProjectPosition(e: SortableEvent) {
|
||||
if (!e.newIndex && e.newIndex !== 0) return
|
||||
|
||||
const projectsActive = availableProjects.value
|
||||
// If the project was dragged to the last position, Safari will report e.newIndex as the size of the projectsActive
|
||||
// array instead of using the position. Because the index is wrong in that case, dragging the project will fail.
|
||||
// To work around that we're explicitly checking that case here and decrease the index.
|
||||
const newIndex = e.newIndex === projectsActive.length ? e.newIndex - 1 : e.newIndex
|
||||
|
||||
const projectId = parseInt(e.item.dataset.projectId)
|
||||
const project = projectStore.projects[projectId]
|
||||
|
||||
const parentProjectId = e.to.parentNode.dataset.projectId ? parseInt(e.to.parentNode.dataset.projectId) : 0
|
||||
const projectBefore = projectsActive[newIndex - 1] ?? null
|
||||
const projectAfter = projectsActive[newIndex + 1] ?? null
|
||||
projectUpdating.value[project.id] = true
|
||||
|
||||
const position = calculateItemPosition(
|
||||
projectBefore !== null ? projectBefore.position : null,
|
||||
projectAfter !== null ? projectAfter.position : null,
|
||||
)
|
||||
|
||||
try {
|
||||
// create a copy of the project in order to not violate pinia manipulation
|
||||
await projectStore.updateProject({
|
||||
...project,
|
||||
position,
|
||||
parentProjectId,
|
||||
})
|
||||
emit('update:modelValue', availableProjects.value)
|
||||
} finally {
|
||||
projectUpdating.value[project.id] = false
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -1,199 +0,0 @@
|
|||
<template>
|
||||
<li
|
||||
class="list-menu loader-container is-loading-small"
|
||||
:class="{'is-loading': isLoading}"
|
||||
>
|
||||
<div>
|
||||
<BaseButton
|
||||
v-if="canCollapse && childProjects?.length > 0"
|
||||
class="collapse-project-button"
|
||||
@click="childProjectsOpen = !childProjectsOpen"
|
||||
>
|
||||
<icon
|
||||
icon="chevron-down"
|
||||
:class="{ 'project-is-collapsed': !childProjectsOpen }"
|
||||
/>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
:to="{ name: 'project.index', params: { projectId: project.id} }"
|
||||
class="list-menu-link"
|
||||
:class="{'router-link-exact-active': currentProject?.id === project.id}"
|
||||
>
|
||||
<span
|
||||
v-if="!canCollapse || childProjects?.length === 0"
|
||||
class="collapse-project-button-placeholder"
|
||||
/>
|
||||
<div
|
||||
class="color-bubble-handle-wrapper"
|
||||
:class="{'is-draggable': project.id > 0}"
|
||||
>
|
||||
<ColorBubble
|
||||
v-if="project.hexColor !== ''"
|
||||
:color="project.hexColor"
|
||||
/>
|
||||
<span
|
||||
v-else-if="project.id < -1"
|
||||
class="saved-filter-icon icon menu-item-icon"
|
||||
>
|
||||
<icon icon="filter" />
|
||||
</span>
|
||||
<span
|
||||
v-if="project.id > 0"
|
||||
class="icon menu-item-icon handle"
|
||||
:class="{'has-color-bubble': project.hexColor !== ''}"
|
||||
>
|
||||
<icon icon="grip-lines" />
|
||||
</span>
|
||||
</div>
|
||||
<span class="project-menu-title">{{ getProjectTitle(project) }}</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-if="project.id > 0"
|
||||
class="favorite"
|
||||
:class="{'is-favorite': project.isFavorite}"
|
||||
@click="projectStore.toggleProjectFavorite(project)"
|
||||
>
|
||||
<icon :icon="project.isFavorite ? 'star' : ['far', 'star']" />
|
||||
</BaseButton>
|
||||
<ProjectSettingsDropdown
|
||||
class="menu-list-dropdown"
|
||||
:project="project"
|
||||
:level="level"
|
||||
>
|
||||
<template #trigger="{toggleOpen}">
|
||||
<BaseButton
|
||||
class="menu-list-dropdown-trigger"
|
||||
@click="toggleOpen"
|
||||
>
|
||||
<icon
|
||||
icon="ellipsis-h"
|
||||
class="icon"
|
||||
/>
|
||||
</BaseButton>
|
||||
</template>
|
||||
</ProjectSettingsDropdown>
|
||||
</div>
|
||||
<ProjectsNavigation
|
||||
v-if="canNestDeeper && childProjectsOpen && canCollapse"
|
||||
:model-value="childProjects"
|
||||
:can-edit-order="true"
|
||||
:can-collapse="canCollapse"
|
||||
:level="level + 1"
|
||||
/>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, ref} from 'vue'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import ProjectSettingsDropdown from '@/components/project/project-settings-dropdown.vue'
|
||||
import {getProjectTitle} from '@/helpers/getProjectTitle'
|
||||
import ColorBubble from '@/components/misc/colorBubble.vue'
|
||||
import ProjectsNavigation from '@/components/home/ProjectsNavigation.vue'
|
||||
import {canNestProjectDeeper} from '@/helpers/canNestProjectDeeper'
|
||||
|
||||
const {
|
||||
project,
|
||||
isLoading,
|
||||
canCollapse,
|
||||
level = 0,
|
||||
} = defineProps<{
|
||||
project: IProject,
|
||||
isLoading?: boolean,
|
||||
canCollapse?: boolean,
|
||||
level?: number,
|
||||
}>()
|
||||
|
||||
const projectStore = useProjectStore()
|
||||
const baseStore = useBaseStore()
|
||||
const currentProject = computed(() => baseStore.currentProject)
|
||||
|
||||
const childProjectsOpen = ref(true)
|
||||
|
||||
const childProjects = computed(() => {
|
||||
if (!canNestDeeper.value) {
|
||||
return []
|
||||
}
|
||||
|
||||
return projectStore.getChildProjects(project.id)
|
||||
.filter(p => !p.isArchived)
|
||||
.sort((a, b) => a.position - b.position)
|
||||
})
|
||||
|
||||
const canNestDeeper = computed(() => canNestProjectDeeper(level))
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.list-setting-spacer {
|
||||
width: 5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.project-is-collapsed {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.favorite {
|
||||
transition: opacity $transition, color $transition;
|
||||
opacity: 0;
|
||||
|
||||
&:hover,
|
||||
&.is-favorite {
|
||||
opacity: 1;
|
||||
color: var(--warning);
|
||||
}
|
||||
}
|
||||
|
||||
.list-menu:hover > div > .favorite {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.list-menu:hover > div > a > .color-bubble-handle-wrapper.is-draggable > {
|
||||
.saved-filter-icon,
|
||||
.color-bubble {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.is-touch .color-bubble {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.color-bubble-handle-wrapper {
|
||||
position: relative;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
margin-right: .25rem;
|
||||
flex-shrink: 0;
|
||||
|
||||
.color-bubble, .icon {
|
||||
transition: all $transition;
|
||||
position: absolute;
|
||||
width: 12px;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.project-menu-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.saved-filter-icon {
|
||||
color: var(--grey-300) !important;
|
||||
font-size: .75rem;
|
||||
}
|
||||
|
||||
.is-touch .handle.has-color-bubble {
|
||||
display: none !important;
|
||||
}
|
||||
</style>
|
|
@ -1,138 +1,119 @@
|
|||
<template>
|
||||
<header
|
||||
:class="{ 'has-background': background, 'menu-active': menuActive }"
|
||||
:class="{'has-background': background, 'menu-active': menuActive}"
|
||||
aria-label="main navigation"
|
||||
class="navbar d-print-none"
|
||||
class="navbar main-theme is-fixed-top d-print-none"
|
||||
>
|
||||
<router-link
|
||||
:to="{ name: 'home' }"
|
||||
class="logo-link"
|
||||
>
|
||||
<Logo
|
||||
width="164"
|
||||
height="48"
|
||||
/>
|
||||
<router-link :to="{name: 'home'}" class="logo-link">
|
||||
<Logo width="164" height="48"/>
|
||||
</router-link>
|
||||
<MenuButton class="menu-button"/>
|
||||
<div class="list-title" ref="listTitle" v-show="currentList.id">
|
||||
<template v-if="currentList.id">
|
||||
<h1
|
||||
:style="{ 'opacity': currentList.title === '' ? '0': '1' }"
|
||||
class="title">
|
||||
{{ currentList.title === '' ? $t('misc.loading') : getListTitle(currentList) }}
|
||||
</h1>
|
||||
|
||||
<MenuButton class="menu-button" />
|
||||
<BaseButton :to="{name: 'list.info', params: {listId: currentList.id}}" class="info-button">
|
||||
<icon icon="circle-info"/>
|
||||
</BaseButton>
|
||||
|
||||
<div
|
||||
v-if="currentProject?.id"
|
||||
class="project-title-wrapper"
|
||||
>
|
||||
<h1 class="project-title">
|
||||
{{ currentProject.title === '' ? $t('misc.loading') : getProjectTitle(currentProject) }}
|
||||
</h1>
|
||||
|
||||
<BaseButton
|
||||
:to="{ name: 'project.info', params: { projectId: currentProject.id } }"
|
||||
class="project-title-button"
|
||||
>
|
||||
<icon icon="circle-info" />
|
||||
</BaseButton>
|
||||
|
||||
<ProjectSettingsDropdown
|
||||
v-if="canWriteCurrentProject && currentProject.id !== -1"
|
||||
class="project-title-dropdown"
|
||||
:project="currentProject"
|
||||
>
|
||||
<template #trigger="{ toggleOpen }">
|
||||
<BaseButton
|
||||
class="project-title-button"
|
||||
@click="toggleOpen"
|
||||
>
|
||||
<icon
|
||||
icon="ellipsis-h"
|
||||
class="icon"
|
||||
/>
|
||||
</BaseButton>
|
||||
</template>
|
||||
</ProjectSettingsDropdown>
|
||||
<list-settings-dropdown v-if="canWriteCurrentList && currentList.id !== -1" :list="currentList"/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="navbar-end">
|
||||
<OpenQuickActions />
|
||||
<Notifications />
|
||||
<Dropdown>
|
||||
<template #trigger="{ toggleOpen, open }">
|
||||
<BaseButton
|
||||
class="username-dropdown-trigger"
|
||||
variant="secondary"
|
||||
:shadow="false"
|
||||
@click="toggleOpen"
|
||||
>
|
||||
<img
|
||||
:src="authStore.avatarUrl"
|
||||
alt=""
|
||||
class="avatar"
|
||||
width="40"
|
||||
height="40"
|
||||
<update/>
|
||||
<BaseButton
|
||||
@click="openQuickActions"
|
||||
class="trigger-button pr-0"
|
||||
v-shortcut="'Control+k'"
|
||||
:title="$t('keyboardShortcuts.quickSearch')"
|
||||
>
|
||||
<icon icon="search"/>
|
||||
</BaseButton>
|
||||
<notifications/>
|
||||
<div class="user">
|
||||
<dropdown class="is-right" ref="usernameDropdown">
|
||||
<template #trigger="{toggleOpen}">
|
||||
<x-button
|
||||
class="username-dropdown-trigger"
|
||||
@click="toggleOpen()"
|
||||
variant="secondary"
|
||||
:shadow="false"
|
||||
>
|
||||
<span class="username">{{ authStore.userDisplayName }}</span>
|
||||
<span
|
||||
class="icon is-small"
|
||||
:style="{
|
||||
transform: open ? 'rotate(180deg)' : 'rotate(0)',
|
||||
}"
|
||||
>
|
||||
<icon icon="chevron-down" />
|
||||
</span>
|
||||
</BaseButton>
|
||||
</template>
|
||||
<img :src="authStore.avatarUrl" alt="" class="avatar" width="40" height="40"/>
|
||||
<span class="username">{{ authStore.userDisplayName }}</span>
|
||||
<span class="icon is-small">
|
||||
<icon icon="chevron-down"/>
|
||||
</span>
|
||||
</x-button>
|
||||
</template>
|
||||
|
||||
<DropdownItem :to="{ name: 'user.settings' }">
|
||||
{{ $t('user.settings.title') }}
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
v-if="imprintUrl"
|
||||
:href="imprintUrl"
|
||||
>
|
||||
{{ $t('navigation.imprint') }}
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
v-if="privacyPolicyUrl"
|
||||
:href="privacyPolicyUrl"
|
||||
>
|
||||
{{ $t('navigation.privacy') }}
|
||||
</DropdownItem>
|
||||
<DropdownItem @click="baseStore.setKeyboardShortcutsActive(true)">
|
||||
{{ $t('keyboardShortcuts.title') }}
|
||||
</DropdownItem>
|
||||
<DropdownItem :to="{ name: 'about' }">
|
||||
{{ $t('about.title') }}
|
||||
</DropdownItem>
|
||||
<DropdownItem @click="authStore.logout()">
|
||||
{{ $t('user.auth.logout') }}
|
||||
</DropdownItem>
|
||||
</Dropdown>
|
||||
<dropdown-item
|
||||
:to="{name: 'user.settings'}"
|
||||
>
|
||||
{{ $t('user.settings.title') }}
|
||||
</dropdown-item>
|
||||
<dropdown-item
|
||||
v-if="imprintUrl"
|
||||
:href="imprintUrl"
|
||||
>
|
||||
{{ $t('navigation.imprint') }}
|
||||
</dropdown-item>
|
||||
<dropdown-item
|
||||
v-if="privacyPolicyUrl"
|
||||
:href="privacyPolicyUrl"
|
||||
>
|
||||
{{ $t('navigation.privacy') }}
|
||||
</dropdown-item>
|
||||
<dropdown-item
|
||||
@click="baseStore.setKeyboardShortcutsActive(true)"
|
||||
>
|
||||
{{ $t('keyboardShortcuts.title') }}
|
||||
</dropdown-item>
|
||||
<dropdown-item
|
||||
:to="{name: 'about'}"
|
||||
>
|
||||
{{ $t('about.title') }}
|
||||
</dropdown-item>
|
||||
<dropdown-item
|
||||
@click="authStore.logout()"
|
||||
>
|
||||
{{ $t('user.auth.logout') }}
|
||||
</dropdown-item>
|
||||
</dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import {ref, computed, onMounted, nextTick} from 'vue'
|
||||
|
||||
import { RIGHTS as Rights } from '@/constants/rights'
|
||||
import {RIGHTS as Rights} from '@/constants/rights'
|
||||
|
||||
import ProjectSettingsDropdown from '@/components/project/project-settings-dropdown.vue'
|
||||
import Update from '@/components/home/update.vue'
|
||||
import ListSettingsDropdown from '@/components/list/list-settings-dropdown.vue'
|
||||
import Dropdown from '@/components/misc/dropdown.vue'
|
||||
import DropdownItem from '@/components/misc/dropdown-item.vue'
|
||||
import Notifications from '@/components/notifications/notifications.vue'
|
||||
import Logo from '@/components/home/Logo.vue'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import MenuButton from '@/components/home/MenuButton.vue'
|
||||
import OpenQuickActions from '@/components/misc/OpenQuickActions.vue'
|
||||
|
||||
import { getProjectTitle } from '@/helpers/getProjectTitle'
|
||||
import {getListTitle} from '@/helpers/getListTitle'
|
||||
|
||||
import { useBaseStore } from '@/stores/base'
|
||||
import { useConfigStore } from '@/stores/config'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
import {useConfigStore} from '@/stores/config'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
|
||||
const baseStore = useBaseStore()
|
||||
const currentProject = computed(() => baseStore.currentProject)
|
||||
const currentList = computed(() => baseStore.currentList)
|
||||
const background = computed(() => baseStore.background)
|
||||
const canWriteCurrentProject = computed(() => baseStore.currentProject?.maxRight > Rights.READ)
|
||||
const canWriteCurrentList = computed(() => baseStore.currentList.maxRight > Rights.READ)
|
||||
const menuActive = computed(() => baseStore.menuActive)
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
@ -140,149 +121,183 @@ const authStore = useAuthStore()
|
|||
const configStore = useConfigStore()
|
||||
const imprintUrl = computed(() => configStore.legal.imprintUrl)
|
||||
const privacyPolicyUrl = computed(() => configStore.legal.privacyPolicyUrl)
|
||||
|
||||
const usernameDropdown = ref()
|
||||
const listTitle = ref()
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
if (typeof usernameDropdown.value === 'undefined' || typeof listTitle.value === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
const usernameWidth = usernameDropdown.value.$el.clientWidth
|
||||
listTitle.value.style.setProperty('--nav-username-width', `${usernameWidth}px`)
|
||||
})
|
||||
|
||||
function openQuickActions() {
|
||||
baseStore.setQuickActionsActive(true)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
$vikunja-nav-logo-full-width: 164px;
|
||||
$user-dropdown-width-mobile: 5rem;
|
||||
|
||||
.navbar {
|
||||
--navbar-button-min-width: 40px;
|
||||
--navbar-gap-width: 1rem;
|
||||
--navbar-icon-size: 1.25rem;
|
||||
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--navbar-gap-width);
|
||||
|
||||
background: var(--site-background);
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
padding-right: .5rem;
|
||||
}
|
||||
|
||||
@media screen and (min-width: $tablet) {
|
||||
padding-left: 2rem;
|
||||
padding-right: 1rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
&.menu-active {
|
||||
@media screen and (max-width: $tablet) {
|
||||
z-index: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// FIXME: notifications should provide a slot for the icon instead, so that we can style it as we want
|
||||
:deep() {
|
||||
.trigger-button {
|
||||
color: var(--grey-400);
|
||||
font-size: var(--navbar-icon-size);
|
||||
}
|
||||
}
|
||||
}
|
||||
$hamburger-menu-icon-spacing: 1rem;
|
||||
$hamburger-menu-icon-width: 28px;
|
||||
|
||||
.logo-link {
|
||||
display: none;
|
||||
padding: 0.5rem 0.75rem;
|
||||
|
||||
@media screen and (min-width: $tablet) {
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: .5rem;
|
||||
padding-left: 2rem;
|
||||
margin-right: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-button {
|
||||
align-self: stretch;
|
||||
margin-right: auto;
|
||||
align-self: stretch;
|
||||
flex: 0 0 auto;
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
margin-left: 1rem;
|
||||
margin-left: $hamburger-menu-icon-spacing;
|
||||
}
|
||||
}
|
||||
|
||||
.project-title-wrapper {
|
||||
margin-inline: auto;
|
||||
display: flex;
|
||||
.navbar.main-theme {
|
||||
background: var(--site-background);
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
// this makes the truncated text of the project title work
|
||||
// inside the flexbox parent
|
||||
min-width: 0;
|
||||
|
||||
@media screen and (min-width: $tablet) {
|
||||
padding-inline: var(--navbar-gap-width);
|
||||
@media screen and (max-width: $desktop) {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
.project-title {
|
||||
font-size: 1rem;
|
||||
// We need the following for overflowing ellipsis to work
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
|
||||
@media screen and (min-width: $tablet) {
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 1.75rem;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.project-title-dropdown {
|
||||
align-self: stretch;
|
||||
|
||||
.project-title-button {
|
||||
flex-grow: 1;
|
||||
.navbar-end {
|
||||
margin-left: 0;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.project-title-button {
|
||||
align-self: stretch;
|
||||
min-width: var(--navbar-button-min-width);
|
||||
display: flex;
|
||||
place-items: center;
|
||||
justify-content: center;
|
||||
font-size: var(--navbar-icon-size);
|
||||
color: var(--grey-400);
|
||||
}
|
||||
|
||||
.navbar-end {
|
||||
margin-left: auto;
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
|
||||
>* {
|
||||
min-width: var(--navbar-button-min-width);
|
||||
}
|
||||
}
|
||||
|
||||
.username-dropdown-trigger {
|
||||
padding-left: 1rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
text-transform: uppercase;
|
||||
font-size: .85rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.username {
|
||||
font-family: $vikunja-font;
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
display: none;
|
||||
&.menu-active {
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.user {
|
||||
width: $user-dropdown-width-mobile;
|
||||
|
||||
.username-dropdown-trigger {
|
||||
line-height: 1;
|
||||
padding: 0 0.25rem;
|
||||
height: 1rem;
|
||||
|
||||
.icon {
|
||||
width: .5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.username {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.avatar {
|
||||
border-radius: 100%;
|
||||
vertical-align: middle;
|
||||
height: 40px;
|
||||
margin-right: .5rem;
|
||||
.navbar {
|
||||
// FIXME: notifications should provide a slot for the icon instead, so that we can style it as we want
|
||||
:deep() {
|
||||
.trigger-button {
|
||||
cursor: pointer;
|
||||
color: var(--grey-400);
|
||||
padding: .5rem;
|
||||
font-size: 1.25rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
> * > .trigger-button {
|
||||
width: $navbar-icon-width;
|
||||
}
|
||||
}
|
||||
|
||||
.user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
span {
|
||||
font-family: $vikunja-font;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
border-radius: 100%;
|
||||
vertical-align: middle;
|
||||
height: 40px;
|
||||
margin-right: .5rem;
|
||||
}
|
||||
|
||||
.username-dropdown-trigger {
|
||||
background: none;
|
||||
|
||||
&:focus:not(:active), &:active {
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.list-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
$edit-icon-width: 1rem;
|
||||
|
||||
@media screen and (min-width: $tablet) {
|
||||
// We need a fixed width for overflowing ellipsis to work
|
||||
--nav-username-width: 0;
|
||||
width: calc(100vw - #{$user-dropdown-width-mobile} - #{2 * $hamburger-menu-icon-spacing} - #{$hamburger-menu-icon-width} - #{$edit-icon-width} - #{2 * $navbar-icon-width} - #{$vikunja-nav-logo-full-width} - var(--nav-username-width));
|
||||
}
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
// We need a fixed width for overflowing ellipsis to work
|
||||
width: calc(100vw - #{$user-dropdown-width-mobile} - #{2 * $hamburger-menu-icon-spacing} - #{$hamburger-menu-icon-width} - #{$edit-icon-width} - #{2 * $navbar-icon-width});
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
:deep(.dropdown-trigger) {
|
||||
color: var(--grey-400);
|
||||
margin-left: .5rem;
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.info-button {
|
||||
text-align: center;
|
||||
height: 1.25rem;
|
||||
line-height: 1.25rem;
|
||||
width: 2rem;
|
||||
margin-top: .25rem;
|
||||
padding: 0 .5rem;
|
||||
color: var(--grey-400);
|
||||
margin-left: .5rem;
|
||||
}
|
||||
</style>
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue