Compare commits

..

No commits in common. "main" and "v0.20.0" have entirely different histories.

528 changed files with 31544 additions and 48899 deletions

View File

@ -1,6 +1,5 @@
--- ---
kind: pipeline kind: pipeline
type: docker
name: build name: build
trigger: trigger:
@ -15,7 +14,6 @@ trigger:
services: services:
- name: api - name: api
image: vikunja/api:unstable image: vikunja/api:unstable
pull: always
environment: environment:
VIKUNJA_SERVICE_TESTINGTOKEN: averyLongSecretToSe33dtheDB VIKUNJA_SERVICE_TESTINGTOKEN: averyLongSecretToSe33dtheDB
VIKUNJA_LOG_LEVEL: DEBUG VIKUNJA_LOG_LEVEL: DEBUG
@ -24,7 +22,7 @@ steps:
# Disabled until we figure out why it is so slow # Disabled until we figure out why it is so slow
# - name: restore-cache # - name: restore-cache
# image: meltwater/drone-cache:dev # image: meltwater/drone-cache:dev
# pull: always # pull: true
# environment: # environment:
# AWS_ACCESS_KEY_ID: # AWS_ACCESS_KEY_ID:
# from_secret: cache_aws_access_key_id # from_secret: cache_aws_access_key_id
@ -42,12 +40,11 @@ steps:
# - .cache # - .cache
- name: dependencies - name: dependencies
image: node:20.11.0-alpine image: node:18-alpine
pull: always pull: true
environment: environment:
PNPM_CACHE_FOLDER: .cache/pnpm PNPM_CACHE_FOLDER: .cache/pnpm
CYPRESS_CACHE_FOLDER: .cache/cypress CYPRESS_CACHE_FOLDER: .cache/cypress
PUPPETEER_SKIP_DOWNLOAD: true
commands: commands:
- corepack enable && pnpm config set store-dir .cache/pnpm - corepack enable && pnpm config set store-dir .cache/pnpm
- pnpm install --fetch-timeout 100000 - pnpm install --fetch-timeout 100000
@ -55,8 +52,8 @@ steps:
# - restore-cache # - restore-cache
- name: lint - name: lint
image: node:20.11.0-alpine image: node:18-alpine
pull: always pull: true
environment: environment:
PNPM_CACHE_FOLDER: .cache/pnpm PNPM_CACHE_FOLDER: .cache/pnpm
commands: commands:
@ -66,8 +63,8 @@ steps:
- dependencies - dependencies
- name: build-prod - name: build-prod
image: node:20.11.0-alpine image: node:18-alpine
pull: always pull: true
environment: environment:
PNPM_CACHE_FOLDER: .cache/pnpm PNPM_CACHE_FOLDER: .cache/pnpm
commands: commands:
@ -77,8 +74,8 @@ steps:
- dependencies - dependencies
- name: test-unit - name: test-unit
image: node:20.11.0-alpine image: node:18-alpine
pull: always pull: true
commands: commands:
- corepack enable && pnpm config set store-dir .cache/pnpm - corepack enable && pnpm config set store-dir .cache/pnpm
- pnpm run test:unit - pnpm run test:unit
@ -87,8 +84,8 @@ steps:
- name: typecheck - name: typecheck
failure: ignore failure: ignore
image: node:20.11.0-alpine image: node:18-alpine
pull: always pull: true
environment: environment:
PNPM_CACHE_FOLDER: .cache/pnpm PNPM_CACHE_FOLDER: .cache/pnpm
commands: commands:
@ -98,8 +95,8 @@ steps:
- dependencies - dependencies
- name: test-frontend - name: test-frontend
image: cypress/browsers:node18.12.0-chrome107 image: cypress/browsers:node16.14.0-chrome99-ff97
pull: always pull: true
environment: environment:
CYPRESS_API_URL: http://api:3456/api/v1 CYPRESS_API_URL: http://api:3456/api/v1
CYPRESS_TEST_SECRET: averyLongSecretToSe33dtheDB CYPRESS_TEST_SECRET: averyLongSecretToSe33dtheDB
@ -112,13 +109,14 @@ steps:
- sed -i 's/localhost/api/g' dist/index.html - sed -i 's/localhost/api/g' dist/index.html
- corepack enable && pnpm config set store-dir .cache/pnpm - corepack enable && pnpm config set store-dir .cache/pnpm
- pnpm cypress install - 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: depends_on:
- build-prod - build-prod
# - name: rebuild-cache # - name: rebuild-cache
# image: meltwater/drone-cache:dev # image: meltwater/drone-cache:dev
# pull: always # pull: true
# environment: # environment:
# AWS_ACCESS_KEY_ID: # AWS_ACCESS_KEY_ID:
# from_secret: cache_aws_access_key_id # from_secret: cache_aws_access_key_id
@ -137,9 +135,8 @@ steps:
# - dependencies # - dependencies
- name: deploy-preview - name: deploy-preview
image: williamjackson/netlify-cli image: node:18-alpine
pull: always pull: true
user: root # The rest runs as root and thus the permissions wouldn't work
environment: environment:
NETLIFY_AUTH_TOKEN: NETLIFY_AUTH_TOKEN:
from_secret: netlify_auth_token from_secret: netlify_auth_token
@ -152,10 +149,8 @@ steps:
# Override the default api url used for preview # Override the default api url used for preview
- sed -i 's|http://localhost:3456|https://try.vikunja.io|g' dist-preview/index.html - sed -i 's|http://localhost:3456|https://try.vikunja.io|g' dist-preview/index.html
- apk add --no-cache perl-utils - apk add --no-cache perl-utils
# create via: - shasum -a 384 -c ./scripts/deploy-preview-netlify.js.sha384
# `shasum -a 384 ./scripts/deploy-preview-netlify.mjs > ./scripts/deploy-preview-netlify.mjs.sha384` - node ./scripts/deploy-preview-netlify.js
- shasum -a 384 -c ./scripts/deploy-preview-netlify.mjs.sha384
- node ./scripts/deploy-preview-netlify.mjs
depends_on: depends_on:
- build-prod - build-prod
when: when:
@ -165,7 +160,6 @@ steps:
--- ---
kind: pipeline kind: pipeline
type: docker
name: release-latest name: release-latest
depends_on: depends_on:
@ -185,7 +179,7 @@ steps:
# - name: restore-cache # - name: restore-cache
# image: meltwater/drone-cache:dev # image: meltwater/drone-cache:dev
# pull: always # pull: true
# environment: # environment:
# AWS_ACCESS_KEY_ID: # AWS_ACCESS_KEY_ID:
# from_secret: cache_aws_access_key_id # from_secret: cache_aws_access_key_id
@ -202,18 +196,14 @@ steps:
# - .cache # - .cache
- name: build - name: build
image: node:20.11.0-alpine image: node:18-alpine
pull: always pull: true
group: build-static
environment: environment:
PNPM_CACHE_FOLDER: .cache/pnpm 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: commands:
- apk add git - apk add git
- corepack enable && pnpm config set store-dir .cache/pnpm - corepack enable && pnpm config set store-dir .cache/.pnp
- pnpm install --fetch-timeout 100000 --frozen-lockfile - pnpm install --fetch-timeout 100000 --frozen-lockfile
- pnpm run lint - pnpm run lint
- "echo '{\"VERSION\": \"'$(git describe --tags --always --abbrev=10 | sed 's/-/+/' | sed 's/^v//' | sed 's/-g/-/')'\"}' > src/version.json" - "echo '{\"VERSION\": \"'$(git describe --tags --always --abbrev=10 | sed 's/-/+/' | sed 's/^v//' | sed 's/-g/-/')'\"}' > src/version.json"
@ -224,9 +214,8 @@ steps:
- name: static - name: static
image: kolaente/zip image: kolaente/zip
pull: always pull: true
commands: commands:
- cp src/version.json dist
- cd dist - cd dist
- zip -r ../vikunja-frontend-unstable.zip * - zip -r ../vikunja-frontend-unstable.zip *
- cd .. - cd ..
@ -234,7 +223,7 @@ steps:
- name: release - name: release
image: plugins/s3 image: plugins/s3
pull: always pull: true
settings: settings:
bucket: vikunja-releases bucket: vikunja-releases
access_key: access_key:
@ -250,7 +239,6 @@ steps:
--- ---
kind: pipeline kind: pipeline
type: docker
name: release-version name: release-version
depends_on: depends_on:
@ -268,7 +256,7 @@ steps:
# - name: restore-cache # - name: restore-cache
# image: meltwater/drone-cache:dev # image: meltwater/drone-cache:dev
# pull: always # pull: true
# environment: # environment:
# AWS_ACCESS_KEY_ID: # AWS_ACCESS_KEY_ID:
# from_secret: cache_aws_access_key_id # from_secret: cache_aws_access_key_id
@ -285,14 +273,11 @@ steps:
# - .cache # - .cache
- name: build - name: build
image: node:20.11.0-alpine image: node:18-alpine
pull: always pull: true
group: build-static
environment: environment:
PNPM_CACHE_FOLDER: .cache/pnpm PNPM_CACHE_FOLDER: .cache/pnpm
SENTRY_AUTH_TOKEN:
from_secret: sentry_auth_token
SENTRY_ORG: vikunja
SENTRY_PROJECT: frontend-oss
commands: commands:
- apk add git - apk add git
- corepack enable && pnpm config set store-dir .cache/pnpm - corepack enable && pnpm config set store-dir .cache/pnpm
@ -306,9 +291,8 @@ steps:
- name: static - name: static
image: kolaente/zip image: kolaente/zip
pull: always pull: true
commands: commands:
- cp src/version.json dist
- cd dist - cd dist
- zip -r ../vikunja-frontend-${DRONE_TAG##v}.zip * - zip -r ../vikunja-frontend-${DRONE_TAG##v}.zip *
- cd .. - cd ..
@ -316,7 +300,7 @@ steps:
- name: release - name: release
image: plugins/s3 image: plugins/s3
pull: always pull: true
settings: settings:
bucket: vikunja-releases bucket: vikunja-releases
access_key: access_key:
@ -332,7 +316,6 @@ steps:
--- ---
kind: pipeline kind: pipeline
type: docker
name: trigger-desktop-update name: trigger-desktop-update
trigger: trigger:
@ -357,10 +340,15 @@ steps:
--- ---
kind: pipeline kind: pipeline
type: docker type: docker
name: docker-release name: docker-arm-release
depends_on: depends_on:
- build - release-latest
- release-version
platform:
os: linux
arch: arm64
trigger: trigger:
ref: ref:
@ -371,65 +359,201 @@ trigger:
- cron - cron
steps: steps:
- name: fetch-tags
image: docker:git
commands:
- git fetch --tags
- name: docker-unstable - name: docker-unstable
image: thegeeklab/drone-docker-buildx image: plugins/docker:linux-arm
privileged: true pull: true
pull: always
settings: settings:
username: username:
from_secret: docker_username from_secret: docker_username
password: password:
from_secret: docker_password from_secret: docker_password
repo: vikunja/frontend repo: vikunja/frontend
tags: unstable tags: unstable-linux-arm
build_args: build_args:
- USE_RELEASE=false - USE_RELEASE=true
platforms: - RELEASE_VERSION=unstable
- linux/386 when:
- linux/amd64 ref:
- linux/arm/v6 - refs/heads/main
- linux/arm/v7 depends_on:
- linux/arm64/v8 - clone
depends_on: [ fetch-tags ]
- name: docker-version
image: plugins/docker:linux-arm
pull: true
settings:
username:
from_secret: docker_username
password:
from_secret: docker_password
repo: vikunja/frontend
auto_tag: true
auto_tag_suffix: linux-arm
build_args:
- USE_RELEASE=true
- RELEASE_VERSION=${DRONE_TAG##v}
when:
ref:
- "refs/tags/**"
depends_on:
- clone
- name: docker-unstable-arm64
image: plugins/docker:linux-arm64
pull: true
settings:
username:
from_secret: docker_username
password:
from_secret: docker_password
repo: vikunja/frontend
tags: unstable-linux-arm64
build_args:
- USE_RELEASE=true
- RELEASE_VERSION=unstable
when:
ref:
- refs/heads/main
depends_on:
- clone
- name: docker-version-arm64
image: plugins/docker:linux-arm64
pull: true
settings:
username:
from_secret: docker_username
password:
from_secret: docker_password
repo: vikunja/frontend
auto_tag: true
auto_tag_suffix: linux-arm64
build_args:
- USE_RELEASE=true
- RELEASE_VERSION=${DRONE_TAG##v}
when:
ref:
- "refs/tags/**"
depends_on:
- clone
---
kind: pipeline
type: docker
name: docker-amd64-release
platform:
os: linux
arch: amd64
depends_on:
- release-latest
- release-version
trigger:
ref:
- refs/heads/main
- "refs/tags/**"
event:
exclude:
- cron
steps:
- name: docker-unstable
image: plugins/docker:linux-amd64
pull: true
settings:
username:
from_secret: docker_username
password:
from_secret: docker_password
repo: vikunja/frontend
tags: unstable-linux-amd64
build_args:
- USE_RELEASE=true
- RELEASE_VERSION=unstable
when: when:
ref: ref:
- refs/heads/main - refs/heads/main
- name: generate-tags - name: docker-version
image: thegeeklab/docker-autotag image: plugins/docker:linux-amd64
environment: pull: true
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
settings: settings:
username: username:
from_secret: docker_username from_secret: docker_username
password: password:
from_secret: docker_password from_secret: docker_password
repo: vikunja/frontend repo: vikunja/frontend
auto_tag: true
auto_tag_suffix: linux-amd64
build_args: build_args:
- USE_RELEASE=false - USE_RELEASE=true
platforms: - RELEASE_VERSION=${DRONE_TAG##v}
- linux/386 when:
- linux/amd64 ref:
- linux/arm/v6 - "refs/tags/**"
- linux/arm/v7
- linux/arm64/v8 ---
depends_on: [ generate-tags ] kind: pipeline
type: docker
name: docker-manifest
trigger:
ref:
- refs/heads/main
- "refs/tags/**"
event:
exclude:
- cron
depends_on:
- docker-amd64-release
- docker-arm-release
steps:
- name: manifest-unstable
pull: always
image: plugins/manifest
settings:
tags: unstable
spec: docker-manifest-unstable.tmpl
password:
from_secret: docker_password
username:
from_secret: docker_username
when:
ref:
- refs/heads/main
- name: manifest-release
pull: always
image: plugins/manifest
settings:
auto_tag: true
ignore_missing: true
spec: docker-manifest.tmpl
password:
from_secret: docker_password
username:
from_secret: docker_username
when:
ref:
- "refs/tags/**"
- name: manifest-release-latest
pull: always
image: plugins/manifest
depends_on:
- clone
settings:
tags: latest
ignore_missing: true
spec: docker-manifest.tmpl
password:
from_secret: docker_password
username:
from_secret: docker_username
when: when:
ref: ref:
- "refs/tags/**" - "refs/tags/**"
@ -452,7 +576,9 @@ depends_on:
- release-version - release-version
- release-latest - release-latest
- trigger-desktop-update - trigger-desktop-update
- docker-release - docker-arm-release
- docker-amd64-release
- docker-manifest
steps: steps:
- name: notify - name: notify
@ -473,12 +599,13 @@ kind: pipeline
type: docker type: docker
name: update-translations name: update-translations
depends_on:
- build
trigger: trigger:
branch: branch:
include:
- main - main
event: event:
include:
- cron - cron
cron: cron:
- update_translations - update_translations
@ -486,14 +613,15 @@ trigger:
steps: steps:
- name: download - name: download
pull: always pull: always
image: ghcr.io/kolaente/kolaente/drone-crowdin-v2:latest image: jonasfranz/crowdin
settings: settings:
crowdin_key: download: true
export_dir: src/i18n/lang/
ignore_branch: true
project_identifier: vikunja
environment:
CROWDIN_KEY:
from_secret: crowdin_key from_secret: crowdin_key
project_id: 462614
target: download
download_to: src/i18n/lang/
download_export_approved_only: true
- name: move-files - name: move-files
pull: always pull: always
@ -513,25 +641,26 @@ steps:
author_name: Frederick [Bot] author_name: Frederick [Bot]
branch: main branch: main
commit: true 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" remote: "ssh://git@kolaente.dev:9022/vikunja/frontend.git"
ssh_key: ssh_key:
from_secret: git_push_ssh_key from_secret: translation_git_push_ssh_key
- name: upload - name: upload
pull: always pull: always
image: ghcr.io/kolaente/kolaente/drone-crowdin-v2:latest image: jonasfranz/crowdin
depends_on: depends_on:
- clone - clone
settings: settings:
crowdin_key: files:
en.json: src/i18n/lang/en.json
ignore_branch: true
project_identifier: vikunja
environment:
CROWDIN_KEY:
from_secret: crowdin_key from_secret: crowdin_key
project_id: 462614
target: upload
upload_files:
src/i18n/lang/en.json: en.json
--- ---
kind: signature kind: signature
hmac: a044c7c4db3c2a11299d4d118397e9d25be36db241723a1bbd0a2f9cc90ffdac hmac: 5dc7ab785b6e4d1611fc2851971e23c444d93d4988517f116e02e8c4d1af82f3
... ...

View File

@ -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

View File

@ -1,5 +1,5 @@
/* eslint-env node */ /* eslint-env node */
require('@rushstack/eslint-patch/modern-module-resolution') require("@rushstack/eslint-patch/modern-module-resolution")
module.exports = { module.exports = {
'root': true, 'root': true,
@ -7,54 +7,48 @@ module.exports = {
'browser': true, 'browser': true,
'es2022': true, 'es2022': true,
'node': true, 'node': true,
'vue/setup-compiler-macros': true,
}, },
'extends': [ 'extends': [
'eslint:recommended', 'eslint:recommended',
'plugin:vue/vue3-recommended', 'plugin:vue/vue3-essential',
'@vue/eslint-config-typescript/recommended', '@vue/eslint-config-typescript/recommended',
], ],
'rules': { 'rules': {
'quotes': ['error', 'single'], 'vue/html-quotes': [
'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': [
'error', '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 // see https://segmentfault.com/q/1010000040813116/a-1020000041134455 (original in chinese)
'vue/no-ref-object-destructure': 'error', 'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': ['error', { vars: 'all', args: 'after-used', ignoreRestSiblings: true }],
'vue/multi-word-component-names': 0,
}, },
'parser': 'vue-eslint-parser', 'parser': 'vue-eslint-parser',
'parserOptions': { 'parserOptions': {
'parser': '@typescript-eslint/parser', 'parser': '@typescript-eslint/parser',
'ecmaVersion': 'latest', 'ecmaVersion': 2022,
'sourceType': 'module',
}, },
'ignorePatterns': [ 'ignorePatterns': [
'*.test.*', '*.test.*',
'cypress/*', 'cypress/*',
], ],
'globals': {
'defineProps': 'readonly',
},
} }

3
.github/FUNDING.yml vendored
View File

@ -1,3 +1,2 @@
github: kolaente github: kolaente
open_collective: vikunja custom: https://www.buymeacoffee.com/kolaente
custom: ["https://vikunja.cloud", "https://www.buymeacoffee.com/kolaente"]

View File

@ -1,7 +1,6 @@
name: Bug Report name: Bug Report
description: Found something you weren't expecting? Report it here! description: Found something you weren't expecting? Report it here!
labels: labels: kind/bug
- kind/bug
body: body:
- type: markdown - type: markdown
attributes: attributes:

View File

@ -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.'

35
.gitignore vendored
View File

@ -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 .DS_Store
node_modules
/dist* /dist*
coverage
*.zip *.zip
.direnv/ .direnv/
# Test files
cypress/screenshots
cypress/videos
# local env files # local env files
.env.local .env.local
.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 # Editor directories and files
.vscode
.idea .idea
.vscode
*.suo *.suo
*.ntvs* *.ntvs*
*.njsproj *.njsproj
@ -33,9 +28,9 @@ cypress/videos
*.sw* *.sw*
!rollup.sw.js !rollup.sw.js
# Test files
cypress/screenshots
cypress/videos
# Local Netlify folder # Local Netlify folder
.netlify .netlify
# histoire
.histoire

14
.npmrc
View File

@ -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 auto-install-peers=true
dedupe-peer-dependents=true fetch-timeout=100000
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

2
.nvmrc
View File

@ -1 +1 @@
20.11.0 v18

View File

@ -8,7 +8,6 @@
"lokalise.i18n-ally", "lokalise.i18n-ally",
"mgmcdermott.vscode-language-babel", "mgmcdermott.vscode-language-babel",
"mikestead.dotenv", "mikestead.dotenv",
"Syler.sass-indented", "Syler.sass-indented"
"zixuanchen.vitest-explorer"
] ]
} }

View File

@ -18,12 +18,6 @@
"javascriptreact", "javascriptreact",
"vue" "vue"
], ],
"volar.completion.preferredTagNameCase": "pascal",
// disable vetur in case it is installed
"vetur.validation.template": false,
// i18n ally // i18n ally
"i18n-ally.localesPaths": [ "i18n-ally.localesPaths": [
"src/i18n/lang" "src/i18n/lang"

File diff suppressed because it is too large Load Diff

View File

@ -1,73 +1,49 @@
# syntax=docker/dockerfile:1 # Stage 1: Build application
# ┬─┐┬ ┐o┬ ┬─┐ FROM node:18-alpine AS compile-image
# │─││ │││ │ │
# ┘─┘┘─┘┘┘─┘┘─┘
FROM --platform=$BUILDPLATFORM node:20.11.0-alpine AS builder
WORKDIR /build WORKDIR /build
ARG USE_RELEASE=false ARG USE_RELEASE=false
ARG RELEASE_VERSION=unstable ARG RELEASE_VERSION=main
ENV PNPM_CACHE_FOLDER .cache/pnpm/ ENV PNPM_CACHE_FOLDER .cache/pnpm/
ENV PUPPETEER_SKIP_DOWNLOAD true ADD . ./
COPY package.json ./ RUN \
COPY pnpm-lock.yaml ./ if [ $USE_RELEASE = true ]; then \
COPY patches ./patches/ wget https://dl.vikunja.io/frontend/vikunja-frontend-$RELEASE_VERSION.zip -O frontend-release.zip && \
unzip frontend-release.zip -d dist/ && \
RUN if [ "$USE_RELEASE" != true ]; then \ exit 0; \
fi && \
# https://pnpm.io/installation#using-corepack # https://pnpm.io/installation#using-corepack
corepack enable && \ corepack enable && \
pnpm install; \
fi
COPY . ./
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
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 don't use corepack prepare here by intend since
# we have renovate to keep our dependencies up to date # we have renovate to keep our dependencies up to date
# Build the frontend # Build the frontend
pnpm run build; \ pnpm install && \
fi 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
# ┌┐┐┌─┐o┌┐┐┐ │ # Stage 2: copy
# ││││ ┬││││┌┼┘ FROM nginx:alpine
# ┘└┘┘─┘┘┘└┘┘ └
COPY nginx.conf /etc/nginx/nginx.conf
COPY run.sh /run.sh
# copy compiled files from stage 1
COPY --from=compile-image /build/dist /usr/share/nginx/html
# Unprivileged user
ENV PUID 1000
ENV PGID 1000
FROM nginx:stable-alpine AS runner
WORKDIR /usr/share/nginx/html
LABEL maintainer="maintainers@vikunja.io" LABEL maintainer="maintainers@vikunja.io"
ENV VIKUNJA_HTTP_PORT 80 RUN apk add --no-cache \
ENV VIKUNJA_HTTP2_PORT 81 # for sh file
ENV VIKUNJA_LOG_FORMAT main bash \
ENV VIKUNJA_API_URL /api/v1 # installs usermod and groupmod
ENV VIKUNJA_SENTRY_ENABLED false shadow
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 "''"
COPY docker/injector.sh /docker-entrypoint.d/50-injector.sh CMD "/run.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

View File

@ -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 # Web frontend for Vikunja
> The todo app to organize your life. > 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) [![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) [![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.0-brightgreen.svg)](https://dl.vikunja.io)
[![Translation](https://badges.crowdin.net/vikunja/localized.svg)](https://crowdin.com/project/vikunja) [![Translation](https://badges.crowdin.net/vikunja/localized.svg)](https://crowdin.com/project/vikunja)
This is the web frontend for Vikunja, written in Vue.js. 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 ## Docker
There is a [docker image available](https://hub.docker.com/r/vikunja/api) with support for http/2 and aggressive caching enabled. 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 ## Project setup
@ -54,3 +42,7 @@ pnpm run build
```shell ```shell
pnpm run lint pnpm run lint
``` ```
## Sponsors
[![Relm](https://vikunja.io/images/sponsors/relm.png)](https://relm.us)

0
crowdin.cli Normal file
View File

View File

@ -11,10 +11,8 @@ export default defineConfig({
}, },
projectId: '181c7x', projectId: '181c7x',
e2e: { e2e: {
specPattern: 'cypress/e2e/**/*.{cy,spec}.{js,jsx,ts,tsx}', baseUrl: 'http://localhost:4173',
baseUrl: 'http://127.0.0.1:4173', specPattern: 'cypress/e2e/**/*.{js,jsx,ts,tsx}',
experimentalRunAllSpecs: true,
// testIsolation: false,
}, },
component: { component: {
devServer: { devServer: {
@ -24,5 +22,4 @@ export default defineConfig({
}, },
viewportWidth: 1600, viewportWidth: 1600,
viewportHeight: 900, viewportHeight: 900,
experimentalMemoryManagement: true,
}) })

View File

@ -36,7 +36,7 @@ to get a shell inside the cypress container.
In that shell you can then execute the tests with In that shell you can then execute the tests with
```shell ```shell
pnpm run test:e2e pnpm run test:frontend
``` ```
### Using The Cypress Dashboard ### Using The Cypress Dashboard
@ -44,5 +44,5 @@ pnpm run test:e2e
To open the Cypress Dashboard and run tests from there, run To open the Cypress Dashboard and run tests from there, run
```shell ```shell
pnpm run test:e2e:dev pnpm run cypress:open
``` ```

View File

@ -9,7 +9,7 @@ services:
ports: ports:
- 3456:3456 - 3456:3456
cypress: cypress:
image: cypress/browsers:node18.12.0-chrome107 image: cypress/browsers:node16.14.0-chrome99-ff97
volumes: volumes:
- ..:/project - ..:/project
- $HOME/.cache:/home/node/.cache/ - $HOME/.cache:/home/node/.cache/

View File

@ -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('.list-cards-wrapper-2-rows')
.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)
})
})

View File

@ -0,0 +1,80 @@
import {formatISO, format} from 'date-fns'
import {TaskFactory} from '../../factories/task'
import {prepareLists} from './prepareLists'
import '../../support/authenticateUser'
describe('List View Gantt', () => {
prepareLists()
it('Hides tasks with no dates', () => {
const tasks = TaskFactory.create(1)
cy.visit('/lists/1/gantt')
cy.get('.g-gantt-rows-container')
.should('not.contain', tasks[0].title)
})
it('Shows tasks from the current and next month', () => {
const now = Date.UTC(2022, 8, 25)
cy.clock(now, ['Date'])
const nextMonth = new Date(now)
nextMonth.setDate(1)
nextMonth.setMonth(9)
cy.visit('/lists/1/gantt')
cy.get('.g-timeunits-container')
.should('contain', format(now, 'MMMM'))
.should('contain', format(nextMonth, 'MMMM'))
})
it('Shows tasks with dates', () => {
const now = new Date()
const tasks = TaskFactory.create(1, {
start_date: formatISO(now),
end_date: formatISO(now.setDate(now.getDate() + 4)),
})
cy.visit('/lists/1/gantt')
cy.get('.g-gantt-rows-container')
.should('not.be.empty')
.should('contain', tasks[0].title)
})
it('Shows tasks with no dates after enabling them', () => {
const tasks = TaskFactory.create(1, {
start_date: null,
end_date: null,
})
cy.visit('/lists/1/gantt')
cy.get('.gantt-options .fancycheckbox')
.contains('Show tasks which don\'t have dates set')
.click()
cy.get('.g-gantt-rows-container')
.should('not.be.empty')
.should('contain', tasks[0].title)
})
it('Drags a task around', () => {
cy.intercept('**/api/v1/tasks/*')
.as('taskUpdate')
const now = new Date()
TaskFactory.create(1, {
start_date: formatISO(now),
end_date: formatISO(now.setDate(now.getDate() + 4)),
})
cy.visit('/lists/1/gantt')
cy.get('.g-gantt-rows-container .g-gantt-row .g-gantt-row-bars-container div .g-gantt-bar')
.first()
.trigger('mousedown', {which: 1})
.trigger('mousemove', {clientX: 500, clientY: 0})
.trigger('mouseup', {force: true})
cy.wait('@taskUpdate')
})
})

View File

@ -1,38 +1,24 @@
import {createFakeUserAndLogin} from '../../support/authenticateUser'
import {BucketFactory} from '../../factories/bucket' import {BucketFactory} from '../../factories/bucket'
import {ProjectFactory} from '../../factories/project' import {ListFactory} from '../../factories/list'
import {TaskFactory} from '../../factories/task' import {TaskFactory} from '../../factories/task'
import {prepareProjects} from './prepareProjects' import {prepareLists} from './prepareLists'
function createSingleTaskInBucket(count = 1, attrs = {}) { import '../../support/authenticateUser'
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]
}
describe('Project View Kanban', () => {
createFakeUserAndLogin()
prepareProjects()
describe('List View Kanban', () => {
let buckets let buckets
prepareLists()
beforeEach(() => { beforeEach(() => {
buckets = BucketFactory.create(2) buckets = BucketFactory.create(2)
}) })
it('Shows all buckets with their tasks', () => { it('Shows all buckets with their tasks', () => {
const data = TaskFactory.create(10, { const data = TaskFactory.create(10, {
project_id: 1, list_id: 1,
bucket_id: 1, bucket_id: 1,
}) })
cy.visit('/projects/1/kanban') cy.visit('/lists/1/kanban')
cy.get('.kanban .bucket .title') cy.get('.kanban .bucket .title')
.contains(buckets[0].title) .contains(buckets[0].title)
@ -47,12 +33,12 @@ describe('Project View Kanban', () => {
it('Can add a new task to a bucket', () => { it('Can add a new task to a bucket', () => {
TaskFactory.create(2, { TaskFactory.create(2, {
project_id: 1, list_id: 1,
bucket_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) .contains(buckets[0].title)
.get('.bucket-footer .button') .get('.bucket-footer .button')
.contains('Add another task') .contains('Add another task')
@ -68,7 +54,7 @@ describe('Project View Kanban', () => {
}) })
it('Can create a new bucket', () => { it('Can create a new bucket', () => {
cy.visit('/projects/1/kanban') cy.visit('/lists/1/kanban')
cy.get('.kanban .bucket.new-bucket .button') cy.get('.kanban .bucket.new-bucket .button')
.click() .click()
@ -82,9 +68,9 @@ describe('Project View Kanban', () => {
}) })
it('Can set a bucket limit', () => { 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() .first()
.click() .click()
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-menu .dropdown-item') 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', () => { 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() .first()
.type('{selectall}New Bucket Title{enter}') .type('{selectall}New Bucket Title{enter}')
cy.get('.kanban .bucket .bucket-header .title') cy.get('.kanban .bucket .bucket-header .title')
@ -114,9 +100,9 @@ describe('Project View Kanban', () => {
}) })
it('Can delete a bucket', () => { 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() .first()
.click() .click()
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-menu .dropdown-item') 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', () => { it('Can drag tasks around', () => {
const tasks = TaskFactory.create(2, { const tasks = TaskFactory.create(2, {
project_id: 1, list_id: 1,
bucket_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) .contains(tasks[0].title)
.first() .first()
.drag('.kanban .bucket:nth-child(2) .tasks') .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', () => { it('Should navigate to the task when the task card is clicked', () => {
const tasks = TaskFactory.create(5, { const tasks = TaskFactory.create(5, {
id: '{increment}', id: '{increment}',
project_id: 1, list_id: 1,
bucket_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) .contains(tasks[0].title)
.should('be.visible') .should('be.visible')
.click() .click()
@ -171,20 +157,20 @@ describe('Project View Kanban', () => {
.should('contain', `/tasks/${tasks[0].id}`, { timeout: 1000 }) .should('contain', `/tasks/${tasks[0].id}`, { timeout: 1000 })
}) })
it('Should remove a task from the kanban board when moving it to another project', () => { it('Should remove a task from the kanban board when moving it to another list', () => {
const projects = ProjectFactory.create(2) const lists = ListFactory.create(2)
BucketFactory.create(2, { BucketFactory.create(2, {
project_id: '{increment}', list_id: '{increment}',
}) })
const tasks = TaskFactory.create(5, { const tasks = TaskFactory.create(5, {
id: '{increment}', id: '{increment}',
project_id: 1, list_id: 1,
bucket_id: 1, bucket_id: 1,
}) })
const task = tasks[0] 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) .contains(task.title)
.should('be.visible') .should('be.visible')
.click() .click()
@ -193,7 +179,7 @@ describe('Project View Kanban', () => {
.contains('Move') .contains('Move')
.click() .click()
cy.get('.task-view .content.details .field .multiselect.control .input-wrapper input') 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 // 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. // 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') cy.get('.task-view .content.details .field .multiselect.control .search-results')
@ -210,76 +196,12 @@ describe('Project View Kanban', () => {
it('Shows a button to filter the kanban board', () => { it('Shows a button to filter the kanban board', () => {
const data = TaskFactory.create(10, { const data = TaskFactory.create(10, {
project_id: 1, list_id: 1,
bucket_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') .should('exist')
}) })
it('Should remove a task from the board when deleting it', () => {
const task = createSingleTaskInBucket(5)
cy.visit('/projects/1/kanban')
cy.get('.kanban .bucket .tasks .task')
.contains(task.title)
.should('be.visible')
.click()
cy.get('.task-view .action-buttons .button')
.should('be.visible')
.contains('Delete')
.click()
cy.get('.modal-mask .modal-container .modal-content .header')
.should('contain', 'Delete this task')
cy.get('.modal-mask .modal-container .modal-content .actions .button')
.contains('Do it!')
.click()
cy.get('.global-notification')
.should('contain', 'Success')
cy.get('.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')
})
}) })

View File

@ -1,32 +1,31 @@
import {createFakeUserAndLogin} from '../../support/authenticateUser' import {UserListFactory} from '../../factories/users_list'
import {UserProjectFactory} from '../../factories/users_project'
import {TaskFactory} from '../../factories/task' import {TaskFactory} from '../../factories/task'
import {UserFactory} from '../../factories/user' import {UserFactory} from '../../factories/user'
import {ProjectFactory} from '../../factories/project' import {ListFactory} from '../../factories/list'
import {prepareProjects} from './prepareProjects' import {prepareLists} from './prepareLists'
describe('Project View Project', () => { import '../../support/authenticateUser'
createFakeUserAndLogin()
prepareProjects()
it('Should be an empty project', () => { describe('List View List', () => {
cy.visit('/projects/1') prepareLists()
it('Should be an empty list', () => {
cy.visit('/lists/1')
cy.url() cy.url()
.should('contain', '/projects/1/list') .should('contain', '/lists/1/list')
cy.get('.project-title') cy.get('.list-title h1')
.should('contain', 'First Project') .should('contain', 'First List')
cy.get('.project-title-dropdown') cy.get('.list-title .dropdown')
.should('exist') .should('exist')
cy.get('p') cy.get('p')
.contains('This project is currently empty.') .contains('This list is currently empty.')
.should('exist') .should('exist')
}) })
it('Should create a new task', () => { it('Should create a new task', () => {
const newTaskTitle = 'New task' const newTaskTitle = 'New task'
cy.visit('/projects/1') cy.visit('/lists/1')
cy.get('.task-add textarea') cy.get('.task-add textarea')
.type(newTaskTitle+'{enter}') .type(newTaskTitle+'{enter}')
cy.get('.tasks') cy.get('.tasks')
@ -36,9 +35,9 @@ describe('Project View Project', () => {
it('Should navigate to the task when the title is clicked', () => { it('Should navigate to the task when the title is clicked', () => {
const tasks = TaskFactory.create(5, { const tasks = TaskFactory.create(5, {
id: '{increment}', id: '{increment}',
project_id: 1, list_id: 1,
}) })
cy.visit('/projects/1/list') cy.visit('/lists/1/list')
cy.get('.tasks .task .tasktext') cy.get('.tasks .task .tasktext')
.contains(tasks[0].title) .contains(tasks[0].title)
@ -49,36 +48,37 @@ describe('Project View Project', () => {
.should('contain', `/tasks/${tasks[0].id}`) .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) UserFactory.create(2)
UserProjectFactory.create(1, { UserListFactory.create(1, {
project_id: 2, list_id: 2,
user_id: 1, user_id: 1,
right: 0, right: 0,
}) })
const projects = ProjectFactory.create(2, { const lists = ListFactory.create(2, {
owner_id: '{increment}', 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') .should('not.exist')
cy.get('input.input[placeholder="Add a new task..."') cy.get('input.input[placeholder="Add a new task..."')
.should('not.exist') .should('not.exist')
}) })
it('Should only show the color of a project in the navigation and not in the list view', () => { it('Should only show the color of a list in the navigation and not in the list view', () => {
const projects = ProjectFactory.create(1, { const lists = ListFactory.create(1, {
hex_color: '00db60', hex_color: '00db60',
}) })
TaskFactory.create(10, { 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') cy.get('.menu-list li .list-menu-link .color-bubble')
.should('have.css', 'background-color', 'rgb(0, 219, 96)') .should('have.css', 'background-color', 'rgb(0, 219, 96)')
cy.get('.tasks .color-bubble') cy.get('.tasks-container .tasks .color-bubble')
.should('not.exist') .should('not.exist')
}) })
@ -86,13 +86,13 @@ describe('Project View Project', () => {
const tasks = TaskFactory.create(100, { const tasks = TaskFactory.create(100, {
id: '{increment}', id: '{increment}',
title: i => `task${i}`, title: i => `task${i}`,
project_id: 1, list_id: 1,
}) })
cy.visit('/projects/1/list') cy.visit('/lists/1/list')
cy.get('.tasks') cy.get('.tasks-container .tasks')
.should('contain', tasks[1].title) .should('contain', tasks[1].title)
cy.get('.tasks') cy.get('.tasks-container .tasks')
.should('not.contain', tasks[99].title) .should('not.contain', tasks[99].title)
cy.get('.card-content .pagination .pagination-link') cy.get('.card-content .pagination .pagination-link')
@ -101,9 +101,9 @@ describe('Project View Project', () => {
cy.url() cy.url()
.should('contain', '?page=2') .should('contain', '?page=2')
cy.get('.tasks') cy.get('.tasks-container .tasks')
.should('contain', tasks[99].title) .should('contain', tasks[99].title)
cy.get('.tasks') cy.get('.tasks-container .tasks')
.should('not.contain', tasks[1].title) .should('not.contain', tasks[1].title)
}) })
}) })

View File

@ -1,38 +1,36 @@
import {createFakeUserAndLogin} from '../../support/authenticateUser'
import {TaskFactory} from '../../factories/task' import {TaskFactory} from '../../factories/task'
describe('Project View Table', () => { import '../../support/authenticateUser'
createFakeUserAndLogin()
describe('List View Table', () => {
it('Should show a table with tasks', () => { it('Should show a table with tasks', () => {
const tasks = TaskFactory.create(1) 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') .should('exist')
cy.get('.project-table table.table') cy.get('.list-table table.table')
.should('contain', tasks[0].title) .should('contain', tasks[0].title)
}) })
it('Should have working column switches', () => { it('Should have working column switches', () => {
TaskFactory.create(1) 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') .contains('Columns')
.click() .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') .contains('Priority')
.click() .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') .contains('Done')
.click() .click()
cy.get('.project-table table.table th') cy.get('.list-table table.table th')
.contains('Priority') .contains('Priority')
.should('exist') .should('exist')
cy.get('.project-table table.table th') cy.get('.list-table table.table th')
.contains('Done') .contains('Done')
.should('not.exist') .should('not.exist')
}) })
@ -40,11 +38,11 @@ describe('Project View Table', () => {
it('Should navigate to the task when the title is clicked', () => { it('Should navigate to the task when the title is clicked', () => {
const tasks = TaskFactory.create(5, { const tasks = TaskFactory.create(5, {
id: '{increment}', 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) .contains(tasks[0].title)
.click() .click()

View File

@ -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 .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 .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.')
})
})

View File

@ -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')
})
})

View File

@ -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)
})
}

View File

@ -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')
})
})

View File

@ -1,27 +1,21 @@
import {createFakeUserAndLogin} from '../../support/authenticateUser' import '../../support/authenticateUser'
describe('The Menu', () => { describe('The Menu', () => {
createFakeUserAndLogin()
beforeEach(() => {
cy.visit('/')
})
it('Is visible by default on desktop', () => { it('Is visible by default on desktop', () => {
cy.get('.menu-container') cy.get('.namespace-container')
.should('have.class', 'is-active') .should('have.class', 'is-active')
}) })
it('Can be hidden on desktop', () => { it('Can be hidden on desktop', () => {
cy.get('button.menu-show-button:visible') cy.get('button.menu-show-button:visible')
.click() .click()
cy.get('.menu-container') cy.get('.namespace-container')
.should('not.have.class', 'is-active') .should('not.have.class', 'is-active')
}) })
it('Is hidden by default on mobile', () => { it('Is hidden by default on mobile', () => {
cy.viewport('iphone-8') cy.viewport('iphone-8')
cy.get('.menu-container') cy.get('.namespace-container')
.should('not.have.class', 'is-active') .should('not.have.class', 'is-active')
}) })
@ -29,7 +23,7 @@ describe('The Menu', () => {
cy.viewport('iphone-8') cy.viewport('iphone-8')
cy.get('button.menu-show-button:visible') cy.get('button.menu-show-button:visible')
.click() .click()
cy.get('.menu-container') cy.get('.namespace-container')
.should('have.class', 'is-active') .should('have.class', 'is-active')
}) })
}) })

View File

@ -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)
})
}

View File

@ -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)
})
})

View File

@ -1,126 +0,0 @@
import {formatISO, format} from 'date-fns'
import {createFakeUserAndLogin} from '../../support/authenticateUser'
import {TaskFactory} from '../../factories/task'
import {prepareProjects} from './prepareProjects'
describe('Project View Gantt', () => {
createFakeUserAndLogin()
prepareProjects()
it('Hides tasks with no dates', () => {
const tasks = TaskFactory.create(1)
cy.visit('/projects/1/gantt')
cy.get('.g-gantt-rows-container')
.should('not.contain', tasks[0].title)
})
it('Shows tasks from the current and next month', () => {
const now = Date.UTC(2022, 8, 25)
cy.clock(now, ['Date'])
const nextMonth = new Date(now)
nextMonth.setDate(1)
nextMonth.setMonth(9)
cy.visit('/projects/1/gantt')
cy.get('.g-timeunits-container')
.should('contain', format(now, 'MMMM'))
.should('contain', format(nextMonth, 'MMMM'))
})
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(),
})
cy.visit('/projects/1/gantt')
cy.get('.g-gantt-rows-container')
.should('not.be.empty')
.should('contain', tasks[0].title)
})
it('Shows tasks with no dates after enabling them', () => {
const tasks = TaskFactory.create(1, {
start_date: null,
end_date: null,
})
cy.visit('/projects/1/gantt')
cy.get('.gantt-options .fancycheckbox')
.contains('Show tasks which don\'t have dates set')
.click()
cy.get('.g-gantt-rows-container')
.should('not.be.empty')
.should('contain', tasks[0].title)
})
it('Drags a task around', () => {
cy.intercept(Cypress.env('API_URL') + '/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(),
})
cy.visit('/projects/1/gantt')
cy.get('.g-gantt-rows-container .g-gantt-row .g-gantt-row-bars-container div .g-gantt-bar')
.first()
.trigger('mousedown', {which: 1})
.trigger('mousemove', {clientX: 500, clientY: 0})
.trigger('mouseup', {force: true})
cy.wait('@taskUpdate')
})
it('Should change the query parameters when selecting a date range', () => {
const now = Date.UTC(2022, 10, 9)
cy.clock(now, ['Date'])
cy.visit('/projects/1/gantt')
cy.get('.project-gantt .gantt-options .field .control input.input.form-control')
.click()
cy.get('.flatpickr-calendar .flatpickr-innerContainer .dayContainer .flatpickr-day')
.first()
.click()
cy.get('.flatpickr-calendar .flatpickr-innerContainer .dayContainer .flatpickr-day')
.last()
.click()
cy.url().should('contain', 'dateFrom=2022-09-25')
cy.url().should('contain', 'dateTo=2022-11-05')
})
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.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')
.should('have.value', '25 Sep 2022 to 5 Nov 2022')
})
it('Should open a task when double clicked on it', () => {
const now = new Date()
const tasks = TaskFactory.create(1, {
start_date: formatISO(now),
end_date: formatISO(now.setDate(now.getDate() + 4)),
})
cy.visit('/projects/1/gantt')
cy.get('.gantt-container .g-gantt-chart .g-gantt-row-bars-container .g-gantt-bar')
.dblclick()
cy.url()
.should('contain', `/tasks/${tasks[0].id}`)
})
})

View File

@ -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')
})
})

View File

@ -1,59 +1,25 @@
import {LinkShareFactory} from '../../factories/link_sharing' import {LinkShareFactory} from '../../factories/link_sharing'
import {ProjectFactory} from '../../factories/project' import {ListFactory} from '../../factories/list'
import {TaskFactory} from '../../factories/task' 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', () => { describe('Link shares', () => {
it('Can view a link share', () => { it('Can view a link share', () => {
const {share, project, tasks} = prepareLinkShare() const lists = ListFactory.create(1)
const tasks = TaskFactory.create(10, {
cy.visit(`/share/${share.hash}/auth`) list_id: lists[0].id
})
cy.get('h1.title') const linkShares = LinkShareFactory.create(1, {
.should('contain', project.title) list_id: lists[0].id,
cy.get('input.input[placeholder="Add a new task..."') right: 0,
.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', () => { cy.visit(`/share/${linkShares[0].hash}/auth`)
const {share, project, tasks} = prepareLinkShare()
cy.visit(`/projects/${project.id}/list#share-auth-token=${share.hash}`)
cy.get('h1.title') cy.get('h1.title')
.should('contain', project.title) .should('contain', lists[0].title)
cy.get('input.input[placeholder="Add a new task..."') cy.get('input.input[placeholder="Add a new task..."')
.should('not.exist') .should('not.exist')
cy.get('.tasks') cy.get('.tasks')
.should('contain', tasks[0].title) .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)
})
}) })

View File

@ -1,12 +1,9 @@
import {createFakeUserAndLogin} from '../../support/authenticateUser'
import {TeamFactory} from '../../factories/team' import {TeamFactory} from '../../factories/team'
import {TeamMemberFactory} from '../../factories/team_member' import {TeamMemberFactory} from '../../factories/team_member'
import {UserFactory} from '../../factories/user' import {UserFactory} from '../../factories/user'
import '../../support/authenticateUser'
describe('Team', () => { describe('Team', () => {
createFakeUserAndLogin()
it('Creates a new team', () => { it('Creates a new team', () => {
TeamFactory.truncate() TeamFactory.truncate()
cy.visit('/teams') cy.visit('/teams')

View File

@ -1,43 +1,45 @@
import {createFakeUserAndLogin} from '../../support/authenticateUser' import {ListFactory} from '../../factories/list'
import {ProjectFactory} from '../../factories/project'
import {seed} from '../../support/seed' import {seed} from '../../support/seed'
import {TaskFactory} from '../../factories/task' 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 {BucketFactory} from '../../factories/bucket'
import {updateUserSettings} from '../../support/updateUserSettings' import {updateUserSettings} from '../../support/updateUserSettings'
function seedTasks(numberOfTasks = 50, startDueDate = new Date()) { import '../../support/authenticateUser'
const project = ProjectFactory.create()[0]
function seedTasks(numberOfTasks = 100, startDueDate = new Date()) {
UserFactory.create(1)
NamespaceFactory.create(1)
const list = ListFactory.create()[0]
BucketFactory.create(1, { BucketFactory.create(1, {
project_id: project.id, list_id: list.id,
}) })
const tasks = [] const tasks = []
let dueDate = startDueDate let dueDate = startDueDate
for (let i = 0; i < numberOfTasks; i++) { for (let i = 0; i < numberOfTasks; i++) {
const now = new Date() 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({ tasks.push({
id: i + 1, id: i + 1,
project_id: project.id, list_id: list.id,
done: false, done: false,
created_by_id: 1, created_by_id: 1,
title: 'Test Task ' + i, title: 'Test Task ' + i,
index: i + 1, index: i + 1,
due_date: dueDate.toISOString(), due_date: formatISO(dueDate),
created: now.toISOString(), created: formatISO(now),
updated: now.toISOString(), updated: formatISO(now),
}) })
} }
seed(TaskFactory.table, tasks) seed(TaskFactory.table, tasks)
return {tasks, project} return {tasks, list}
} }
describe('Home Page Task Overview', () => { describe('Home Page Task Overview', () => {
createFakeUserAndLogin()
it('Should show tasks with a near due date first on the home page overview', () => { it('Should show tasks with a near due date first on the home page overview', () => {
const taskCount = 50 const {tasks} = seedTasks()
const {tasks} = seedTasks(taskCount)
cy.visit('/') cy.visit('/')
cy.get('[data-cy="showTasks"] .card .task') 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', () => { it('Should show overdue tasks first, then show other tasks', () => {
const now = new Date() const oldDate = (new Date()).setDate((new Date()).getDate() - 14)
const oldDate = new Date(new Date(now).setDate(now.getDate() - 14)) const {tasks} = seedTasks(100, oldDate)
const taskCount = 50
const {tasks} = seedTasks(taskCount, oldDate)
cy.visit('/') cy.visit('/')
cy.get('[data-cy="showTasks"] .card .task') cy.get('[data-cy="showTasks"] .card .task')
@ -68,10 +68,10 @@ describe('Home Page Task Overview', () => {
TaskFactory.create(1, { TaskFactory.create(1, {
id: 999, id: 999,
title: newTaskTitle, title: newTaskTitle,
due_date: new Date().toISOString(), due_date: formatISO(new Date()),
}, false) }, false)
cy.visit(`/projects/${tasks[0].project_id}/list`) cy.visit(`/lists/${tasks[0].list_id}/list`)
cy.get('.tasks .task') cy.get('.tasks .task')
.first() .first()
.should('contain.text', newTaskTitle) .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', () => { 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 // 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' const newTaskTitle = 'New Task'
cy.visit('/') cy.visit('/')
cy.visit(`/projects/${tasks[0].project_id}/list`) cy.visit(`/lists/${tasks[0].list_id}/list`)
cy.get('.task-add textarea') cy.get('.task-add textarea')
.type(newTaskTitle+'{enter}') .type(newTaskTitle+'{enter}')
cy.visit('/') cy.visit('/')
@ -111,10 +111,10 @@ describe('Home Page Task Overview', () => {
.should('contain.text', newTaskTitle) .should('contain.text', newTaskTitle)
}) })
it('Should show a task without a due date added via default project at the bottom', () => { it('Should show a task without a due date added via default list at the bottom', () => {
const {project} = seedTasks(40) const {list} = seedTasks(40)
updateUserSettings({ updateUserSettings({
default_project_id: project.id, default_list_id: list.id,
overdue_tasks_reminders_time: '9:00', overdue_tasks_reminders_time: '9:00',
}) })
@ -129,22 +129,23 @@ describe('Home Page Task Overview', () => {
.should('contain.text', newTaskTitle) .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() TaskFactory.truncate()
cy.visit('/') cy.visit('/')
cy.get('.home.app-content .content') 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() seedTasks()
cy.visit('/') cy.visit('/')
cy.get('.home.app-content .content') cy.get('.home.app-content .content')
.should('not.contain.text', 'You can create a new project for your new tasks:') .should('not.contain.text', 'You can create a new list for your new tasks:')
.should('not.contain.text', 'Or import your projects and tasks from other services into Vikunja:') .should('not.contain.text', 'Or import your lists and tasks from other services into Vikunja:')
}) })
}) })

View File

@ -1,17 +1,18 @@
import {createFakeUserAndLogin} from '../../support/authenticateUser' import {formatISO} from 'date-fns'
import {TaskFactory} from '../../factories/task' import {TaskFactory} from '../../factories/task'
import {ProjectFactory} from '../../factories/project' import {ListFactory} from '../../factories/list'
import {TaskCommentFactory} from '../../factories/task_comment' import {TaskCommentFactory} from '../../factories/task_comment'
import {UserFactory} from '../../factories/user' 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 {TaskAssigneeFactory} from '../../factories/task_assignee'
import {LabelFactory} from '../../factories/labels' import {LabelFactory} from '../../factories/labels'
import {LabelTaskFactory} from '../../factories/label_task' import {LabelTaskFactory} from '../../factories/label_task'
import {BucketFactory} from '../../factories/bucket' import {BucketFactory} from '../../factories/bucket'
import '../../support/authenticateUser'
import {TaskAttachmentFactory} from '../../factories/task_attachments' import {TaskAttachmentFactory} from '../../factories/task_attachments'
import {TaskReminderFactory} from '../../factories/task_reminders'
function addLabelToTaskAndVerify(labelTitle: string) { function addLabelToTaskAndVerify(labelTitle: string) {
cy.get('.task-view .action-buttons .button') cy.get('.task-view .action-buttons .button')
@ -36,7 +37,7 @@ function uploadAttachmentAndVerify(taskId: number) {
cy.get('.task-view .action-buttons .button') cy.get('.task-view .action-buttons .button')
.contains('Add Attachments') .contains('Add Attachments')
.click() .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 .selectFile('cypress/fixtures/image.jpg', {force: true}) // The input is not visible, but on purpose
cy.wait('@uploadAttachment') cy.wait('@uploadAttachment')
@ -45,23 +46,23 @@ function uploadAttachmentAndVerify(taskId: number) {
} }
describe('Task', () => { describe('Task', () => {
createFakeUserAndLogin() let namespaces
let lists
let projects
let buckets let buckets
beforeEach(() => { beforeEach(() => {
// UserFactory.create(1) UserFactory.create(1)
projects = ProjectFactory.create(1) namespaces = NamespaceFactory.create(1)
lists = ListFactory.create(1)
buckets = BucketFactory.create(1, { buckets = BucketFactory.create(1, {
project_id: projects[0].id, list_id: lists[0].id,
}) })
TaskFactory.truncate() TaskFactory.truncate()
UserProjectFactory.truncate() UserListFactory.truncate()
}) })
it('Should be created new', () => { it('Should be created new', () => {
cy.visit('/projects/1/list') cy.visit('/lists/1/list')
cy.get('.input[placeholder="Add a new task…"') cy.get('.input[placeholder="Add a new task…"')
.type('New Task') .type('New Task')
cy.get('.button') cy.get('.button')
@ -72,11 +73,11 @@ describe('Task', () => {
.should('contain', 'New 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) TaskFactory.create(1)
cy.visit('/projects/1/list') cy.visit('/lists/1/list')
cy.get('.project-is-empty-notice') cy.get('.list-is-empty-notice')
.should('not.exist') .should('not.exist')
cy.get('.input[placeholder="Add a new task…"') cy.get('.input[placeholder="Add a new task…"')
.type('New Task') .type('New Task')
@ -93,8 +94,8 @@ describe('Task', () => {
it('Marks a task as done', () => { it('Marks a task as done', () => {
TaskFactory.create(1) TaskFactory.create(1)
cy.visit('/projects/1/list') cy.visit('/lists/1/list')
cy.get('.tasks .task .fancycheckbox') cy.get('.tasks .task .fancycheckbox label.check')
.first() .first()
.click() .click()
cy.get('.global-notification') cy.get('.global-notification')
@ -104,65 +105,25 @@ describe('Task', () => {
it('Can add a task to favorites', () => { it('Can add a task to favorites', () => {
TaskFactory.create(1) TaskFactory.create(1)
cy.visit('/projects/1/list') cy.visit('/lists/1/list')
cy.get('.tasks .task .favorite') cy.get('.tasks .task .favorite')
.first() .first()
.click() .click()
cy.get('.menu-container') cy.get('.menu.namespaces-lists')
.should('contain', 'Favorites') .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', () => { describe('Task Detail View', () => {
beforeEach(() => { beforeEach(() => {
TaskCommentFactory.truncate() TaskCommentFactory.truncate()
LabelTaskFactory.truncate() LabelTaskFactory.truncate()
TaskAttachmentFactory.truncate()
}) })
it('Shows all task details', () => { it('Shows all task details', () => {
const tasks = TaskFactory.create(1, { const tasks = TaskFactory.create(1, {
id: 1, id: 1,
index: 1, index: 1,
description: 'Lorem ipsum dolor sit amet.', description: 'Lorem ipsum dolor sit amet.'
}) })
cy.visit(`/tasks/${tasks[0].id}`) cy.visit(`/tasks/${tasks[0].id}`)
@ -171,7 +132,8 @@ describe('Task', () => {
cy.get('.task-view h1.title.task-id') cy.get('.task-view h1.title.task-id')
.should('contain', '#1') .should('contain', '#1')
cy.get('.task-view h6.subtitle') 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') cy.get('.task-view .details.content.description')
.should('contain', tasks[0].description) .should('contain', tasks[0].description)
cy.get('.task-view .action-buttons p.created') cy.get('.task-view .action-buttons p.created')
@ -183,7 +145,7 @@ describe('Task', () => {
id: 1, id: 1,
index: 1, index: 1,
done: true, done: true,
done_at: new Date().toISOString(), done_at: formatISO(new Date())
}) })
cy.visit(`/tasks/${tasks[0].id}`) cy.visit(`/tasks/${tasks[0].id}`)
@ -216,33 +178,33 @@ describe('Task', () => {
.should('contain', 'Mark as undone') .should('contain', 'Mark as undone')
}) })
it('Shows a task identifier since the project has one', () => { it('Shows a task identifier since the list has one', () => {
const projects = ProjectFactory.create(1, { const lists = ListFactory.create(1, {
id: 1, id: 1,
identifier: 'TEST', identifier: 'TEST',
}) })
const tasks = TaskFactory.create(1, { const tasks = TaskFactory.create(1, {
id: 1, id: 1,
project_id: projects[0].id, list_id: lists[0].id,
index: 1, index: 1,
}) })
cy.visit(`/tasks/${tasks[0].id}`) cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view h1.title.task-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', () => { it('Can edit the description', () => {
const tasks = TaskFactory.create(1, { const tasks = TaskFactory.create(1, {
id: 1, id: 1,
description: 'Lorem ipsum dolor sit amet.', description: 'Lorem ipsum dolor sit amet.'
}) })
cy.visit(`/tasks/${tasks[0].id}`) 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() .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') .type('{selectall}New Description')
cy.get('[data-cy="saveEditor"]') cy.get('[data-cy="saveEditor"]')
.contains('Save') .contains('Save')
@ -253,52 +215,13 @@ describe('Task', () => {
.should('exist') .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', () => { it('Can add a new comment', () => {
const tasks = TaskFactory.create(1, { const tasks = TaskFactory.create(1, {
id: 1, id: 1,
}) })
cy.visit(`/tasks/${tasks[0].id}`) 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') .should('be.visible')
.type('{selectall}New Comment') .type('{selectall}New Comment')
cy.get('.task-view .comments .media.comment .button:not([disabled])') cy.get('.task-view .comments .media.comment .button:not([disabled])')
@ -306,20 +229,20 @@ describe('Task', () => {
.should('be.visible') .should('be.visible')
.click() .click()
cy.get('.task-view .comments .media.comment .tiptap__editor') cy.get('.task-view .comments .media.comment .editor')
.should('contain', 'New Comment') .should('contain', 'New Comment')
cy.get('.global-notification') cy.get('.global-notification')
.should('contain', 'Success') .should('contain', 'Success')
}) })
it('Can move a task to another project', () => { it('Can move a task to another list', () => {
const projects = ProjectFactory.create(2) const lists = ListFactory.create(2)
BucketFactory.create(2, { BucketFactory.create(2, {
project_id: '{increment}', list_id: '{increment}'
}) })
const tasks = TaskFactory.create(1, { const tasks = TaskFactory.create(1, {
id: 1, id: 1,
project_id: projects[0].id, list_id: lists[0].id,
}) })
cy.visit(`/tasks/${tasks[0].id}`) cy.visit(`/tasks/${tasks[0].id}`)
@ -327,7 +250,7 @@ describe('Task', () => {
.contains('Move') .contains('Move')
.click() .click()
cy.get('.task-view .content.details .field .multiselect.control .input-wrapper input') 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 // 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. // 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') cy.get('.task-view .content.details .field .multiselect.control .search-results')
@ -336,7 +259,8 @@ describe('Task', () => {
.click() .click()
cy.get('.task-view h6.subtitle') 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') cy.get('.global-notification')
.should('contain', 'Success') .should('contain', 'Success')
}) })
@ -344,7 +268,7 @@ describe('Task', () => {
it('Can delete a task', () => { it('Can delete a task', () => {
const tasks = TaskFactory.create(1, { const tasks = TaskFactory.create(1, {
id: 1, id: 1,
project_id: 1, list_id: 1,
}) })
cy.visit(`/tasks/${tasks[0].id}`) cy.visit(`/tasks/${tasks[0].id}`)
@ -361,17 +285,17 @@ describe('Task', () => {
cy.get('.global-notification') cy.get('.global-notification')
.should('contain', 'Success') .should('contain', 'Success')
cy.url() cy.url()
.should('contain', `/projects/${tasks[0].project_id}/`) .should('contain', `/lists/${tasks[0].list_id}/`)
}) })
it('Can add an assignee to a task', () => { it('Can add an assignee to a task', () => {
const users = UserFactory.create(5) const users = UserFactory.create(5)
const tasks = TaskFactory.create(1, { const tasks = TaskFactory.create(1, {
id: 1, id: 1,
project_id: 1, list_id: 1,
}) })
UserProjectFactory.create(5, { UserListFactory.create(5, {
project_id: 1, list_id: 1,
user_id: '{increment}', user_id: '{increment}',
}) })
@ -396,10 +320,10 @@ describe('Task', () => {
const users = UserFactory.create(2) const users = UserFactory.create(2)
const tasks = TaskFactory.create(1, { const tasks = TaskFactory.create(1, {
id: 1, id: 1,
project_id: 1, list_id: 1,
}) })
UserProjectFactory.create(5, { UserListFactory.create(5, {
project_id: 1, list_id: 1,
user_id: '{increment}', user_id: '{increment}',
}) })
TaskAssigneeFactory.create(1, { TaskAssigneeFactory.create(1, {
@ -422,7 +346,7 @@ describe('Task', () => {
it('Can add a new label to a task', () => { it('Can add a new label to a task', () => {
const tasks = TaskFactory.create(1, { const tasks = TaskFactory.create(1, {
id: 1, id: 1,
project_id: 1, list_id: 1,
}) })
LabelFactory.truncate() LabelFactory.truncate()
const newLabelText = 'some new label' const newLabelText = 'some new label'
@ -450,7 +374,7 @@ describe('Task', () => {
it('Can add an existing label to a task', () => { it('Can add an existing label to a task', () => {
const tasks = TaskFactory.create(1, { const tasks = TaskFactory.create(1, {
id: 1, id: 1,
project_id: 1, list_id: 1,
}) })
const labels = LabelFactory.create(1) const labels = LabelFactory.create(1)
LabelTaskFactory.truncate() LabelTaskFactory.truncate()
@ -463,13 +387,13 @@ describe('Task', () => {
it('Can add a label to a task and it shows up on the kanban board afterwards', () => { it('Can add a label to a task and it shows up on the kanban board afterwards', () => {
const tasks = TaskFactory.create(1, { const tasks = TaskFactory.create(1, {
id: 1, id: 1,
project_id: projects[0].id, list_id: lists[0].id,
bucket_id: buckets[0].id, bucket_id: buckets[0].id,
}) })
const labels = LabelFactory.create(1) const labels = LabelFactory.create(1)
LabelTaskFactory.truncate() LabelTaskFactory.truncate()
cy.visit(`/projects/${projects[0].id}/kanban`) cy.visit(`/lists/${lists[0].id}/kanban`)
cy.get('.bucket .task') cy.get('.bucket .task')
.contains(tasks[0].title) .contains(tasks[0].title)
@ -487,7 +411,7 @@ describe('Task', () => {
it('Can remove a label from a task', () => { it('Can remove a label from a task', () => {
const tasks = TaskFactory.create(1, { const tasks = TaskFactory.create(1, {
id: 1, id: 1,
project_id: 1, list_id: 1,
}) })
const labels = LabelFactory.create(1) const labels = LabelFactory.create(1)
LabelTaskFactory.create(1, { LabelTaskFactory.create(1, {
@ -497,10 +421,10 @@ describe('Task', () => {
cy.visit(`/tasks/${tasks[0].id}`) 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('be.visible')
.should('contain', labels[0].title) .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() .children()
.first() .first()
.get('[data-cy="taskDetail.removeLabel"]') .get('[data-cy="taskDetail.removeLabel"]')
@ -541,234 +465,6 @@ describe('Task', () => {
.should('contain', 'Success') .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', () => { it('Can set a priority for a task', () => {
const tasks = TaskFactory.create(1, { const tasks = TaskFactory.create(1, {
id: 1, id: 1,
@ -830,13 +526,13 @@ describe('Task', () => {
TaskAttachmentFactory.truncate() TaskAttachmentFactory.truncate()
const tasks = TaskFactory.create(1, { const tasks = TaskFactory.create(1, {
id: 1, id: 1,
project_id: projects[0].id, list_id: lists[0].id,
bucket_id: buckets[0].id, bucket_id: buckets[0].id,
}) })
const labels = LabelFactory.create(1) const labels = LabelFactory.create(1)
LabelTaskFactory.truncate() LabelTaskFactory.truncate()
cy.visit(`/projects/${projects[0].id}/kanban`) cy.visit(`/lists/${lists[0].id}/kanban`)
cy.get('.bucket .task') cy.get('.bucket .task')
.contains(tasks[0].title) .contains(tasks[0].title)
@ -855,114 +551,30 @@ describe('Task', () => {
const tasks = TaskFactory.create(1, { const tasks = TaskFactory.create(1, {
id: 1, id: 1,
description: ` description: `
<ul data-type="taskList"> This is a checklist:
<li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label>
<div><p>First Item</p></div> * [ ] one item
</li> * [ ] another item
<li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label> * [ ] third item
<div><p>Second Item</p></div> * [ ] fourth item
</li> * [x] and this one is already done
<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>`,
}) })
cy.visit(`/tasks/${tasks[0].id}`) cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view .checklist-summary') cy.get('.task-view .checklist-summary')
.should('contain.text', '1 of 5 tasks') .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) .eq(2)
.click() .click()
cy.get('.task-view .details.content.description h3 span.is-small.has-text-success') cy.get('.editor .content ul > li input[type=checkbox]')
.contains('Saved!')
.should('exist')
cy.get('.tiptap__editor ul > li input[type=checkbox]')
.eq(2) .eq(2)
.should('be.checked') .should('be.checked')
cy.get('.tiptap__editor input[type=checkbox]') cy.get('.editor .content input[type=checkbox]')
.should('have.length', 5) .should('have.length', 5)
cy.get('.task-view .checklist-summary') cy.get('.task-view .checklist-summary')
.should('contain.text', '2 of 5 tasks') .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)
})
})
})
})
}) })
}) })

View File

@ -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"
}
}

View File

@ -1,41 +1,41 @@
import {UserFactory} from '../../factories/user' import {UserFactory} from '../../factories/user'
import {ProjectFactory} from '../../factories/project'
const testAndAssertFailed = fixture => { const testAndAssertFailed = fixture => {
cy.intercept(Cypress.env('API_URL') + '/login*').as('login')
cy.visit('/login') cy.visit('/login')
cy.get('input[id=username]').type(fixture.username) cy.get('input[id=username]').type(fixture.username)
cy.get('input[id=password]').type(fixture.password) cy.get('input[id=password]').type(fixture.password)
cy.get('.button').contains('Login').click() 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.url().should('include', '/')
cy.get('div.message.danger').contains('Wrong username or password.') cy.get('div.message.danger').contains('Wrong username or password.')
} }
const credentials = { context('Login', () => {
beforeEach(() => {
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', username: 'test',
password: '1234', password: '1234',
} }
function login() { cy.visit('/login')
cy.get('input[id=username]').type(credentials.username) cy.get('input[id=username]').type(fixture.username)
cy.get('input[id=password]').type(credentials.password) cy.get('input[id=password]').type(fixture.password)
cy.get('.button').contains('Login').click() cy.get('.button').contains('Login').click()
cy.url().should('include', '/') cy.url().should('include', '/')
}
context('Login', () => {
beforeEach(() => {
UserFactory.create(1, {username: credentials.username})
})
it('Should log in with the right credentials', () => {
cy.visit('/login')
login()
cy.clock(1625656161057) // 13:00 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', () => { it('Should fail with a bad password', () => {
@ -60,15 +60,4 @@ context('Login', () => {
cy.visit('/') cy.visit('/')
cy.url().should('include', '/login') 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`)
})
}) })

View File

@ -1,17 +1,15 @@
import {createFakeUserAndLogin} from '../../support/authenticateUser' import '../../support/authenticateUser'
import {createProjects} from '../project/prepareProjects' import {createLists} from '../list/prepareLists'
function logout() { function logout() {
cy.get('.navbar .username-dropdown-trigger') cy.get('.navbar .user .username')
.click() .click()
cy.get('.navbar .dropdown-item') cy.get('.navbar .user .dropdown-menu .dropdown-item')
.contains('Logout') .contains('Logout')
.click() .click()
} }
describe('Log out', () => { describe('Log out', () => {
createFakeUserAndLogin()
it('Logs the user out', () => { it('Logs the user out', () => {
cy.visit('/') cy.visit('/')
@ -26,21 +24,21 @@ describe('Log out', () => {
}) })
}) })
it.skip('Should clear the project history after logging the user out', () => { it.skip('Should clear the list history after logging the user out', () => {
const projects = createProjects() const lists = createLists()
cy.visit(`/projects/${projects[0].id}`) cy.visit(`/lists/${lists[0].id}`)
.then(() => { .then(() => {
expect(localStorage.getItem('projectHistory')).to.not.eq(null) expect(localStorage.getItem('listHistory')).to.not.eq(null)
}) })
logout() 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() cy.url()
.should('contain', '/login') .should('contain', '/login')
.then(() => { .then(() => {
expect(localStorage.getItem('projectHistory')).to.eq(null) expect(localStorage.getItem('listHistory')).to.eq(null)
}) })
}) })
}) })

View File

@ -17,7 +17,7 @@ context('Registration', () => {
it('Should work without issues', () => { it('Should work without issues', () => {
const fixture = { const fixture = {
username: 'testuser', username: 'testuser',
password: '12345678', password: '123456',
email: 'testuser@example.com', email: 'testuser@example.com',
} }
@ -31,10 +31,10 @@ context('Registration', () => {
cy.get('h2').should('contain', `Hi ${fixture.username}!`) cy.get('h2').should('contain', `Hi ${fixture.username}!`)
}) })
it('Should fail', () => { it.only('Should fail', () => {
const fixture = { const fixture = {
username: 'test', username: 'test',
password: '12345678', password: '123456',
email: 'testuser@example.com', email: 'testuser@example.com',
} }

View File

@ -1,7 +1,11 @@
import {createFakeUserAndLogin} from '../../support/authenticateUser' import {UserFactory} from '../../factories/user'
import '../../support/authenticateUser'
describe('User Settings', () => { describe('User Settings', () => {
createFakeUserAndLogin() beforeEach(() => {
UserFactory.create(1)
})
it('Changes the user avatar', () => { it('Changes the user avatar', () => {
cy.intercept(`${Cypress.env('API_URL')}/user/settings/avatar/upload`).as('uploadAvatar') cy.intercept(`${Cypress.env('API_URL')}/user/settings/avatar/upload`).as('uploadAvatar')
@ -37,7 +41,7 @@ describe('User Settings', () => {
cy.get('.global-notification') cy.get('.global-notification')
.should('contain', 'Success') .should('contain', 'Success')
cy.get('.navbar .username-dropdown-trigger .username') cy.get('.navbar .user .username')
.should('contain', 'Lorem Ipsum') .should('contain', 'Lorem Ipsum')
}) })
}) })

View File

@ -1,5 +1,6 @@
import {faker} from '@faker-js/faker' import {faker} from '@faker-js/faker'
import {Factory} from '../support/factory' import {Factory} from '../support/factory'
import {formatISO} from 'date-fns'
export class BucketFactory extends Factory { export class BucketFactory extends Factory {
static table = 'buckets' static table = 'buckets'
@ -10,10 +11,10 @@ export class BucketFactory extends Factory {
return { return {
id: '{increment}', id: '{increment}',
title: faker.lorem.words(3), title: faker.lorem.words(3),
project_id: 1, list_id: 1,
created_by_id: 1, created_by_id: 1,
created: now.toISOString(), created: formatISO(now),
updated: now.toISOString(), updated: formatISO(now)
} }
} }
} }

View File

@ -1,4 +1,5 @@
import {Factory} from '../support/factory' import {Factory} from '../support/factory'
import {formatISO} from 'date-fns'
export class LabelTaskFactory extends Factory { export class LabelTaskFactory extends Factory {
static table = 'label_tasks' static table = 'label_tasks'
@ -10,7 +11,7 @@ export class LabelTaskFactory extends Factory {
id: '{increment}', id: '{increment}',
task_id: 1, task_id: 1,
label_id: 1, label_id: 1,
created: now.toISOString(), created: formatISO(now),
} }
} }
} }

View File

@ -1,6 +1,7 @@
import {faker} from '@faker-js/faker' import {faker} from '@faker-js/faker'
import {Factory} from '../support/factory' import {Factory} from '../support/factory'
import {formatISO} from 'date-fns'
export class LabelFactory extends Factory { export class LabelFactory extends Factory {
static table = 'labels' static table = 'labels'
@ -14,8 +15,8 @@ export class LabelFactory extends Factory {
description: faker.lorem.text(10), description: faker.lorem.text(10),
hex_color: (Math.random()*0xFFFFFF<<0).toString(16), // random 6-digit hex number hex_color: (Math.random()*0xFFFFFF<<0).toString(16), // random 6-digit hex number
created_by_id: 1, created_by_id: 1,
created: now.toISOString(), created: formatISO(now),
updated: now.toISOString(), updated: formatISO(now),
} }
} }
} }

View File

@ -1,4 +1,5 @@
import {Factory} from '../support/factory' import {Factory} from '../support/factory'
import {formatISO} from "date-fns"
import {faker} from '@faker-js/faker' import {faker} from '@faker-js/faker'
export class LinkShareFactory extends Factory { export class LinkShareFactory extends Factory {
@ -10,12 +11,12 @@ export class LinkShareFactory extends Factory {
return { return {
id: '{increment}', id: '{increment}',
hash: faker.random.word(32), hash: faker.random.word(32),
project_id: 1, list_id: 1,
right: 0, right: 0,
sharing_type: 0, sharing_type: 0,
shared_by_id: 1, shared_by_id: 1,
created: now.toISOString(), created: formatISO(now),
updated: now.toISOString(), updated: formatISO(now)
} }
} }
} }

20
cypress/factories/list.ts Normal file
View File

@ -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)
}
}
}

View File

@ -1,8 +1,9 @@
import {Factory} from '../support/factory'
import {faker} from '@faker-js/faker' import {faker} from '@faker-js/faker'
import {Factory} from '../support/factory'
import {formatISO} from 'date-fns'
export class ProjectFactory extends Factory { export class NamespaceFactory extends Factory {
static table = 'projects' static table = 'namespaces'
static factory() { static factory() {
const now = new Date() const now = new Date()
@ -11,8 +12,8 @@ export class ProjectFactory extends Factory {
id: '{increment}', id: '{increment}',
title: faker.lorem.words(3), title: faker.lorem.words(3),
owner_id: 1, owner_id: 1,
created: now.toISOString(), created: formatISO(now),
updated: now.toISOString(), updated: formatISO(now)
} }
} }
} }

View File

@ -1,5 +1,6 @@
import {faker} from '@faker-js/faker' import {faker} from '@faker-js/faker'
import {Factory} from '../support/factory' import {Factory} from '../support/factory'
import {formatISO} from 'date-fns'
export class TaskFactory extends Factory { export class TaskFactory extends Factory {
static table = 'tasks' static table = 'tasks'
@ -11,12 +12,12 @@ export class TaskFactory extends Factory {
id: '{increment}', id: '{increment}',
title: faker.lorem.words(3), title: faker.lorem.words(3),
done: false, done: false,
project_id: 1, list_id: 1,
created_by_id: 1, created_by_id: 1,
index: '{increment}', index: '{increment}',
position: '{increment}', position: '{increment}',
created: now.toISOString(), created: formatISO(now),
updated: now.toISOString() updated: formatISO(now)
} }
} }
} }

View File

@ -1,4 +1,5 @@
import {Factory} from '../support/factory' import {Factory} from '../support/factory'
import {formatISO} from 'date-fns'
export class TaskAssigneeFactory extends Factory { export class TaskAssigneeFactory extends Factory {
static table = 'task_assignees' static table = 'task_assignees'
@ -10,7 +11,7 @@ export class TaskAssigneeFactory extends Factory {
id: '{increment}', id: '{increment}',
task_id: 1, task_id: 1,
user_id: 1, user_id: 1,
created: now.toISOString(), created: formatISO(now),
} }
} }
} }

View File

@ -1,4 +1,5 @@
import {Factory} from '../support/factory' import {Factory} from '../support/factory'
import {formatISO} from 'date-fns'
export class TaskAttachmentFactory extends Factory { export class TaskAttachmentFactory extends Factory {
static table = 'task_attachments' static table = 'task_attachments'
@ -10,7 +11,7 @@ export class TaskAttachmentFactory extends Factory {
id: '{increment}', id: '{increment}',
task_id: 1, task_id: 1,
file_id: 1, file_id: 1,
created: now.toISOString(), created: formatISO(now),
} }
} }
} }

View File

@ -1,6 +1,7 @@
import {faker} from '@faker-js/faker' import {faker} from '@faker-js/faker'
import {Factory} from '../support/factory' import {Factory} from '../support/factory'
import {formatISO} from "date-fns"
export class TaskCommentFactory extends Factory { export class TaskCommentFactory extends Factory {
static table = 'task_comments' static table = 'task_comments'
@ -13,8 +14,8 @@ export class TaskCommentFactory extends Factory {
comment: faker.lorem.text(3), comment: faker.lorem.text(3),
author_id: 1, author_id: 1,
task_id: 1, task_id: 1,
created: now.toISOString(), created: formatISO(now),
updated: now.toISOString() updated: formatISO(now)
} }
} }
} }

View File

@ -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,
}
}
}

View File

@ -1,5 +1,6 @@
import {faker} from '@faker-js/faker' import {faker} from '@faker-js/faker'
import {Factory} from '../support/factory' import {Factory} from '../support/factory'
import {formatISO} from 'date-fns'
export class TeamFactory extends Factory { export class TeamFactory extends Factory {
static table = 'teams' static table = 'teams'
@ -10,8 +11,8 @@ export class TeamFactory extends Factory {
return { return {
name: faker.lorem.words(3), name: faker.lorem.words(3),
created_by_id: 1, created_by_id: 1,
created: now.toISOString(), created: formatISO(now),
updated: now.toISOString(), updated: formatISO(now)
} }
} }
} }

View File

@ -1,4 +1,5 @@
import {Factory} from '../support/factory' import {Factory} from '../support/factory'
import {formatISO} from 'date-fns'
export class TeamMemberFactory extends Factory { export class TeamMemberFactory extends Factory {
static table = 'team_members' static table = 'team_members'
@ -8,7 +9,7 @@ export class TeamMemberFactory extends Factory {
team_id: 1, team_id: 1,
user_id: 1, user_id: 1,
admin: false, admin: false,
created: new Date().toISOString(), created: formatISO(new Date()),
} }
} }
} }

View File

@ -1,6 +1,7 @@
import {faker} from '@faker-js/faker' import {faker} from '@faker-js/faker'
import {Factory} from '../support/factory' import {Factory} from '../support/factory'
import {formatISO} from "date-fns"
export class UserFactory extends Factory { export class UserFactory extends Factory {
static table = 'users' static table = 'users'
@ -14,8 +15,8 @@ export class UserFactory extends Factory {
password: '$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.', // 1234 password: '$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.', // 1234
status: 0, status: 0,
issuer: 'local', issuer: 'local',
created: now.toISOString(), created: formatISO(now),
updated: now.toISOString(), updated: formatISO(now)
} }
} }
} }

View File

@ -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)
}
}
}

View File

@ -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(),
}
}
}

View File

@ -4,32 +4,26 @@
import {UserFactory} from '../factories/user' import {UserFactory} from '../factories/user'
export function login(user, cacheAcrossSpecs = false) { let token
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)
})
}, {
cacheAcrossSpecs,
})
}
export function createFakeUserAndLogin() {
let user
before(() => { before(() => {
user = UserFactory.create(1)[0] 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
})
}) })
beforeEach(() => { beforeEach(() => {
login(user, true) cy.log(`Using token ${token} to make authenticated requests`)
cy.visit('/', {
onBeforeLoad(win) {
win.localStorage.setItem('token', token)
},
})
}) })
return user
}

View File

@ -35,3 +35,37 @@
// } // }
// } // }
// } // }
/**
* 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)
})
})
})

View File

@ -4,7 +4,7 @@ import {seed} from './seed'
* A factory makes it easy to seed the database with data. * A factory makes it easy to seed the database with data.
*/ */
export class Factory { export class Factory {
static table: string | null = null static table = null
static factory() { static factory() {
return {} return {}

10
cypress/tsconfig.json Normal file
View File

@ -0,0 +1,10 @@
{
"extends": "@vue/tsconfig/tsconfig.web.json",
"include": ["./integration/**/*", "./support/**/*"],
"compilerOptions": {
"isolatedModules": false,
"target": "es5",
"lib": ["es5", "dom"],
"types": ["cypress"]
}
}

View File

@ -0,0 +1,17 @@
image: vikunja/frontend:unstable
manifests:
-
image: vikunja/frontend:unstable-linux-amd64
platform:
architecture: amd64
os: linux
-
image: vikunja/frontend:unstable-linux-arm64
platform:
architecture: arm64
os: linux
-
image: vikunja/frontend:unstable-linux-arm
platform:
architecture: arm
os: linux

23
docker-manifest.tmpl Normal file
View File

@ -0,0 +1,23 @@
image: vikunja/frontend:{{#if build.tag}}{{trimPrefix "v" build.tag}}{{else}}latest{{/if}}
{{#if build.tags}}
tags:
{{#each build.tags}}
- {{this}}
{{/each}}
{{/if}}
manifests:
-
image: vikunja/frontend:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-amd64
platform:
architecture: amd64
os: linux
-
image: vikunja/frontend:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-arm64
platform:
architecture: arm64
os: linux
-
image: vikunja/frontend:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-arm
platform:
architecture: arm
os: linux

View File

@ -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'

View File

@ -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

View File

@ -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;
}

View File

@ -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 { }
}

View File

@ -30,21 +30,21 @@ A basic service can look like this:
```javascript ```javascript
import AbstractService from './abstractService' 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() { constructor() {
super({ super({
getAll: '/projects', getAll: '/lists',
get: '/projects/{id}', get: '/lists/{id}',
create: '/namespaces/{namespaceID}/projects', create: '/namespaces/{namespaceID}/lists',
update: '/projects/{id}', update: '/lists/{id}',
delete: '/projects/{id}', delete: '/lists/{id}',
}) })
} }
modelFactory(data) { modelFactory(data) {
return new ProjectModel(data) return new ListModel(data)
} }
} }
``` ```
@ -132,7 +132,7 @@ import AbstractModel from './abstractModel'
import TaskModel from './task' import TaskModel from './task'
import UserModel from './user' import UserModel from './user'
export default class ProjectModel extends AbstractModel { export default class ListModel extends AbstractModel {
constructor(data) { constructor(data) {
// The constructor of AbstractModel handles all the default parsing. // The constructor of AbstractModel handles all the default parsing.

9
env.config.d.ts vendored
View File

@ -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
}

30
env.d.ts vendored
View File

@ -1,33 +1,3 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
/// <reference types="vite-svg-loader" /> /// <reference types="vite-svg-loader" />
/// <reference types="cypress" /> /// <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
}

View File

@ -2,11 +2,11 @@
"nodes": { "nodes": {
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1701336116, "lastModified": 1664753041,
"narHash": "sha256-kEmpezCR/FpITc6yMbAh4WrOCiT2zg5pSjnKrq51h5Y=", "narHash": "sha256-0ogaD8PaGHluARFeupofvk1Nq9gpVeZdlFM0Kcwguys=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "f5c27c6136db4d76c30e533c20517df6864c46ee", "rev": "a62844b302507c7531ad68a86cb7aa54704c9cb4",
"type": "github" "type": "github"
}, },
"original": { "original": {

View File

@ -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',
},
})

View File

@ -9,7 +9,13 @@
<link rel="icon" href="/favicon.ico"> <link rel="icon" href="/favicon.ico">
<link rel="apple-touch-icon" href="/images/icons/apple-touch-icon-180x180.png"/> <link rel="apple-touch-icon" href="/images/icons/apple-touch-icon-180x180.png"/>
<!--__vite-plugin-inject-preload__--> <link rel="preload" crossorigin="anonymous" href="/fonts/open-sans-v15-latin-700italic.woff2" as="font">
<link rel="preload" crossorigin="anonymous" href="/fonts/open-sans-v15-latin-italic.woff2" as="font">
<link rel="preload" crossorigin="anonymous" href="/fonts/quicksand-v7-latin-500.woff2" as="font">
<link rel="preload" crossorigin="anonymous" href="/fonts/quicksand-v7-latin-700.woff2" as="font">
<link rel="preload" crossorigin="anonymous" href="/fonts/open-sans-v15-latin-regular.woff2" as="font">
<link rel="preload" crossorigin="anonymous" href="/fonts/open-sans-v15-latin-700.woff2" as="font">
<link rel="preload" crossorigin="anonymous" href="/fonts/quicksand-v7-latin-regular.woff2" as="font">
</head> </head>
<body> <body>
<noscript> <noscript>
@ -27,13 +33,6 @@
// our sentry instance to notify us of potential problems. // our sentry instance to notify us of potential problems.
window.SENTRY_ENABLED = false window.SENTRY_ENABLED = false
window.SENTRY_DSN = 'https://85694a2d757547cbbc90cd4b55c5a18d@o1047380.ingest.sentry.io/6024480' 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> </script>
</body> </body>
</html> </html>

115
nginx.conf Normal file
View File

@ -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.

View File

@ -1,187 +1,112 @@
{ {
"name": "vikunja-frontend", "name": "vikunja-frontend",
"description": "The todo app to organize your life.",
"private": true,
"version": "0.10.0", "version": "0.10.0",
"license": "AGPL-3.0-or-later", "private": true,
"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",
"scripts": { "scripts": {
"serve": "vite", "serve": "vite",
"preview": "vite preview --port 4173", "serve:dist-dev": "node scripts/serve-dist.js",
"preview:dev": "vite preview --outDir dist-dev --mode development --port 4173", "serve:dist": "vite preview --port 4173",
"build": "vite build && workbox copyLibraries dist/", "build": "vite build && workbox copyLibraries dist/",
"build:modern-only": "BUILD_MODERN_ONLY=true 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/", "build:dev": "vite build -m development --outDir dist-dev/",
"lint": "eslint 'src/**/*.{js,ts,vue}'", "lint": "eslint --ignore-pattern '*.test.*' ./src --ext .vue,.js,.ts",
"lint:fix": "pnpm run lint --fix", "cypress:open": "cypress open",
"test:e2e": "start-server-and-test preview http://127.0.0.1:4173 'cypress run --e2e --browser chrome'", "test:unit": "vitest --run",
"test:e2e-record": "start-server-and-test preview http://127.0.0.1:4173 'cypress run --e2e --browser chrome --record'", "test:unit-watch": "vitest watch",
"test:e2e-dev-dev": "start-server-and-test preview:dev http://127.0.0.1:4173 'cypress open --e2e'", "test:frontend": "cypress run",
"test:e2e-dev": "start-server-and-test preview http://127.0.0.1:4173 'cypress open --e2e'",
"test:unit": "vitest --dir ./src",
"typecheck": "vue-tsc --noEmit && vue-tsc --noEmit -p tsconfig.vitest.json --composite false", "typecheck": "vue-tsc --noEmit && vue-tsc --noEmit -p tsconfig.vitest.json --composite false",
"browserslist:update": "pnpm dlx browserslist@latest --update-db", "browserslist:update": "npx browserslist@latest --update-db"
"fonts:update": "pnpm fonts:download && pnpm 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"
}, },
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-svg-core": "6.5.1", "@fortawesome/fontawesome-svg-core": "6.2.0",
"@fortawesome/free-regular-svg-icons": "6.5.1", "@fortawesome/free-regular-svg-icons": "6.2.0",
"@fortawesome/free-solid-svg-icons": "6.5.1", "@fortawesome/free-solid-svg-icons": "6.2.0",
"@fortawesome/vue-fontawesome": "3.0.6", "@fortawesome/vue-fontawesome": "3.0.1",
"@github/hotkey": "3.1.0", "@github/hotkey": "2.0.1",
"@infectoone/vue-ganttastic": "2.2.0", "@infectoone/vue-ganttastic": "2.1.2",
"@intlify/unplugin-vue-i18n": "2.0.0", "@kyvg/vue3-notification": "2.4.1",
"@kyvg/vue3-notification": "3.1.4", "@sentry/tracing": "7.17.2",
"@sentry/tracing": "7.100.1", "@sentry/vue": "7.17.2",
"@sentry/vue": "7.100.1", "@types/is-touch-device": "1.0.0",
"@tiptap/core": "2.2.1", "@types/lodash.clonedeep": "4.5.7",
"@tiptap/extension-blockquote": "2.2.1", "@types/sortablejs": "1.15.0",
"@tiptap/extension-bold": "2.2.1", "@vueuse/core": "9.4.0",
"@tiptap/extension-bullet-list": "2.2.1", "@vueuse/router": "9.4.0",
"@tiptap/extension-code": "2.2.1", "axios": "0.27.2",
"@tiptap/extension-code-block-lowlight": "2.2.1", "blurhash": "2.0.3",
"@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",
"bulma-css-variables": "0.9.33", "bulma-css-variables": "0.9.33",
"camel-case": "4.1.2", "camel-case": "4.1.2",
"date-fns": "3.3.1", "codemirror": "5.65.9",
"dayjs": "1.11.10", "date-fns": "2.29.3",
"dompurify": "3.0.8", "dayjs": "1.11.6",
"dompurify": "2.4.0",
"easymde": "2.18.0",
"fast-deep-equal": "3.1.3", "fast-deep-equal": "3.1.3",
"flatpickr": "4.6.13", "flatpickr": "4.6.13",
"flexsearch": "0.7.31", "flexsearch": "0.7.21",
"floating-vue": "5.2.2", "floating-vue": "2.0.0-beta.20",
"highlight.js": "11.6.0",
"is-touch-device": "1.0.1", "is-touch-device": "1.0.1",
"klona": "2.0.6", "lodash.clonedeep": "4.5.0",
"lodash.debounce": "4.0.8", "lodash.debounce": "4.0.8",
"lowlight": "2.9.0", "marked": "4.1.1",
"pinia": "2.1.7", "minimist": "1.2.7",
"pinia": "2.0.23",
"register-service-worker": "1.7.2", "register-service-worker": "1.7.2",
"snake-case": "3.0.4", "snake-case": "3.0.4",
"sortablejs": "1.15.2", "sortablejs": "1.15.0",
"tippy.js": "6.3.7", "ufo": "0.8.6",
"ufo": "1.4.0", "vue": "3.2.41",
"vue": "3.4.15", "vue-advanced-cropper": "2.8.6",
"vue-advanced-cropper": "2.8.8", "vue-flatpickr-component": "10.0.0",
"vue-flatpickr-component": "11.0.3", "vue-i18n": "9.2.2",
"vue-i18n": "9.9.1", "vue-router": "4.1.6",
"vue-router": "4.2.5", "workbox-precaching": "6.5.4",
"workbox-precaching": "7.0.0",
"zhyswan-vuedraggable": "4.1.3" "zhyswan-vuedraggable": "4.1.3"
}, },
"devDependencies": { "devDependencies": {
"@4tw/cypress-drag-drop": "2.2.5", "@4tw/cypress-drag-drop": "2.2.1",
"@cypress/vite-dev-server": "5.0.7", "@cypress/vite-dev-server": "3.3.1",
"@cypress/vue": "6.0.0", "@cypress/vue": "4.2.1",
"@faker-js/faker": "8.4.0", "@faker-js/faker": "7.6.0",
"@histoire/plugin-screenshot": "0.17.8", "@rushstack/eslint-patch": "1.2.0",
"@histoire/plugin-vue": "0.17.9", "@types/dompurify": "2.3.4",
"@rushstack/eslint-patch": "1.7.2", "@types/flexsearch": "0.7.3",
"@tsconfig/node18": "18.2.2", "@types/lodash.debounce": "4.0.7",
"@types/codemirror": "5.60.15", "@types/marked": "4.0.7",
"@types/dompurify": "3.0.5", "@types/node": "18.11.7",
"@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",
"@types/postcss-preset-env": "7.7.0", "@types/postcss-preset-env": "7.7.0",
"@types/sortablejs": "1.15.7", "@typescript-eslint/eslint-plugin": "5.41.0",
"@typescript-eslint/eslint-plugin": "6.20.0", "@typescript-eslint/parser": "5.41.0",
"@typescript-eslint/parser": "6.20.0", "@vitejs/plugin-legacy": "2.3.0",
"@vitejs/plugin-legacy": "5.3.0", "@vitejs/plugin-vue": "3.2.0",
"@vitejs/plugin-vue": "5.0.3", "@vue/eslint-config-typescript": "11.0.2",
"@vue/eslint-config-typescript": "12.0.0", "@vue/test-utils": "2.2.0",
"@vue/test-utils": "2.4.4", "@vue/tsconfig": "0.1.3",
"@vue/tsconfig": "0.5.1", "autoprefixer": "10.4.13",
"autoprefixer": "10.4.17", "browserslist": "4.21.4",
"browserslist": "4.22.3", "caniuse-lite": "1.0.30001423",
"caniuse-lite": "1.0.30001581", "cypress": "10.11.0",
"css-has-pseudo": "6.0.1", "esbuild": "0.15.12",
"csstype": "3.1.3", "eslint": "8.26.0",
"cypress": "13.6.3", "eslint-plugin-vue": "9.6.0",
"esbuild": "0.20.0", "express": "4.18.2",
"eslint": "8.56.0", "happy-dom": "7.6.6",
"eslint-plugin-vue": "9.20.1", "netlify-cli": "12.0.11",
"happy-dom": "13.3.5", "postcss": "8.4.18",
"histoire": "0.17.9", "postcss-preset-env": "7.8.2",
"postcss": "8.4.33", "rollup": "3.2.3",
"postcss-easing-gradients": "3.0.1", "rollup-plugin-visualizer": "5.8.3",
"postcss-easings": "4.0.0", "sass": "1.55.0",
"postcss-focus-within": "8.0.1", "typescript": "4.8.4",
"postcss-preset-env": "9.3.0", "vite": "3.2.0",
"rollup": "4.9.6", "vite-plugin-pwa": "0.13.1",
"rollup-plugin-visualizer": "5.12.0", "vite-svg-loader": "3.6.0",
"sass": "1.70.0", "vitest": "0.24.3",
"start-server-and-test": "2.0.3", "vue-tsc": "1.0.9",
"typescript": "5.3.3", "wait-on": "6.0.1",
"vite": "5.0.12", "workbox-cli": "6.5.4"
"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"
}, },
"pnpm": { "license": "AGPL-3.0-or-later",
"patchedDependencies": { "packageManager": "pnpm@7.14.0"
"flexsearch@0.7.31": "patches/flexsearch@0.7.31.patch"
}
}
} }

View File

@ -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",

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -2,16 +2,11 @@
"$schema": "https://docs.renovatebot.com/renovate-schema.json", "$schema": "https://docs.renovatebot.com/renovate-schema.json",
"labels": ["dependencies"], "labels": ["dependencies"],
"extends": [ "extends": [
"config:js-app" "config:base"
],
"hostRules": [
{
"timeout": 600000
}
], ],
"packageRules": [ "packageRules": [
{ {
"matchPackageNames": ["happy-dom"], "matchPackageNames": ["netlify-cli", "happy-dom"],
"extends": ["schedule:weekly"] "extends": ["schedule:weekly"]
}, },
{ {
@ -25,24 +20,11 @@
"@vueuse/" "@vueuse/"
] ]
}, },
{
"groupName": "histoire",
"matchPackagePrefixes": [
"@histoire/",
"histoire"
]
},
{
"groupName": "tiptap",
"matchPackagePrefixes": [
"@tiptap/",
"tiptap"
]
},
{ {
"matchDepTypes": ["devDependencies"], "matchDepTypes": ["devDependencies"],
"groupName": "dev-dependencies", "automerge": true,
"extends": ["schedule:daily"] "automergeStrategy": "squash",
"automergeType": "pr"
} }
] ]
} }

28
run.sh Executable file
View File

@ -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;"

View File

@ -1,18 +1,15 @@
import { exec } from 'node:child_process' const {exec} = require('child_process')
const axios = require('axios')
function createSlug(string) { const BOT_USER_ID = 513
return String(string) const giteaToken = process.env.GITEA_TOKEN
const siteId = process.env.NETLIFY_SITE_ID
const branchSlug = String(process.env.DRONE_SOURCE_BRANCH)
.trim() .trim()
.normalize('NFKD') .normalize('NFKD')
.toLowerCase() .toLowerCase()
.replace(/[.\s/]/g, '-') .replace(/[.\s/]/g, '-')
.replace(/[^A-Za-z\d-]/g, '') .replace(/[^A-Za-z\d-]/g, '')
}
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 prNumber = process.env.DRONE_PULL_REQUEST const prNumber = process.env.DRONE_PULL_REQUEST
const prIssueCommentsUrl = `https://kolaente.dev/api/v1/repos/vikunja/frontend/issues/${prNumber}/comments` const prIssueCommentsUrl = `https://kolaente.dev/api/v1/repos/vikunja/frontend/issues/${prNumber}/comments`
@ -33,12 +30,12 @@ const promiseExec = cmd => {
} }
(async function () { (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) 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) console.log(stdout)
const data = await fetch(prIssueCommentsUrl).then(response => response.json()) const {data} = await axios.get(prIssueCommentsUrl)
const hasComment = data.some(c => c.user.id === BOT_USER_ID) const hasComment = data.some(c => c.user.id === BOT_USER_ID)
if (hasComment) { if (hasComment) {
@ -46,7 +43,8 @@ const promiseExec = cmd => {
return return
} }
const message = ` await axios.post(prIssueCommentsUrl, {
body: `
Hi ${process.env.DRONE_COMMIT_AUTHOR}! Hi ${process.env.DRONE_COMMIT_AUTHOR}!
Thank you for creating a PR! Thank you for creating a PR!
@ -59,25 +57,14 @@ You will need to manually connect this to an api running somehwere. The easiest
Have a nice day! Have a nice day!
> Beep boop, I'm a bot. > Beep boop, I'm a bot.
` `,
}, {
try {
const response = await fetch(prIssueCommentsUrl, {
method: 'POST',
body: JSON.stringify({
body: message,
}),
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'accept': 'application/json', 'accept': 'application/json',
'Authorization': `token ${giteaToken}`, 'Authorization': `token ${giteaToken}`,
}, },
}) })
if (!response.ok) {
throw new Error(`HTTP error, status = ${response.status}`)
}
console.log(`Preview comment sent successfully to PR #${prNumber}!`) console.log(`Preview comment sent successfully to PR #${prNumber}!`)
} catch (e) {
console.log(`Could not send preview comment to PR #${prNumber}! ${e.message}`)
}
})() })()

View File

@ -0,0 +1 @@
bb46342a0a08105b340ba7976cff9d80ef89901120ec0639669caa70bb7d2dbc43e78b1f635a7654ab2456e8358c98a4 ./scripts/deploy-preview-netlify.js

View File

@ -1 +0,0 @@
4a7c1293c7b12e9ab476cdf35251a407c6a1cd005d22c06df994222cccfb25cde5f47d15866a098c9d739778fee4dc19 ./scripts/deploy-preview-netlify.mjs

View File

@ -1,56 +0,0 @@
#!/bin/sh
set -e
#
# This script downloads our original font files from their source repos
# and puts them in our originalMedia folder.
#
err_report() {
echo "Error on line $(caller)" >&2
}
trap err_report ERR
ORIGINAL_FONTS_DIR="./originalMedia/fonts"
# update these if there is a new version
FONT_URLS=(
"https://github.com/googlefonts/opensans/blob/27d060e1aad6886daeda67629ee28189f795f534/fonts/variable/OpenSans%5Bwdth%2Cwght%5D.ttf?raw=true"
"https://github.com/googlefonts/opensans/blob/27d060e1aad6886daeda67629ee28189f795f534/fonts/variable/OpenSans-Italic%5Bwdth%2Cwght%5D.ttf?raw=true"
"https://github.com/andrew-paglinawan/QuicksandFamily/blob/db6de44878582966f45a0debaef10d57108d93a7/fonts/Quicksand%5Bwght%5D.ttf?raw=true"
)
echo ""
echo "###################################################"
echo "# Download font files"
echo "###################################################"
echo ""
mkdir -p $ORIGINAL_FONTS_DIR
for URL in ${FONT_URLS[@]}; do
wget -L $URL \
--directory-prefix=$ORIGINAL_FONTS_DIR \
--quiet \
--timestamping \
--show-progress
done
echo ""
echo "###################################################"
echo "# Remove '?raw=true' filename suffix"
echo "###################################################"
echo ""
# Iterate over all files in directory with filetype ending in "?raw=true"
for file in $ORIGINAL_FONTS_DIR/*?raw=true; do
# Remove "?raw=true" from file name and store in variable
new_name=$(echo $file | sed 's/?raw=true//')
# Overwrite existing file with new name
mv -v $file $new_name
done
echo "Renaming files complete"

View File

@ -1,161 +0,0 @@
#!/bin/sh
set -e
#
# This script subsets our variable fonts,
# converts them to woff2 files and puts them in the
# fonts folder.
#
# We do have to update the font paths in the @font-face
# definitions manually since we use a checksum to make
#
# We use fonttools to create a partial instance of the
# variable font where we keep only our needed features.
# See more at:
# https://fonttools.readthedocs.io/en/latest/varLib/instancer.html
#
# fonttools requires python > 3.7. For up-to-date
# instructions see https://github.com/fonttools/fonttools#installation
#
# Lot's of info was gathered from:
# https://markoskon.com/creating-font-subsets/
# https://barrd.dev/article/create-a-variable-font-subset-for-smaller-file-size/
#
ORIGINAL_FONTS="./originalMedia/fonts"
TEMP_FOLDER="./.subset-fonts-temp"
FONT_FOLDER="./src/assets/fonts"
err_report() {
echo "Error on line $(caller)" >&2
}
trap err_report ERR
mkdir -p $TEMP_FOLDER
# the latin subset that google uses on GoogleFonts
# this is the same as the latin subset range that google uses on GoogleFonts
# see for examle the unicode-range definition here:
# https://fonts.googleapis.com/css2?family=Open+Sans
UNICODE_LATIN_SUBSET="U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,\
U+02C6,U+02DA,U+02DC,U+2000-206F,U+2074,U+20AC,\
U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD"
get_filename_without_type() {
filename=$1
dirname=$(dirname $filename)
# Extract the file type using parameter expansion
filetype=${filename##*.}
basename=$(basename $filename .$filetype)
echo $basename
}
# This function takes a font file and creates a subset of it using a specified set of unicode characters.
instance_and_subset () {
# Define default arguments for the subsetter.
DEFAULT_SUBSETTER_ARGS="--layout-features=* --unicodes=${UNICODE_LATIN_SUBSET}"
# Assign function arguments to variables with more descriptive names.
INPUT_FONT_FILE=$1
INSTANCER_ARGS=$2
OUTPUT_FONT_BASENAME=$3
OUTPUT_FOLDER=$FONT_FOLDER
# If the output font basename is not provided, use the input font file's basename as the output font basename.
if [ -z "$OUTPUT_FONT_BASENAME" ]; then
INPUT_FONT_BASENAME=$(get_filename_without_type $INPUT_FONT_FILE)
OUTPUT_FONT_BASENAME=$INPUT_FONT_BASENAME
fi
# Use the default subsetter arguments if no custom arguments are provided.
SUBSETTER_ARGS="${4:-$DEFAULT_SUBSETTER_ARGS}"
CHECKSUM=$(
# Concatenate the contents of the input font file, the instancer arguments, and the subsetter arguments
printf "%s%s" "$(cat $INPUT_FONT_FILE)" "$INSTANCER_ARGS" "$SUBSETTER_ARGS" |
# Calculate the Blake2b checksum of the concatenated string
b2sum |
# Extract the checksum from the output of b2sum (it's the first field)
awk '{print $1}'
)
# Limit the checksum to 8 characters.
CHECKSUM=$(echo "${CHECKSUM:0:8}")
# Construct the output font's filename
OUTPUT_FONT_BASENAME="${OUTPUT_FONT_BASENAME}_${CHECKSUM}"
OUTPUT_FONT_FILE="${OUTPUT_FOLDER}/${OUTPUT_FONT_BASENAME}.woff2"
# Check if the output font file already exists
if test -f $OUTPUT_FONT_FILE; then
echo "${OUTPUT_FONT_FILE} exists"
return 0
fi
FONT_INSTANCE="${TEMP_FOLDER}/${OUTPUT_FONT_BASENAME}.ttf"
if [ -n "$INSTANCER_ARGS" ]; then
# If the INSTANCER_ARGS variable is set, use fonttools to create a font instance
fonttools varLib.instancer --output $FONT_INSTANCE $INPUT_FONT_FILE $INSTANCER_ARGS
else
# Otherwise, just copy the input font file to the font instance file
cp $INPUT_FONT_FILE $FONT_INSTANCE
fi
# Use pyftsubset to create a subset of the font instance and save it to the output font file
pyftsubset $FONT_INSTANCE --output-file=$OUTPUT_FONT_FILE --flavor=woff2 $SUBSETTER_ARGS
echo "${OUTPUT_FONT_BASENAME} subsetted."
}
echo ""
echo "###################################################"
echo "# Install required libs"
echo "###################################################"
echo ""
pip install fonttools brotli
echo ""
echo "###################################################"
echo "# Create a partial instance of the variable font"
echo "# where we keep only our needed features and then"
echo "# subset fonts with latin unicode range and export"
echo "# as woff2 file"
echo "###################################################"
echo ""
mkdir -p $TEMP_FOLDER
echo "\nOpen Sans"
# we drop the wdth axis for all
instance_and_subset "${ORIGINAL_FONTS}/OpenSans[wdth,wght].ttf" "wdth=drop wght=400:700" "OpenSans[wght]"
# we restrict the wght range
instance_and_subset "${ORIGINAL_FONTS}/OpenSans[wdth,wght].ttf" "wdth=drop wght=400" "OpenSans-Regular"
instance_and_subset "${ORIGINAL_FONTS}/OpenSans[wdth,wght].ttf" "wdth=drop wght=700" "OpenSans-Bold"
echo "\nOpen Sans Italic"
# we drop the wdth axis for all
instance_and_subset "${ORIGINAL_FONTS}/OpenSans-Italic[wdth,wght].ttf" "wdth=drop wght=400:700" "OpenSans-Italic[wght]"
# we restrict the wght range
instance_and_subset "${ORIGINAL_FONTS}/OpenSans-Italic[wdth,wght].ttf" "wdth=drop wght=400" "OpenSans-RegularItalic"
instance_and_subset "${ORIGINAL_FONTS}/OpenSans-Italic[wdth,wght].ttf" "wdth=drop wght=700" "OpenSans-BoldItalic"
echo "\nQuicksand"
instance_and_subset "${ORIGINAL_FONTS}/Quicksand[wght].ttf" "wght=400:700"
# we restrict the wght range
instance_and_subset "${ORIGINAL_FONTS}/Quicksand[wght].ttf" "wght=400" "Quicksand-Regular"
instance_and_subset "${ORIGINAL_FONTS}/Quicksand[wght].ttf" "wght=600" "Quicksand-SemiBold"
instance_and_subset "${ORIGINAL_FONTS}/Quicksand[wght].ttf" "wght=700" "Quicksand-Bold"
echo "\nSubsetting files complete"
# remove temp folder
rm -r $TEMP_FOLDER

16
scripts/serve-dist.js Normal file
View File

@ -0,0 +1,16 @@
const path = require('path')
const express = require('express')
const app = express()
const p = path.join(__dirname, '..', 'dist-dev')
const port = 4173
app.use(express.static(p))
// Handle urls set by the frontend
app.get('*', (request, response, next) => {
response.sendFile(`${p}/index.html`)
})
app.listen(port, '127.0.0.1', () => {
console.log(`Serving files from ${p}`)
console.log(`Server started on port ${port}`)
})

View File

@ -1,58 +1,46 @@
<template> <template>
<Ready> <ready>
<template v-if="authUser"> <template v-if="authUser">
<TheNavigation/> <TheNavigation/>
<ContentAuth /> <content-auth/>
</template> </template>
<ContentLinkShare v-else-if="authLinkShare" /> <content-link-share v-else-if="authLinkShare"/>
<NoAuthWrapper v-else> <no-auth-wrapper v-else>
<router-view/> <router-view/>
</NoAuthWrapper> </no-auth-wrapper>
<KeyboardShortcuts v-if="keyboardShortcutsActive" />
<Teleport to="body">
<AddToHomeScreen />
<UpdateNotification />
<Notification/> <Notification/>
<DemoMode />
</Teleport> <keyboard-shortcuts v-if="keyboardShortcutsActive"/>
</Ready> </ready>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import {computed, watch} from 'vue' import {computed, watch, type Ref} from 'vue'
import {useRoute, useRouter} from 'vue-router' import {useRouter} from 'vue-router'
import {useRouteQuery} from '@vueuse/router'
import {useI18n} from 'vue-i18n' import {useI18n} from 'vue-i18n'
import isTouchDevice from 'is-touch-device' import isTouchDevice from 'is-touch-device'
import {success} from '@/message'
import Notification from '@/components/misc/notification.vue' 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 TheNavigation from '@/components/home/TheNavigation.vue'
import ContentAuth from '@/components/home/contentAuth.vue' import ContentAuth from './components/home/contentAuth.vue'
import ContentLinkShare from '@/components/home/contentLinkShare.vue' import ContentLinkShare from './components/home/contentLinkShare.vue'
import NoAuthWrapper from '@/components/misc/no-auth-wrapper.vue' import NoAuthWrapper from '@/components/misc/no-auth-wrapper.vue'
import Ready from '@/components/misc/ready.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 {useBaseStore} from '@/stores/base'
import {useColorScheme} from '@/composables/useColorScheme' import {useColorScheme} from '@/composables/useColorScheme'
import {useBodyClass} from '@/composables/useBodyClass' import {useBodyClass} from '@/composables/useBodyClass'
import AddToHomeScreen from '@/components/home/AddToHomeScreen.vue' import {useAuthStore} from './stores/auth'
import DemoMode from '@/components/home/DemoMode.vue'
const importAccountDeleteService = () => import('@/services/accountDelete')
const importMessage = () => import('@/message')
const baseStore = useBaseStore() const baseStore = useBaseStore()
const authStore = useAuthStore() const authStore = useAuthStore()
const router = useRouter() const router = useRouter()
const route = useRoute()
useBodyClass('is-touch', isTouchDevice()) useBodyClass('is-touch', isTouchDevice())
const keyboardShortcutsActive = computed(() => baseStore.keyboardShortcutsActive) const keyboardShortcutsActive = computed(() => baseStore.keyboardShortcutsActive)
@ -63,25 +51,22 @@ const authLinkShare = computed(() => authStore.authLinkShare)
const {t} = useI18n({useScope: 'global'}) const {t} = useI18n({useScope: 'global'})
// setup account deletion verification // setup account deletion verification
const accountDeletionConfirm = computed(() => route.query?.accountDeletionConfirm as (string | undefined)) const accountDeletionConfirm = useRouteQuery('accountDeletionConfirm') as Ref<null | string>
watch(accountDeletionConfirm, async (accountDeletionConfirm) => { watch(accountDeletionConfirm, async (accountDeletionConfirm) => {
if (accountDeletionConfirm === undefined) { if (accountDeletionConfirm === null) {
return return
} }
const messageP = importMessage()
const AccountDeleteService = (await importAccountDeleteService()).default
const accountDeletionService = new AccountDeleteService() const accountDeletionService = new AccountDeleteService()
await accountDeletionService.confirm(accountDeletionConfirm) await accountDeletionService.confirm(accountDeletionConfirm)
const {success} = await messageP
success({message: t('user.deletion.confirmSuccess')}) success({message: t('user.deletion.confirmSuccess')})
authStore.refreshUserInfo() authStore.refreshUserInfo()
}, { immediate: true }) }, { immediate: true })
// setup password reset redirect // setup password reset redirect
const userPasswordReset = computed(() => route.query?.userPasswordReset as (string | undefined)) const userPasswordReset = useRouteQuery('userPasswordReset') as Ref<null | string>
watch(userPasswordReset, (userPasswordReset) => { watch(userPasswordReset, (userPasswordReset) => {
if (userPasswordReset === undefined) { if (userPasswordReset === null) {
return return
} }
@ -90,9 +75,9 @@ watch(userPasswordReset, (userPasswordReset) => {
}, { immediate: true }) }, { immediate: true })
// setup email verification redirect // setup email verification redirect
const userEmailConfirm = computed(() => route.query?.userEmailConfirm as (string | undefined)) const userEmailConfirm = useRouteQuery('userEmailConfirm') as Ref<null | string>
watch(userEmailConfirm, (userEmailConfirm) => { watch(userEmailConfirm, (userEmailConfirm) => {
if (userEmailConfirm === undefined) { if (userEmailConfirm === null) {
return return
} }
@ -100,7 +85,7 @@ watch(userEmailConfirm, (userEmailConfirm) => {
router.push({name: 'user.login'}) router.push({name: 'user.login'})
}, { immediate: true }) }, { immediate: true })
setLanguage(authStore.settings.language) setLanguage()
useColorScheme() useColorScheme()
</script> </script>

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More