Compare commits

..

6 Commits

Author SHA1 Message Date
kolaente 118efb6234
feat: use Vikunja's logo for splash screen
continuous-integration/drone/pr Build is passing Details
2022-01-16 17:13:55 +01:00
kolaente 5a503a31bb
feat: use Vikunja's logo as app icon
continuous-integration/drone/pr Build is passing Details
2022-01-16 16:50:11 +01:00
kolaente f52dd5e772
chore: move to ts configuration for capacitor
continuous-integration/drone/pr Build is passing Details
2022-01-16 16:38:37 +01:00
kolaente 829c95dd2e
chore: update deps
continuous-integration/drone/pr Build is passing Details
2022-01-16 16:29:21 +01:00
kolaente ab4a4a4a2a
chore: add capacitor sync task to package.json 2022-01-16 16:29:00 +01:00
kolaente 6fc47dd529
Add capacitor 2022-01-16 16:28:53 +01:00
745 changed files with 43808 additions and 74710 deletions

View File

@ -1,6 +1,5 @@
---
kind: pipeline
type: docker
name: build
trigger:
@ -15,7 +14,6 @@ trigger:
services:
- name: api
image: vikunja/api:unstable
pull: always
environment:
VIKUNJA_SERVICE_TESTINGTOKEN: averyLongSecretToSe33dtheDB
VIKUNJA_LOG_LEVEL: DEBUG
@ -24,101 +22,36 @@ steps:
# Disabled until we figure out why it is so slow
# - name: restore-cache
# image: meltwater/drone-cache:dev
# pull: always
# pull: true
# environment:
# AWS_ACCESS_KEY_ID:
# from_secret: cache_aws_access_key_id
# AWS_SECRET_ACCESS_KEY:
# from_secret: cache_aws_secret_access_key
# settings:
# debug: true
# restore: true
# bucket: kolaente.dev-drone-dependency-cache
# endpoint: https://s3.fr-par.scw.cloud
# region: fr-par
# path_style: true
# cache_key: '{{ .Repo.Name }}_{{ checksum "pnpm-lock.yaml" }}_{{ arch }}_{{ os }}'
# cache_key: '{{ .Repo.Name }}_{{ checksum "yarn.lock" }}_{{ arch }}_{{ os }}'
# mount:
# - .cache
# - '.cache'
- name: dependencies
image: node:20.11.0-alpine
pull: always
image: node:16
pull: true
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
CYPRESS_CACHE_FOLDER: .cache/cypress
PUPPETEER_SKIP_DOWNLOAD: true
YARN_CACHE_FOLDER: .cache/yarn/
CYPRESS_CACHE_FOLDER: .cache/cypress/
commands:
- corepack enable && pnpm config set store-dir .cache/pnpm
- pnpm install --fetch-timeout 100000
- yarn --frozen-lockfile --network-timeout 100000
# depends_on:
# - restore-cache
- name: lint
image: node:20.11.0-alpine
pull: always
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
commands:
- corepack enable && pnpm config set store-dir .cache/pnpm
- pnpm run lint
depends_on:
- dependencies
- name: build-prod
image: node:20.11.0-alpine
pull: always
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
commands:
- corepack enable && pnpm config set store-dir .cache/pnpm
- pnpm run build
depends_on:
- dependencies
- name: test-unit
image: node:20.11.0-alpine
pull: always
commands:
- corepack enable && pnpm config set store-dir .cache/pnpm
- pnpm run test:unit
depends_on:
- dependencies
- name: typecheck
failure: ignore
image: node:20.11.0-alpine
pull: always
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
commands:
- corepack enable && pnpm config set store-dir .cache/pnpm
- pnpm run typecheck
depends_on:
- dependencies
- name: test-frontend
image: cypress/browsers:node18.12.0-chrome107
pull: always
environment:
CYPRESS_API_URL: http://api:3456/api/v1
CYPRESS_TEST_SECRET: averyLongSecretToSe33dtheDB
PNPM_CACHE_FOLDER: .cache/pnpm
CYPRESS_CACHE_FOLDER: .cache/cypress
CYPRESS_DEFAULT_COMMAND_TIMEOUT: 60000
CYPRESS_RECORD_KEY:
from_secret: cypress_project_key
commands:
- sed -i 's/localhost/api/g' dist/index.html
- corepack enable && pnpm config set store-dir .cache/pnpm
- pnpm cypress install
- pnpm run test:e2e-record
depends_on:
- build-prod
# - name: rebuild-cache
# image: meltwater/drone-cache:dev
# pull: always
# pull: true
# environment:
# AWS_ACCESS_KEY_ID:
# from_secret: cache_aws_access_key_id
@ -130,16 +63,92 @@ steps:
# endpoint: https://s3.fr-par.scw.cloud
# region: fr-par
# path_style: true
# cache_key: '{{ .Repo.Name }}_{{ checksum "pnpm-lock.yaml" }}_{{ arch }}_{{ os }}'
# cache_key: '{{ .Repo.Name }}_{{ checksum "yarn.lock" }}_{{ arch }}_{{ os }}'
# mount:
# - .cache
# - '.cache'
# depends_on:
# - dependencies
- name: lint
image: node:16
pull: true
environment:
YARN_CACHE_FOLDER: .cache/yarn/
CYPRESS_CACHE_FOLDER: .cache/cypress/
commands:
- yarn run lint
depends_on:
- dependencies
- name: build-prod
image: node:16
pull: true
environment:
YARN_CACHE_FOLDER: .cache/yarn/
commands:
- yarn build
depends_on:
- dependencies
- name: test-unit
image: node:16
pull: true
commands:
- yarn test:unit
depends_on:
- dependencies
- name: typecheck
failure: ignore
image: node:16
pull: true
commands:
- yarn typecheck
depends_on:
- dependencies
- name: test-frontend
image: cypress/browsers:node16.5.0-chrome94-ff93
pull: true
environment:
CYPRESS_API_URL: http://api:3456/api/v1
CYPRESS_TEST_SECRET: averyLongSecretToSe33dtheDB
YARN_CACHE_FOLDER: .cache/yarn/
CYPRESS_CACHE_FOLDER: .cache/cypress/
CYPRESS_DEFAULT_COMMAND_TIMEOUT: 60000
commands:
- sed -i 's/localhost/api/g' dist/index.html
- yarn serve:dist & npx wait-on http://localhost:5000
- yarn test:frontend --browser chrome
depends_on:
- dependencies
- build-prod
- name: upload-test-results
image: plugins/s3
pull: true
settings:
bucket: drone-test-results
access_key:
from_secret: test_results_aws_access_key_id
secret_key:
from_secret: test_results_aws_secret_access_key
endpoint: https://s3.fr-par.scw.cloud
region: fr-par
path_style: true
source: cypress/screenshots/**/**/*
strip_prefix: cypress/screenshots/
target: /${DRONE_REPO}/${DRONE_PULL_REQUEST}_${DRONE_BRANCH}/${DRONE_BUILD_NUMBER}/
depends_on:
- test-frontend
when:
status:
- failure
- success
- name: deploy-preview
image: williamjackson/netlify-cli
pull: always
user: root # The rest runs as root and thus the permissions wouldn't work
image: node:16
pull: true
environment:
NETLIFY_AUTH_TOKEN:
from_secret: netlify_auth_token
@ -148,14 +157,8 @@ steps:
GITEA_TOKEN:
from_secret: gitea_token
commands:
- cp -r dist dist-preview
# Override the default api url used for preview
- sed -i 's|http://localhost:3456|https://try.vikunja.io|g' dist-preview/index.html
- apk add --no-cache perl-utils
# create via:
# `shasum -a 384 ./scripts/deploy-preview-netlify.mjs > ./scripts/deploy-preview-netlify.mjs.sha384`
- shasum -a 384 -c ./scripts/deploy-preview-netlify.mjs.sha384
- node ./scripts/deploy-preview-netlify.mjs
- shasum -a 384 -c ./scripts/deploy-preview-netlify.js.sha384
- node ./scripts/deploy-preview-netlify.js
depends_on:
- build-prod
when:
@ -165,7 +168,6 @@ steps:
---
kind: pipeline
type: docker
name: release-latest
depends_on:
@ -185,7 +187,7 @@ steps:
# - name: restore-cache
# image: meltwater/drone-cache:dev
# pull: always
# pull: true
# environment:
# AWS_ACCESS_KEY_ID:
# from_secret: cache_aws_access_key_id
@ -197,36 +199,29 @@ steps:
# endpoint: https://s3.fr-par.scw.cloud
# region: fr-par
# path_style: true
# cache_key: '{{ .Repo.Name }}_{{ checksum "pnpm-lock.yaml" }}_{{ arch }}_{{ os }}'
# cache_key: '{{ .Repo.Name }}_{{ checksum "yarn.lock" }}_{{ arch }}_{{ os }}'
# mount:
# - .cache
# - '.cache'
- name: build
image: node:20.11.0-alpine
pull: always
image: node:16
pull: true
group: build-static
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
SENTRY_AUTH_TOKEN:
from_secret: sentry_auth_token
SENTRY_ORG: vikunja
SENTRY_PROJECT: frontend-oss
PUPPETEER_SKIP_DOWNLOAD: true
YARN_CACHE_FOLDER: .cache/yarn/
commands:
- apk add git
- corepack enable && pnpm config set store-dir .cache/pnpm
- pnpm install --fetch-timeout 100000 --frozen-lockfile
- pnpm run lint
- yarn --frozen-lockfile --network-timeout 100000
- yarn run lint
- "echo '{\"VERSION\": \"'$(git describe --tags --always --abbrev=10 | sed 's/-/+/' | sed 's/^v//' | sed 's/-g/-/')'\"}' > src/version.json"
- pnpm run build
- yarn run build
- sed -i 's/http\:\\/\\/localhost\\:3456\\/api\\/v1/\\/api\\/v1/g' dist/index.html # Override the default api url used for developing
# depends_on:
# - restore-cache
- name: static
image: kolaente/zip
pull: always
pull: true
commands:
- cp src/version.json dist
- cd dist
- zip -r ../vikunja-frontend-unstable.zip *
- cd ..
@ -234,7 +229,7 @@ steps:
- name: release
image: plugins/s3
pull: always
pull: true
settings:
bucket: vikunja-releases
access_key:
@ -250,7 +245,6 @@ steps:
---
kind: pipeline
type: docker
name: release-version
depends_on:
@ -268,7 +262,7 @@ steps:
# - name: restore-cache
# image: meltwater/drone-cache:dev
# pull: always
# pull: true
# environment:
# AWS_ACCESS_KEY_ID:
# from_secret: cache_aws_access_key_id
@ -280,35 +274,29 @@ steps:
# endpoint: https://s3.fr-par.scw.cloud
# region: fr-par
# path_style: true
# cache_key: '{{ .Repo.Name }}_{{ checksum "pnpm-lock.yaml" }}_{{ arch }}_{{ os }}'
# cache_key: '{{ .Repo.Name }}_{{ checksum "yarn.lock" }}_{{ arch }}_{{ os }}'
# mount:
# - .cache
# - '.cache'
- name: build
image: node:20.11.0-alpine
pull: always
image: node:16
pull: true
group: build-static
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
SENTRY_AUTH_TOKEN:
from_secret: sentry_auth_token
SENTRY_ORG: vikunja
SENTRY_PROJECT: frontend-oss
YARN_CACHE_FOLDER: .cache/yarn/
commands:
- apk add git
- corepack enable && pnpm config set store-dir .cache/pnpm
- pnpm install --fetch-timeout 100000 --frozen-lockfile
- pnpm run lint
- yarn --frozen-lockfile --network-timeout 100000
- yarn run lint
- "echo '{\"VERSION\": \"'$(git describe --tags --always --abbrev=10 | sed 's/-/+/' | sed 's/^v//' | sed 's/-g/-/')'\"}' > src/version.json"
- pnpm run build
- yarn run build
- sed -i 's/http\:\\/\\/localhost\\:3456\\/api\\/v1/\\/api\\/v1/g' dist/index.html # Override the default api url used for developing
# depends_on:
# - restore-cache
- name: static
image: kolaente/zip
pull: always
pull: true
commands:
- cp src/version.json dist
- cd dist
- zip -r ../vikunja-frontend-${DRONE_TAG##v}.zip *
- cd ..
@ -316,7 +304,7 @@ steps:
- name: release
image: plugins/s3
pull: always
pull: true
settings:
bucket: vikunja-releases
access_key:
@ -332,7 +320,6 @@ steps:
---
kind: pipeline
type: docker
name: trigger-desktop-update
trigger:
@ -357,10 +344,15 @@ steps:
---
kind: pipeline
type: docker
name: docker-release
name: docker-arm-release
depends_on:
- build
- release-latest
- release-version
platform:
os: linux
arch: arm64
trigger:
ref:
@ -371,65 +363,201 @@ trigger:
- cron
steps:
- name: fetch-tags
image: docker:git
commands:
- git fetch --tags
- name: docker-unstable
image: thegeeklab/drone-docker-buildx
privileged: true
pull: always
image: plugins/docker:linux-arm
pull: true
settings:
username:
from_secret: docker_username
password:
from_secret: docker_password
repo: vikunja/frontend
tags: unstable
tags: unstable-linux-arm
build_args:
- USE_RELEASE=false
platforms:
- linux/386
- linux/amd64
- linux/arm/v6
- linux/arm/v7
- linux/arm64/v8
depends_on: [ fetch-tags ]
- USE_RELEASE=true
- RELEASE_VERSION=unstable
when:
ref:
- refs/heads/main
depends_on:
- clone
- 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:
ref:
- refs/heads/main
- name: generate-tags
image: thegeeklab/docker-autotag
environment:
DOCKER_AUTOTAG_VERSION: ${DRONE_TAG}
DOCKER_AUTOTAG_EXTRA_TAGS: latest
DOCKER_AUTOTAG_OUTPUT_FILE: .tags
depends_on: [ fetch-tags ]
when:
ref:
- "refs/tags/**"
- name: docker-release
image: thegeeklab/drone-docker-buildx
privileged: true
pull: always
- name: docker-version
image: plugins/docker:linux-amd64
pull: true
settings:
username:
from_secret: docker_username
password:
from_secret: docker_password
repo: vikunja/frontend
auto_tag: true
auto_tag_suffix: linux-amd64
build_args:
- USE_RELEASE=false
platforms:
- linux/386
- linux/amd64
- linux/arm/v6
- linux/arm/v7
- linux/arm64/v8
depends_on: [ generate-tags ]
- USE_RELEASE=true
- RELEASE_VERSION=${DRONE_TAG##v}
when:
ref:
- "refs/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:
ref:
- "refs/tags/**"
@ -452,7 +580,9 @@ depends_on:
- release-version
- release-latest
- trigger-desktop-update
- docker-release
- docker-arm-release
- docker-amd64-release
- docker-manifest
steps:
- name: notify
@ -473,27 +603,29 @@ kind: pipeline
type: docker
name: update-translations
depends_on:
- build
trigger:
branch:
include:
- main
- main
event:
include:
- cron
- cron
cron:
- update_translations
steps:
- name: download
pull: always
image: ghcr.io/kolaente/kolaente/drone-crowdin-v2:latest
image: jonasfranz/crowdin
settings:
crowdin_key:
download: true
export_dir: src/i18n/lang/
ignore_branch: true
project_identifier: vikunja
environment:
CROWDIN_KEY:
from_secret: crowdin_key
project_id: 462614
target: download
download_to: src/i18n/lang/
download_export_approved_only: true
- name: move-files
pull: always
@ -513,25 +645,26 @@ steps:
author_name: Frederick [Bot]
branch: main
commit: true
commit_message: "chore(i18n): update translations via Crowdin"
commit_message: "[skip ci] Updated translations via Crowdin"
remote: "ssh://git@kolaente.dev:9022/vikunja/frontend.git"
ssh_key:
from_secret: git_push_ssh_key
from_secret: translation_git_push_ssh_key
- name: upload
pull: always
image: ghcr.io/kolaente/kolaente/drone-crowdin-v2:latest
image: jonasfranz/crowdin
depends_on:
- clone
settings:
crowdin_key:
from_secret: crowdin_key
project_id: 462614
target: upload
upload_files:
src/i18n/lang/en.json: en.json
files:
en.json: src/i18n/lang/en.json
ignore_branch: true
project_identifier: vikunja
environment:
CROWDIN_KEY:
from_secret: crowdin_key
---
kind: signature
hmac: a044c7c4db3c2a11299d4d118397e9d25be36db241723a1bbd0a2f9cc90ffdac
hmac: 188ee90100c5fc5922a445e531e7a47453121edddb2a64a182eb23ed2bf602de
...

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

1
.envrc
View File

@ -1 +0,0 @@
use flake

View File

@ -1,60 +0,0 @@
/* eslint-env node */
require('@rushstack/eslint-patch/modern-module-resolution')
module.exports = {
'root': true,
'env': {
'browser': true,
'es2022': true,
'node': true,
},
'extends': [
'eslint:recommended',
'plugin:vue/vue3-recommended',
'@vue/eslint-config-typescript/recommended',
],
'rules': {
'quotes': ['error', 'single'],
'comma-dangle': ['error', 'always-multiline'],
'semi': ['error', 'never'],
'vue/v-on-event-hyphenation': ['warn', 'never', { 'autofix': true }],
'vue/multi-word-component-names': 'off',
// uncategorized rules:
'vue/component-api-style': ['error', ['script-setup']],
'vue/component-name-in-template-casing': ['warn', 'PascalCase'],
'vue/custom-event-name-casing': ['error', 'camelCase'],
'vue/define-macros-order': 'error',
'vue/match-component-file-name': ['error', {
'extensions': ['.js', '.jsx', '.ts', '.tsx', '.vue'],
'shouldMatchCase': true,
}],
'vue/no-boolean-default': ['warn', 'default-false'],
'vue/match-component-import-name': 'error',
'vue/prefer-separate-static-class': 'warn',
'vue/padding-line-between-blocks': 'error',
'vue/next-tick-style': ['error', 'promise'],
'vue/block-lang': [
'error',
{ 'script': { 'lang': 'ts' } },
],
'vue/no-required-prop-with-default': ['error', { 'autofix': true }],
'vue/no-duplicate-attr-inheritance': 'error',
'vue/no-empty-component-block': 'error',
'vue/html-indent': ['error', 'tab'],
// vue3
'vue/no-ref-object-destructure': 'error',
},
'parser': 'vue-eslint-parser',
'parserOptions': {
'parser': '@typescript-eslint/parser',
'ecmaVersion': 'latest',
},
'ignorePatterns': [
'*.test.*',
'cypress/*',
],
}

View File

@ -1,44 +0,0 @@
<!--
Please fill out this issue template to report a bug.
If you want to propose a new feature, please open a discussion thread in the forum: https://community.vikunja.io
-->
**Version information:**
Frontend Version:
API Version:
Browser and OS Version:
**Steps to reproduce:**
<!--
Add clear steps to reproduce the bug. Provide screenshots where applicable.
-->
1.
2.
...
**Expected behavior:**
<!--
Describe what happened.
-->
**Actual behavior:**
<!--
Describe what happened instead.
-->
**Checklist:**
* [ ] I have provided all required information
* [ ] I am using the latest release or the latest unstable build
* [ ] I was able to reproduce the bug on [try](https://try.vikunja.io)

3
.github/FUNDING.yml vendored
View File

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

View File

@ -1,59 +0,0 @@
name: Bug Report
description: Found something you weren't expecting? Report it here!
labels:
- kind/bug
body:
- type: markdown
attributes:
value: |
NOTE: If your issue is a security concern, please send an email to security@vikunja.io instead of opening a public issue.
- type: markdown
attributes:
value: |
Please fill out this issue template to report a bug.
1. If you want to propose a new feature, please open a discussion thread in the forum: https://community.vikunja.io
2. Please ask questions or configuration/deploy problems on our [Matrix Room](https://matrix.to/#/#vikunja:matrix.org) or forum (https://community.vikunja.io).
3. Make sure you are using the latest release and
take a moment to check that your issue hasn't been reported before.
4. Please give all relevant information below for bug reports, because
incomplete details will be handled as an invalid report and closed.
- type: textarea
id: description
attributes:
label: Description
description: |
Please provide a description of your issue here, with a URL if you were able to reproduce the issue (see below).
- type: input
id: frontend-version
attributes:
label: Vikunja Frontend Version
description: Vikunja frontend version (or commit reference) of your instance
validations:
required: true
- type: input
id: api-version
attributes:
label: Vikunja API Version
description: Vikunja API version (or commit reference) of your instance
validations:
required: true
- type: input
id: browser-version
attributes:
label: Browser and version
description: If your issue is related to a frontend problem, please provide the browser and version you used to reproduce it.
- type: dropdown
id: can-reproduce
attributes:
label: Can you reproduce the bug on the Vikunja demo site?
options:
- "Yes"
- "No"
validations:
required: true
- type: textarea
id: screenshots
attributes:
label: Screenshots
description: If this issue involves the Web Interface, please provide one or more screenshots

View File

@ -1,17 +0,0 @@
blank_issues_enabled: false
contact_links:
- name: API issues
url: https://code.vikunja.io/api/issues
about: This is the frontend repo. Please open api-related bug reports and discussions in the api 0repo. Not sure if your issue is frontend or api? Ask in Matrix or the forum first.
- name: Forum
url: https://community.vikunja.io/
about: Feature Requests, Questions, configuration or deployment problems should be discussed in the forum.
- name: Security-related issues
url: https://vikunja.io/contact/#security
about: For security concerns, please send a mail to security@vikunja.io instead of opening a public issue.
- name: Chat on Matrix
url: https://matrix.to/#/#vikunja:matrix.org
about: Please ask any quick questions here.
- name: Translations
url: https://crowdin.com/project/vikunja
about: Any problems or requests for new languages about translations should be handled in crowdin.

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

32
.gitignore vendored
View File

@ -1,31 +1,21 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
stats.html
node_modules
.DS_Store
node_modules
/dist*
coverage
*.zip
.direnv/
# Test files
cypress/screenshots
cypress/videos
# local env files
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
stats.html
# Editor directories and files
.vscode
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
@ -33,9 +23,9 @@ cypress/videos
*.sw*
!rollup.sw.js
# Test files
cypress/screenshots
cypress/videos
# Local Netlify folder
.netlify
# histoire
.histoire

14
.npmrc
View File

@ -1,14 +0,0 @@
fetch-timeout=100000
# pnpm settings
# The following settings prepare for the new default value of pnpm 8
# they can be removed directly after having moved to pnpm 8
auto-install-peers=true
dedupe-peer-dependents=true
resolve-peers-from-workspace-root=true
save-workspace-protocol=rolling
resolution-mode=lowest-direct
publishConfig.linkDirectory=true
# remove some time after having moved to pnpm 8
use-lockfile-v6=true

2
.nvmrc
View File

@ -1 +1 @@
20.11.0
v16

View File

@ -3,12 +3,10 @@
"codezombiech.gitignore",
"dbaeumer.vscode-eslint",
"editorconfig.editorconfig",
"vue.volar",
"vue.vscode-typescript-vue-plugin",
"johnsoncodehk.volar",
"lokalise.i18n-ally",
"mgmcdermott.vscode-language-babel",
"mikestead.dotenv",
"Syler.sass-indented",
"zixuanchen.vitest-explorer"
"Syler.sass-indented"
]
}

View File

@ -1,5 +1,5 @@
{
"eslint.packageManager": "pnpm",
"eslint.packageManager": "yarn",
"editor.formatOnSave": false,
"editor.codeActionsOnSave": {
"source.fixAll": true
@ -18,10 +18,6 @@
"javascriptreact",
"vue"
],
"volar.completion.preferredTagNameCase": "pascal",
// disable vetur in case it is installed
"vetur.validation.template": false,
// i18n ally
@ -30,5 +26,5 @@
],
"i18n-ally.sortKeys": true,
"i18n-ally.keepFulfilled": true,
"i18n-ally.keystyle": "nested"
"i18n-ally.keystyle": "nested",
}

File diff suppressed because it is too large Load Diff

View File

@ -1,73 +1,39 @@
# syntax=docker/dockerfile:1
# ┬─┐┬ ┐o┬ ┬─┐
# │─││ │││ │ │
# ┘─┘┘─┘┘┘─┘┘─┘
FROM --platform=$BUILDPLATFORM node:20.11.0-alpine AS builder
# Stage 1: Build application
FROM node:16 AS compile-image
WORKDIR /build
ARG USE_RELEASE=false
ARG RELEASE_VERSION=unstable
ENV PNPM_CACHE_FOLDER .cache/pnpm/
ENV PUPPETEER_SKIP_DOWNLOAD true
ARG RELEASE_VERSION=main
COPY package.json ./
COPY pnpm-lock.yaml ./
COPY patches ./patches/
ENV YARN_CACHE_FOLDER .cache/yarn/
COPY . ./
RUN if [ "$USE_RELEASE" != true ]; then \
# https://pnpm.io/installation#using-corepack
corepack enable && \
pnpm install; \
fi
RUN \
if [ $USE_RELEASE = true ]; then \
rm -rf dist/ && \
wget https://dl.vikunja.io/frontend/vikunja-frontend-$RELEASE_VERSION.zip -O frontend-release.zip && \
unzip frontend-release.zip -d dist/ && \
exit 0; \
fi && \
# Build the frontend
yarn install --frozen-lockfile --network-timeout 100000 && \
echo '{"VERSION": "'$(git describe --tags --always --abbrev=10 | sed 's/-/+/' | sed 's/^v//' | sed 's/-g/-/')'"}' > src/version.json && \
yarn run build
COPY . ./
# Stage 2: copy
FROM nginx
RUN if [ "$USE_RELEASE" != true ]; then \
apk add --no-cache --virtual .build-deps git jq && \
git describe --tags --always --abbrev=10 | sed 's/-/+/; s/^v//; s/-g/-/' | \
xargs -0 -I{} jq -Mcnr --arg version {} '{VERSION:$version}' | \
tee src/version.json && \
apk del .build-deps; \
fi
COPY nginx.conf /etc/nginx/nginx.conf
COPY run.sh /run.sh
RUN if [ "$USE_RELEASE" = true ]; then \
wget "https://dl.vikunja.io/frontend/vikunja-frontend-${RELEASE_VERSION}.zip" -O frontend-release.zip && \
unzip frontend-release.zip -d dist/; \
else \
# we don't use corepack prepare here by intend since
# we have renovate to keep our dependencies up to date
# Build the frontend
pnpm run build; \
fi
# copy compiled files from stage 1
COPY --from=compile-image /build/dist /usr/share/nginx/html
# ┌┐┐┌─┐o┌┐┐┐ │
# ││││ ┬││││┌┼┘
# ┘└┘┘─┘┘┘└┘┘ └
# Unprivileged user
ENV PUID 1000
ENV PGID 1000
FROM nginx:stable-alpine AS runner
WORKDIR /usr/share/nginx/html
LABEL maintainer="maintainers@vikunja.io"
ENV VIKUNJA_HTTP_PORT 80
ENV VIKUNJA_HTTP2_PORT 81
ENV VIKUNJA_LOG_FORMAT main
ENV VIKUNJA_API_URL /api/v1
ENV VIKUNJA_SENTRY_ENABLED false
ENV VIKUNJA_SENTRY_DSN https://85694a2d757547cbbc90cd4b55c5a18d@o1047380.ingest.sentry.io/6024480
ENV VIKUNJA_PROJECT_INFINITE_NESTING_ENABLED false
ENV VIKUNJA_ALLOW_ICON_CHANGES true
ENV VIKUNJA_CUSTOM_LOGO_URL "''"
COPY docker/injector.sh /docker-entrypoint.d/50-injector.sh
COPY docker/ipv6-disable.sh /docker-entrypoint.d/60-ipv6-disable.sh
COPY docker/nginx.conf /etc/nginx/nginx.conf
COPY docker/templates/. /etc/nginx/templates/
# copy compiled files from stage 1
COPY --from=builder /build/dist ./
# manage permissions
RUN chmod 0755 /docker-entrypoint.d/*.sh /etc/nginx/templates && \
chmod -R 0644 /etc/nginx/nginx.conf && \
chown -R nginx:nginx ./ /etc/nginx/conf.d /etc/nginx/templates && \
rm -f /docker-entrypoint.d/10-listen-on-ipv6-by-default.sh
CMD "/run.sh"

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
> The todo app to organize your life.
[![Build Status](https://drone.kolaente.de/api/badges/vikunja/frontend/status.svg)](https://drone.kolaente.de/vikunja/frontend)
[![License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](LICENSE)
[![Download](https://img.shields.io/badge/download-v0.22.1-brightgreen.svg)](https://dl.vikunja.io)
[![Download](https://img.shields.io/badge/download-v0.18.2-brightgreen.svg)](https://dl.vikunja.io)
[![Translation](https://badges.crowdin.net/vikunja/localized.svg)](https://crowdin.com/project/vikunja)
This is the web frontend for Vikunja, written in Vue.js.
@ -22,35 +18,27 @@ If you find any security-related issues you don't want to disclose publicly, ple
## Docker
There is a [docker image available](https://hub.docker.com/r/vikunja/api) with support for http/2 and aggressive caching enabled.
In order to build it from sources run the command below. (Docker >= v19.03)
```shell
export DOCKER_BUILDKIT=1
docker build -t vikunja/frontend .
```
Refer to [multi-platform documentation](https://docs.docker.com/build/building/multi-platform/) in order to build for different platforms.
## Project setup
```shell
pnpm install
yarn install
```
### Compiles and hot-reloads for development
```shell
pnpm run serve
yarn run serve
```
### Compiles and minifies for production
```shell
pnpm run build
yarn run build
```
### Lints and fixes files
```shell
pnpm run lint
```
yarn run lint
```

91
android/.gitignore vendored Normal file
View File

@ -0,0 +1,91 @@
# NPM renames .gitignore to .npmignore
# In order to prevent that, we remove the initial "."
# And the CLI then renames it
# Using Android gitignore template: https://github.com/github/gitignore/blob/master/Android.gitignore
# Built application files
*.apk
*.ap_
*.aab
# Files for the ART/Dalvik VM
*.dex
# Java class files
*.class
# Generated files
bin/
gen/
out/
release/
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties
# Proguard folder generated by Eclipse
proguard/
# Log Files
*.log
# Android Studio Navigation editor temp files
.navigation/
# Android Studio captures folder
captures/
# IntelliJ
*.iml
.idea/workspace.xml
.idea/tasks.xml
.idea/gradle.xml
.idea/assetWizardSettings.xml
.idea/dictionaries
.idea/libraries
# Android Studio 3 in .gitignore file.
.idea/caches
.idea/modules.xml
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
.idea/navEditor.xml
# Keystore files
# Uncomment the following lines if you do not want to check your keystore files in.
#*.jks
#*.keystore
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
# Freeline
freeline.py
freeline/
freeline_project_description.json
# fastlane
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots
fastlane/test_output
fastlane/readme.md
# Version control
vcs.xml
# lint
lint/intermediates/
lint/generated/
lint/outputs/
lint/tmp/
# lint/reports/
# Cordova plugins for Capacitor
capacitor-cordova-android-plugins
# Copied web assets
app/src/main/assets/public

2
android/app/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
/build/*
!/build/.npmkeep

46
android/app/build.gradle Normal file
View File

@ -0,0 +1,46 @@
apply plugin: 'com.android.application'
android {
compileSdkVersion rootProject.ext.compileSdkVersion
defaultConfig {
applicationId "io.vikunja.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
}
repositories {
flatDir{
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
}
}
dependencies {
implementation fileTree(include: ['*.jar'], dir: 'libs')
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
implementation project(':capacitor-android')
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
implementation project(':capacitor-cordova-android-plugins')
}
apply from: 'capacitor.build.gradle'
try {
def servicesJSON = file('google-services.json')
if (servicesJSON.text) {
apply plugin: 'com.google.gms.google-services'
}
} catch(Exception e) {
logger.warn("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
}

View File

@ -0,0 +1,19 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
}
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
}
if (hasProperty('postBuildExtras')) {
postBuildExtras()
}

21
android/app/proguard-rules.pro vendored Normal file
View File

@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@ -0,0 +1,63 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="io.vikunja.app">
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
android:name="io.vikunja.app.MainActivity"
android:label="@string/title_activity_main"
android:theme="@style/AppTheme.NoActionBarLaunch"
android:launchMode="singleTask">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="@string/custom_url_scheme" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"></meta-data>
</provider>
</application>
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- Camera, Photos, input file -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<!-- Geolocation API -->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-feature android:name="android.hardware.location.gps" />
<!-- Network API -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Navigator.getUserMedia -->
<!-- Video -->
<uses-permission android:name="android.permission.CAMERA" />
<!-- Audio -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
</manifest>

View File

@ -0,0 +1,6 @@
{
"appId": "io.vikunja.app",
"appName": "Vikunja",
"webDir": "dist",
"bundledWebRuntime": false
}

View File

@ -0,0 +1 @@
[]

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@ -0,0 +1,21 @@
package io.vikunja.app;
import android.os.Bundle;
import com.getcapacitor.BridgeActivity;
import com.getcapacitor.Plugin;
import java.util.ArrayList;
public class MainActivity extends BridgeActivity {
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// Initializes the Bridge
this.init(savedInstanceState, new ArrayList<Class<? extends Plugin>>() {{
// Additional plugins you've installed go here
// Ex: add(TotallyAwesomePlugin.class);
}});
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@ -0,0 +1,101 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<group android:scaleX="0.29109365"
android:scaleY="0.29109365"
android:translateX="16.740015"
android:translateY="16.74">
<path
android:pathData="m167.62,161.652c4.758,-13.899 12.617,-18.154 13.594,-39.601 0.343,-7.537 -1.955,-31.477 -1.955,-31.477 0,-0 3.735,-9.599 -3.443,-20.292 -11.13,-16.58 -34.053,-17.707 -46.092,-17.707 -12.039,-0 -34.694,1.127 -45.825,17.707 -7.179,10.693 -3.444,20.292 -3.444,20.292 0,-0 -1.271,12.313 -2.234,23.172 -0.227,2.548 0.021,12.629 0.327,15.064 0.55,4.364 1.931,8.652 3.441,12.826 1.912,5.285 4.909,10.648 7.198,16.07 6.907,16.371 1.127,36.647 1.48,54.3 0.298,12.531 -2.668,25.242 -3.831,37.496 13.084,4.434 26.945,6.747 41.162,6.747 15.978,-0 31.507,-2.913 45.987,-8.484 -2.719,-15.342 -5.879,-29.894 -6.366,-35.446 -1.419,-16.785 -5.507,-34.587 -0.001,-50.667"
android:strokeWidth="0.048874177"
android:fillColor="#ffffff"
android:strokeColor="#00000000"
android:fillType="nonZero"/>
<path
android:pathData="m79.507,63.921c0.153,2.497 0.4,5.057 1.103,7.488 0.221,0.758 0.547,2.624 1.522,2.902 1.212,0.345 3.455,-1.101 4.305,-1.728 1.648,-1.213 3.19,-2.461 4.838,-3.687 1.051,-0.782 2.199,-1.396 3.228,-2.199 1.12,-0.873 1.963,-1.93 2.895,-2.957 1.131,-1.248 1.98,-2.54 2.85,-3.933 0.68,-1.087 4.943,-1.795 5.625,-2.804 0.745,-1.103 0.122,-2.468 0.122,-3.826 0,-9.889 0.594,-19.675 0.825,-29.372 0.238,-6.652 2.851,-14.55 -7.125,-11.432 -21.375,6.652 -21.137,36.168 -20.187,51.549"
android:strokeWidth="0.04884417"
android:fillColor="#ffffff"
android:strokeColor="#00000000"
android:fillType="nonZero"/>
<path
android:pathData="m173.67,77.809c-0.215,-0.233 -0.559,-0.432 -0.835,-0.636 -1.197,-0.887 -2.326,-1.814 -3.354,-2.864 -1.255,-1.282 -2.559,-2.528 -3.755,-3.857 -1.052,-1.168 -2.308,-2.214 -3.334,-3.41 -0.684,-0.795 -1.344,-1.641 -1.948,-2.475 -0.308,-0.423 -0.469,-0.933 -0.712,-1.386 -0.361,-0.668 -5.318,-1.349 -4.825,-2.001 0.84,-1.111 2.71,-2.053 3.16,-3.325 2.299,-6.489 3.303,-32.603 3.58,-39.268 0.714,-17.044 15.676,0.832 18.289,7.691 7.601,19.954 3.086,34.504 -6.175,51.549"
android:strokeWidth="0.04884417"
android:fillColor="#ffffff"
android:strokeColor="#00000000"
android:fillType="nonZero"/>
<path
android:pathData="m153.319,143.388c-0.011,-9.841 -1.234,-13.822 -6.721,-18.634 -3.432,-3.009 -7.445,-4.157 -13.832,-3.999 -6.387,-0.158 -10.99,1.784 -14.309,5.23 -5.007,5.199 -6.325,8.274 -6.339,18.114 -0.008,5.319 5.727,17.089 7.434,19.805 2.832,4.791 8.075,6.513 13.214,6.154 5.139,0.359 10.382,-1.363 13.213,-6.154 1.708,-2.716 7.348,-14.261 7.34,-20.517"
android:strokeWidth="0.04884417"
android:fillColor="#f1e6d3"
android:strokeColor="#00000000"
android:fillType="nonZero"/>
<path
android:pathData="m88.003,72.959c-1.282,-11.478 -5.144,-31.997 8.198,-44.165 1.472,-1.343 1.867,3.227 2.113,5.075 0.338,2.538 -0.007,9.789 0.045,12.344 0.095,4.812 1.103,7.329 0.634,9.528 -1.284,6.012 -2.314,5.624 -3.541,9.28 -2.434,7.25 -7.098,11.075 -7.449,7.937"
android:strokeWidth="0.04884417"
android:fillColor="#f1d7d4"
android:strokeColor="#00000000"
android:fillType="nonZero"/>
<path
android:pathData="m171.355,70.691c1.366,2.444 4.933,-10.701 6.051,-16.41 1.053,-5.374 2.39,-12.516 0.541,-17.855 -0.992,-2.864 -7.077,-11.276 -7.795,-8.386 -0.28,1.122 -0.424,9.48 -1.113,13.479 -0.852,4.948 -2.388,9.841 -2.397,14.873 -0.02,11.276 3.378,11.741 4.713,14.299"
android:strokeWidth="0.04884417"
android:fillColor="#f1d7d4"
android:strokeColor="#00000000"
android:fillType="nonZero"/>
<path
android:pathData="m127.391,205.803c-0.289,0.878 -2.479,1.521 -3.302,1.786 -1.988,0.642 -3.753,-0.925 -4.863,-2.246 -1.544,-1.831 -1.503,-4.751 -2.15,-6.886 -0.391,-1.291 -2.604,-1.466 -3.269,-0.272 -2.217,3.987 -10.821,-3.515 -8.822,-6.013 1.318,-1.647 -1.838,-3.102 -3.139,-1.477 -3.86,4.821 6.523,13.799 12.477,11.366 0.265,1.205 0.543,2.408 1.055,3.551 1.003,2.247 3.197,3.698 5.468,4.919 3.093,1.658 9.101,-1.254 10.019,-4.034 0.639,-1.934 -2.837,-2.618 -3.474,-0.695"
android:strokeWidth="0.04884417"
android:fillColor="#faeee0"
android:strokeColor="#00000000"
android:fillType="nonZero"/>
<path
android:pathData="m154.806,176.672c0.319,1.339 0.37,5.876 -2.125,3.394 -1.012,-1.007 -3.022,-0.413 -3.104,0.934 -0.193,3.118 -3.32,3.187 -6.018,2.244 -2.141,-0.747 -3.271,2.212 -1.147,2.953 3.962,1.382 8.261,0.616 9.97,-2.378 1.344,0.633 2.865,0.805 4.084,-0.342 2.129,-2.006 2.45,-4.83 1.831,-7.428 -0.47,-1.968 -3.96,-1.346 -3.49,0.623"
android:strokeWidth="0.04884417"
android:fillColor="#faeee0"
android:strokeColor="#00000000"
android:fillType="nonZero"/>
<path
android:pathData="m161.128,241.87c-0.934,1.909 -2.647,1.79 -4.485,0.988 -1.348,-0.587 -3.014,0.383 -2.608,1.748 0.818,2.736 -2.638,3.535 -4.975,2.559 -2.057,-0.859 -3.845,1.839 -1.766,2.708 4.211,1.757 9.564,0.22 10.34,-3.485 2.708,0.429 5.295,-0.331 6.586,-2.975 0.894,-1.824 -2.187,-3.396 -3.093,-1.544"
android:strokeWidth="0.04884417"
android:fillColor="#faeee0"
android:strokeColor="#00000000"
android:fillType="nonZero"/>
<path
android:pathData="m133.758,94.668c-0.434,2.691 -5.479,-0.443 -6.732,-1.292 -1.103,-0.746 -2.53,-0.293 -2.664,0.965 -0.18,1.695 -2.464,1.203 -3.508,0.99 -1.974,-0.401 -3.649,-1.632 -3.696,-3.481 -0.046,-1.796 1.094,-3.683 1.689,-5.369 0.38,-1.077 -1.163,-2.262 -2.291,-1.542 -2.032,1.297 -11.829,2.464 -7.927,-1.806 1.243,-1.36 -0.951,-3.306 -2.207,-1.931 -1.802,1.973 -3.021,6.493 0.878,7.45 2.058,0.505 5.037,0.479 7.575,-0.1 -1.06,3.397 -1.282,6.507 3.087,8.689 2.953,1.476 7.008,1.465 8.726,-0.814 3.615,1.944 9.295,3.831 10.08,-1.035 0.278,-1.721 -2.728,-2.464 -3.009,-0.725"
android:strokeWidth="0.04884417"
android:fillColor="#faeee0"
android:strokeColor="#00000000"
android:fillType="nonZero"/>
<path
android:pathData="m116.859,157.027c5.249,0.444 10.5,1.636 15.797,1.445 3.386,-0.123 6.892,-0.597 10.233,-1.093 2.427,-0.359 5.403,-0.784 7.026,-2.583 0.152,6.002 -12.335,7.881 -17.178,8.003 -5.619,0.142 -11.935,-2.296 -15.878,-5.772"
android:strokeWidth="0.04884417"
android:fillColor="#494949"
android:strokeColor="#00000000"
android:fillType="nonZero"/>
<path
android:pathData="m131.049,144.504c-0.6,3.853 -0.724,7.76 -0.363,11.636 0.096,1.013 -0.174,3.428 1.173,3.936 1.092,0.413 2.442,-0.363 2.62,-1.348 0.301,-1.661 -0.405,-3.755 -0.465,-5.456 -0.071,-1.992 0,-3.987 0.211,-5.969 0.126,-1.175 0.785,-2.761 -0.56,-3.589 -0.987,-0.607 -2.434,-0.383 -2.616,0.79"
android:strokeWidth="0.04884417"
android:fillColor="#494949"
android:strokeColor="#00000000"
android:fillType="nonZero"/>
<path
android:pathData="m141.253,135.138c-3.155,0.136 -6.084,1.902 -8.453,3.603 -2.144,-1.423 -5.391,-2.731 -8.092,-1.97 -1.61,0.452 -0.148,0.946 0.524,1.375 1.241,0.825 2.253,1.886 3.166,2.982 1.385,1.68 2.617,3.444 4.095,5.064 1.023,-0.737 2,-1.48 2.89,-2.341 0.413,-0.399 3.791,-4.923 4.31,-5.689 0.331,-0.487 0.665,-0.991 1.045,-1.45 0.385,-0.464 1.476,-1.202 0.517,-1.575"
android:strokeWidth="0.04884417"
android:fillColor="#494949"
android:strokeColor="#00000000"
android:fillType="nonZero"/>
<path
android:pathData="m101.325,113.765c1.558,-4.04 3.232,-9.118 7.502,-8.31 3.233,0.693 7.56,11.482 4.79,10.417 -1.5,-0.577 -2.694,-5.481 -5.511,-6.117 -1.79,-0.404 -4.271,5.8 -6.666,6.435 -1.161,0.307 -0.577,-1.385 -0.115,-2.424"
android:strokeWidth="0.04884417"
android:fillColor="#2c3844"
android:strokeColor="#00000000"
android:fillType="nonZero"/>
<path
android:pathData="m147.202,113.765c1.558,-4.04 3.233,-9.118 7.502,-8.31 3.232,0.693 7.559,11.482 4.79,10.417 -1.5,-0.577 -2.694,-5.481 -5.511,-6.117 -1.79,-0.404 -4.271,5.8 -6.665,6.435 -1.161,0.307 -0.577,-1.385 -0.116,-2.424"
android:strokeWidth="0.04884417"
android:fillColor="#2c3844"
android:strokeColor="#00000000"
android:fillType="nonZero"/>
</group>
</vector>

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<WebView
android:layout_width="match_parent"
android:layout_height="match_parent" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#1973FF</color>
</resources>

View File

@ -0,0 +1,7 @@
<?xml version='1.0' encoding='utf-8'?>
<resources>
<string name="app_name">Vikunja</string>
<string name="title_activity_main">Vikunja</string>
<string name="package_name">io.vikunja.app</string>
<string name="custom_url_scheme">io.vikunja.app</string>
</resources>

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
</style>
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.NoActionBar">
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
<item name="android:background">@null</item>
</style>
<style name="AppTheme.NoActionBarLaunch" parent="AppTheme.NoActionBar">
<item name="android:background">@drawable/splash</item>
</style>
</resources>

View File

@ -0,0 +1,6 @@
<?xml version='1.0' encoding='utf-8'?>
<widget version="1.0.0" xmlns="http://www.w3.org/ns/widgets" xmlns:cdv="http://cordova.apache.org/ns/1.0">
<access origin="*" />
</widget>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="my_images" path="." />
<cache-path name="my_cache_images" path="." />
</paths>

29
android/build.gradle Normal file
View File

@ -0,0 +1,29 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
repositories {
google()
jcenter()
}
dependencies {
classpath 'com.android.tools.build:gradle:3.6.1'
classpath 'com.google.gms:google-services:4.3.3'
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}
apply from: "variables.gradle"
allprojects {
repositories {
google()
jcenter()
}
}
task clean(type: Delete) {
delete rootProject.buildDir
}

View File

@ -0,0 +1,3 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')

24
android/gradle.properties Normal file
View File

@ -0,0 +1,24 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx1536m
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Automatically convert third-party libraries to use AndroidX
android.enableJetifier=true

Binary file not shown.

View File

@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-5.6.4-all.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

188
android/gradlew vendored Executable file
View File

@ -0,0 +1,188 @@
#!/usr/bin/env sh
#
# Copyright 2015 the original author or authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=$((i+1))
done
case $i in
(0) set -- ;;
(1) set -- "$args0" ;;
(2) set -- "$args0" "$args1" ;;
(3) set -- "$args0" "$args1" "$args2" ;;
(4) set -- "$args0" "$args1" "$args2" "$args3" ;;
(5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
(6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
(7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
(8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
(9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=$(save "$@")
# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
cd "$(dirname "$0")"
fi
exec "$JAVACMD" "$@"

100
android/gradlew.bat vendored Normal file
View File

@ -0,0 +1,100 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto init
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto init
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:init
@rem Get command-line arguments, handling Windows variants
if not "%OS%" == "Windows_NT" goto win9xME_args
:win9xME_args
@rem Slurp the command line arguments.
set CMD_LINE_ARGS=
set _SKIP=2
:win9xME_args_slurp
if "x%~1" == "x" goto execute
set CMD_LINE_ARGS=%*
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

5
android/settings.gradle Normal file
View File

@ -0,0 +1,5 @@
include ':app'
include ':capacitor-cordova-android-plugins'
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
apply from: 'capacitor.settings.gradle'

17
android/variables.gradle Normal file
View File

@ -0,0 +1,17 @@
ext {
minSdkVersion = 21
compileSdkVersion = 29
targetSdkVersion = 29
androidxAppCompatVersion = '1.1.0'
androidxCoreVersion = '1.2.0'
androidxMaterialVersion = '1.1.0-rc02'
androidxBrowserVersion = '1.2.0'
androidxLocalbroadcastmanagerVersion = '1.0.0'
androidxExifInterfaceVersion = '1.2.0'
firebaseMessagingVersion = '20.1.2'
playServicesLocationVersion = '17.0.0'
junitVersion = '4.12'
androidxJunitVersion = '1.1.1'
androidxEspressoCoreVersion = '3.2.0'
cordovaAndroidVersion = '7.0.0'
}

10
capacitor.config.ts Normal file
View File

@ -0,0 +1,10 @@
import { CapacitorConfig } from '@capacitor/cli'
const config: CapacitorConfig = {
appId: 'io.vikunja.app',
appName: 'Vikunja',
webDir: 'dist',
bundledWebRuntime: false,
}
export default config

View File

@ -1,59 +0,0 @@
[changelog]
body = """
{% if version %}\
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
{% else %}\
## [unreleased]
{% endif %}\
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group | upper_first }}
{% for commit in commits
| filter(attribute="scope")
| sort(attribute="scope") %}
* *({{commit.scope}})* {{ commit.message | upper_first }}
{%- if commit.breaking %}
{% raw %} {% endraw %}- **BREAKING**: {{commit.breaking_description}}
{%- endif -%}
{%- endfor -%}
{%- for commit in commits %}
{%- if commit.scope -%}
{% else -%}
* {{ commit.message | upper_first }} ([{{ commit.id | truncate(length=7, end="") }}]({{ commit.id }}))
{% if commit.breaking -%}
{% raw %} {% endraw %}- **BREAKING**: {{commit.breaking_description}}
{% endif -%}
{% endif -%}
{% endfor -%}
{% raw %}\n{% endraw %}\
{% endfor %}\n
"""
#{% for group, commits in commits | group_by(attribute="group") %}
# ### {{ group | upper_first }}
# {% for commit in commits %}\
# - {% if commit.breaking %}[**breaking**] {% endif %}{{ commit.message | upper_first }} ([{{ commit.id | truncate(length=7, end="") }}]({{ commit.id }}))
# {% endfor %}\
#{% endfor %}\n
# remove the leading and trailing whitespace from the template
trim = true
[git]
conventional_commits = true
filter_unconventional = false
commit_parsers = [
{ message = ".*(deps).*", group = "Dependencies"},
{ message = "^feat", group = "Features"},
{ message = "^fix", group = "Bug Fixes"},
{ message = "^doc", group = "Documentation"},
{ message = "^perf", group = "Performance"},
{ message = "^refactor", group = "Refactor"},
{ message = "^style", group = "Styling"},
{ message = "^test", group = "Testing"},
{ message = "^chore\\(release\\): prepare for", skip = true},
{ message = "^chore", group = "Miscellaneous Tasks"},
{ body = ".*security", group = "Security"},
{ message = ".*", group = "Other", default_scope = "other"}, # Everything that's not a conventional commit goes into the "Other" category
]

View File

@ -1,28 +0,0 @@
import {defineConfig} from 'cypress'
export default defineConfig({
env: {
API_URL: 'http://localhost:3456/api/v1',
TEST_SECRET: 'averyLongSecretToSe33dtheDB',
},
video: false,
retries: {
runMode: 2,
},
projectId: '181c7x',
e2e: {
specPattern: 'cypress/e2e/**/*.{cy,spec}.{js,jsx,ts,tsx}',
baseUrl: 'http://127.0.0.1:4173',
experimentalRunAllSpecs: true,
// testIsolation: false,
},
component: {
devServer: {
framework: 'vue',
bundler: 'vite',
},
},
viewportWidth: 1600,
viewportHeight: 900,
experimentalMemoryManagement: true,
})

11
cypress.json Normal file
View File

@ -0,0 +1,11 @@
{
"baseUrl": "http://localhost:5000",
"env": {
"API_URL": "http://localhost:3456/api/v1",
"TEST_SECRET": "averyLongSecretToSe33dtheDB"
},
"video": false,
"retries": {
"runMode": 2
}
}

View File

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

View File

@ -9,7 +9,7 @@ services:
ports:
- 3456:3456
cypress:
image: cypress/browsers:node18.12.0-chrome107
image: cypress/browsers:node12.18.3-chrome87-ff82
volumes:
- ..:/project
- $HOME/.cache:/home/node/.cache/

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,285 +0,0 @@
import {createFakeUserAndLogin} from '../../support/authenticateUser'
import {BucketFactory} from '../../factories/bucket'
import {ProjectFactory} from '../../factories/project'
import {TaskFactory} from '../../factories/task'
import {prepareProjects} from './prepareProjects'
function createSingleTaskInBucket(count = 1, attrs = {}) {
const projects = ProjectFactory.create(1)
const buckets = BucketFactory.create(2, {
project_id: projects[0].id,
})
const tasks = TaskFactory.create(count, {
project_id: projects[0].id,
bucket_id: buckets[0].id,
...attrs,
})
return tasks[0]
}
describe('Project View Kanban', () => {
createFakeUserAndLogin()
prepareProjects()
let buckets
beforeEach(() => {
buckets = BucketFactory.create(2)
})
it('Shows all buckets with their tasks', () => {
const data = TaskFactory.create(10, {
project_id: 1,
bucket_id: 1,
})
cy.visit('/projects/1/kanban')
cy.get('.kanban .bucket .title')
.contains(buckets[0].title)
.should('exist')
cy.get('.kanban .bucket .title')
.contains(buckets[1].title)
.should('exist')
cy.get('.kanban .bucket')
.first()
.should('contain', data[0].title)
})
it('Can add a new task to a bucket', () => {
TaskFactory.create(2, {
project_id: 1,
bucket_id: 1,
})
cy.visit('/projects/1/kanban')
cy.get('.kanban .bucket')
.contains(buckets[0].title)
.get('.bucket-footer .button')
.contains('Add another task')
.click()
cy.get('.kanban .bucket')
.contains(buckets[0].title)
.get('.bucket-footer .field .control input.input')
.type('New Task{enter}')
cy.get('.kanban .bucket')
.first()
.should('contain', 'New Task')
})
it('Can create a new bucket', () => {
cy.visit('/projects/1/kanban')
cy.get('.kanban .bucket.new-bucket .button')
.click()
cy.get('.kanban .bucket.new-bucket input.input')
.type('New Bucket{enter}')
cy.wait(1000) // Wait for the request to finish
cy.get('.kanban .bucket .title')
.contains('New Bucket')
.should('exist')
})
it('Can set a bucket limit', () => {
cy.visit('/projects/1/kanban')
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-trigger')
.first()
.click()
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-menu .dropdown-item')
.contains('Limit: Not Set')
.click()
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-menu .dropdown-item .field input.input')
.first()
.type(3)
cy.get('[data-cy="setBucketLimit"]')
.first()
.click()
cy.get('.kanban .bucket .bucket-header span.limit')
.contains('0/3')
.should('exist')
})
it('Can rename a bucket', () => {
cy.visit('/projects/1/kanban')
cy.get('.kanban .bucket .bucket-header .title')
.first()
.type('{selectall}New Bucket Title{enter}')
cy.get('.kanban .bucket .bucket-header .title')
.first()
.should('contain', 'New Bucket Title')
})
it('Can delete a bucket', () => {
cy.visit('/projects/1/kanban')
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-trigger')
.first()
.click()
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-menu .dropdown-item')
.contains('Delete')
.click()
cy.get('.modal-mask .modal-container .modal-content .header')
.should('contain', 'Delete the bucket')
cy.get('.modal-mask .modal-container .modal-content .actions .button')
.contains('Do it!')
.click()
cy.get('.kanban .bucket .title')
.contains(buckets[0].title)
.should('not.exist')
cy.get('.kanban .bucket .title')
.contains(buckets[1].title)
.should('exist')
})
it('Can drag tasks around', () => {
const tasks = TaskFactory.create(2, {
project_id: 1,
bucket_id: 1,
})
cy.visit('/projects/1/kanban')
cy.get('.kanban .bucket .tasks .task')
.contains(tasks[0].title)
.first()
.drag('.kanban .bucket:nth-child(2) .tasks')
cy.get('.kanban .bucket:nth-child(2) .tasks')
.should('contain', tasks[0].title)
cy.get('.kanban .bucket:nth-child(1) .tasks')
.should('not.contain', tasks[0].title)
})
it('Should navigate to the task when the task card is clicked', () => {
const tasks = TaskFactory.create(5, {
id: '{increment}',
project_id: 1,
bucket_id: 1,
})
cy.visit('/projects/1/kanban')
cy.get('.kanban .bucket .tasks .task')
.contains(tasks[0].title)
.should('be.visible')
.click()
cy.url()
.should('contain', `/tasks/${tasks[0].id}`, { timeout: 1000 })
})
it('Should remove a task from the kanban board when moving it to another project', () => {
const projects = ProjectFactory.create(2)
BucketFactory.create(2, {
project_id: '{increment}',
})
const tasks = TaskFactory.create(5, {
id: '{increment}',
project_id: 1,
bucket_id: 1,
})
const task = tasks[0]
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', { timeout: 3000 })
.contains('Move')
.click()
cy.get('.task-view .content.details .field .multiselect.control .input-wrapper input')
.type(`${projects[1].title}{enter}`)
// The requests happen with a 200ms timeout. Because of that, the results are not yet there when cypress
// presses enter and we can't simulate pressing on enter to select the item.
cy.get('.task-view .content.details .field .multiselect.control .search-results')
.children()
.first()
.click()
cy.get('.global-notification', { timeout: 1000 })
.should('contain', 'Success')
cy.go('back')
cy.get('.kanban .bucket')
.should('not.contain', task.title)
})
it('Shows a button to filter the kanban board', () => {
const data = TaskFactory.create(10, {
project_id: 1,
bucket_id: 1,
})
cy.visit('/projects/1/kanban')
cy.get('.project-kanban .filter-container .base-button')
.should('exist')
})
it('Should remove a task from the board when deleting it', () => {
const task = createSingleTaskInBucket(5)
cy.visit('/projects/1/kanban')
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,109 +0,0 @@
import {createFakeUserAndLogin} from '../../support/authenticateUser'
import {UserProjectFactory} from '../../factories/users_project'
import {TaskFactory} from '../../factories/task'
import {UserFactory} from '../../factories/user'
import {ProjectFactory} from '../../factories/project'
import {prepareProjects} from './prepareProjects'
describe('Project View Project', () => {
createFakeUserAndLogin()
prepareProjects()
it('Should be an empty project', () => {
cy.visit('/projects/1')
cy.url()
.should('contain', '/projects/1/list')
cy.get('.project-title')
.should('contain', 'First Project')
cy.get('.project-title-dropdown')
.should('exist')
cy.get('p')
.contains('This project is currently empty.')
.should('exist')
})
it('Should create a new task', () => {
const newTaskTitle = 'New task'
cy.visit('/projects/1')
cy.get('.task-add textarea')
.type(newTaskTitle+'{enter}')
cy.get('.tasks')
.should('contain.text', newTaskTitle)
})
it('Should navigate to the task when the title is clicked', () => {
const tasks = TaskFactory.create(5, {
id: '{increment}',
project_id: 1,
})
cy.visit('/projects/1/list')
cy.get('.tasks .task .tasktext')
.contains(tasks[0].title)
.first()
.click()
cy.url()
.should('contain', `/tasks/${tasks[0].id}`)
})
it('Should not see any elements for a project which is shared read only', () => {
UserFactory.create(2)
UserProjectFactory.create(1, {
project_id: 2,
user_id: 1,
right: 0,
})
const projects = ProjectFactory.create(2, {
owner_id: '{increment}',
})
cy.visit(`/projects/${projects[1].id}/`)
cy.get('.project-title-wrapper .icon')
.should('not.exist')
cy.get('input.input[placeholder="Add a new task..."')
.should('not.exist')
})
it('Should only show the color of a project in the navigation and not in the list view', () => {
const projects = ProjectFactory.create(1, {
hex_color: '00db60',
})
TaskFactory.create(10, {
project_id: projects[0].id,
})
cy.visit(`/projects/${projects[0].id}/`)
cy.get('.menu-list li .list-menu-link .color-bubble')
.should('have.css', 'background-color', 'rgb(0, 219, 96)')
cy.get('.tasks .color-bubble')
.should('not.exist')
})
it('Should paginate for > 50 tasks', () => {
const tasks = TaskFactory.create(100, {
id: '{increment}',
title: i => `task${i}`,
project_id: 1,
})
cy.visit('/projects/1/list')
cy.get('.tasks')
.should('contain', tasks[1].title)
cy.get('.tasks')
.should('not.contain', tasks[99].title)
cy.get('.card-content .pagination .pagination-link')
.contains('2')
.click()
cy.url()
.should('contain', '?page=2')
cy.get('.tasks')
.should('contain', tasks[99].title)
cy.get('.tasks')
.should('not.contain', tasks[1].title)
})
})

View File

@ -1,54 +0,0 @@
import {createFakeUserAndLogin} from '../../support/authenticateUser'
import {TaskFactory} from '../../factories/task'
describe('Project View Table', () => {
createFakeUserAndLogin()
it('Should show a table with tasks', () => {
const tasks = TaskFactory.create(1)
cy.visit('/projects/1/table')
cy.get('.project-table table.table')
.should('exist')
cy.get('.project-table table.table')
.should('contain', tasks[0].title)
})
it('Should have working column switches', () => {
TaskFactory.create(1)
cy.visit('/projects/1/table')
cy.get('.project-table .filter-container .items .button')
.contains('Columns')
.click()
cy.get('.project-table .filter-container .card.columns-filter .card-content .fancycheckbox')
.contains('Priority')
.click()
cy.get('.project-table .filter-container .card.columns-filter .card-content .fancycheckbox')
.contains('Done')
.click()
cy.get('.project-table table.table th')
.contains('Priority')
.should('exist')
cy.get('.project-table table.table th')
.contains('Done')
.should('not.exist')
})
it('Should navigate to the task when the title is clicked', () => {
const tasks = TaskFactory.create(5, {
id: '{increment}',
project_id: 1,
})
cy.visit('/projects/1/table')
cy.get('.project-table table.table')
.contains(tasks[0].title)
.click()
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 +0,0 @@
import {LinkShareFactory} from '../../factories/link_sharing'
import {ProjectFactory} from '../../factories/project'
import {TaskFactory} from '../../factories/task'
function prepareLinkShare() {
const projects = ProjectFactory.create(1)
const tasks = TaskFactory.create(10, {
project_id: projects[0].id
})
const linkShares = LinkShareFactory.create(1, {
project_id: projects[0].id,
right: 0,
})
return {
share: linkShares[0],
project: projects[0],
tasks,
}
}
describe('Link shares', () => {
it('Can view a link share', () => {
const {share, project, tasks} = prepareLinkShare()
cy.visit(`/share/${share.hash}/auth`)
cy.get('h1.title')
.should('contain', project.title)
cy.get('input.input[placeholder="Add a new task..."')
.should('not.exist')
cy.get('.tasks')
.should('contain', tasks[0].title)
cy.url().should('contain', `/projects/${project.id}/list#share-auth-token=${share.hash}`)
})
it('Should work when directly viewing a project with share hash present', () => {
const {share, project, tasks} = prepareLinkShare()
cy.visit(`/projects/${project.id}/list#share-auth-token=${share.hash}`)
cy.get('h1.title')
.should('contain', project.title)
cy.get('input.input[placeholder="Add a new task..."')
.should('not.exist')
cy.get('.tasks')
.should('contain', tasks[0].title)
})
it('Should work when directly viewing a task with share hash present', () => {
const {share, project, tasks} = prepareLinkShare()
cy.visit(`/tasks/${tasks[0].id}#share-auth-token=${share.hash}`)
cy.get('h1.title')
.should('contain', tasks[0].title)
})
})

View File

@ -1,150 +0,0 @@
import {createFakeUserAndLogin} from '../../support/authenticateUser'
import {ProjectFactory} from '../../factories/project'
import {seed} from '../../support/seed'
import {TaskFactory} from '../../factories/task'
import {BucketFactory} from '../../factories/bucket'
import {updateUserSettings} from '../../support/updateUserSettings'
function seedTasks(numberOfTasks = 50, startDueDate = new Date()) {
const project = ProjectFactory.create()[0]
BucketFactory.create(1, {
project_id: project.id,
})
const tasks = []
let dueDate = startDueDate
for (let i = 0; i < numberOfTasks; i++) {
const now = new Date()
dueDate = new Date(new Date(dueDate).setDate(dueDate.getDate() + 2))
tasks.push({
id: i + 1,
project_id: project.id,
done: false,
created_by_id: 1,
title: 'Test Task ' + i,
index: i + 1,
due_date: dueDate.toISOString(),
created: now.toISOString(),
updated: now.toISOString(),
})
}
seed(TaskFactory.table, tasks)
return {tasks, project}
}
describe('Home Page Task Overview', () => {
createFakeUserAndLogin()
it('Should show tasks with a near due date first on the home page overview', () => {
const taskCount = 50
const {tasks} = seedTasks(taskCount)
cy.visit('/')
cy.get('[data-cy="showTasks"] .card .task')
.each(([task], index) => {
expect(task.innerText).to.contain(tasks[index].title)
})
})
it('Should show overdue tasks first, then show other tasks', () => {
const now = new Date()
const oldDate = new Date(new Date(now).setDate(now.getDate() - 14))
const taskCount = 50
const {tasks} = seedTasks(taskCount, oldDate)
cy.visit('/')
cy.get('[data-cy="showTasks"] .card .task')
.each(([task], index) => {
expect(task.innerText).to.contain(tasks[index].title)
})
})
it('Should show a new task with a very soon due date at the top', () => {
const {tasks} = seedTasks()
const newTaskTitle = 'New Task'
cy.visit('/')
TaskFactory.create(1, {
id: 999,
title: newTaskTitle,
due_date: new Date().toISOString(),
}, false)
cy.visit(`/projects/${tasks[0].project_id}/list`)
cy.get('.tasks .task')
.first()
.should('contain.text', newTaskTitle)
cy.visit('/')
cy.get('[data-cy="showTasks"] .card .task')
.first()
.should('contain.text', newTaskTitle)
})
it('Should not show a new task without a date at the bottom when there are > 50 tasks', () => {
// We're not using the api here to create the task in order to verify the flow
const {tasks} = seedTasks(100)
const newTaskTitle = 'New Task'
cy.visit('/')
cy.visit(`/projects/${tasks[0].project_id}/list`)
cy.get('.task-add textarea')
.type(newTaskTitle+'{enter}')
cy.visit('/')
cy.get('[data-cy="showTasks"] .card .task')
.last()
.should('not.contain.text', newTaskTitle)
})
it('Should show a new task without a date at the bottom when there are < 50 tasks', () => {
seedTasks(40)
const newTaskTitle = 'New Task'
TaskFactory.create(1, {
id: 999,
title: newTaskTitle,
}, false)
cy.visit('/')
cy.get('[data-cy="showTasks"] .card .task')
.last()
.should('contain.text', newTaskTitle)
})
it('Should show a task without a due date added via default project at the bottom', () => {
const {project} = seedTasks(40)
updateUserSettings({
default_project_id: project.id,
overdue_tasks_reminders_time: '9:00',
})
const newTaskTitle = 'New Task'
cy.visit('/')
cy.get('.add-task-textarea')
.type(`${newTaskTitle}{enter}`)
cy.get('[data-cy="showTasks"] .card .task')
.last()
.should('contain.text', newTaskTitle)
})
it('Should show the cta buttons for new project when there are no tasks', () => {
TaskFactory.truncate()
cy.visit('/')
cy.get('.home.app-content .content')
.should('contain.text', 'Import your projects and tasks from other services into Vikunja:')
})
it('Should not show the cta buttons for new project when there are tasks', () => {
seedTasks()
cy.visit('/')
cy.get('.home.app-content .content')
.should('not.contain.text', 'You can create a new project for your new tasks:')
.should('not.contain.text', 'Or import your projects and tasks from other services into Vikunja:')
})
})

View File

@ -1,968 +0,0 @@
import {createFakeUserAndLogin} from '../../support/authenticateUser'
import {TaskFactory} from '../../factories/task'
import {ProjectFactory} from '../../factories/project'
import {TaskCommentFactory} from '../../factories/task_comment'
import {UserFactory} from '../../factories/user'
import {UserProjectFactory} from '../../factories/users_project'
import {TaskAssigneeFactory} from '../../factories/task_assignee'
import {LabelFactory} from '../../factories/labels'
import {LabelTaskFactory} from '../../factories/label_task'
import {BucketFactory} from '../../factories/bucket'
import {TaskAttachmentFactory} from '../../factories/task_attachments'
import {TaskReminderFactory} from '../../factories/task_reminders'
function addLabelToTaskAndVerify(labelTitle: string) {
cy.get('.task-view .action-buttons .button')
.contains('Add Labels')
.click()
cy.get('.task-view .details.labels-list .multiselect input')
.type(labelTitle)
cy.get('.task-view .details.labels-list .multiselect .search-results')
.children()
.first()
.click()
cy.get('.global-notification', {timeout: 4000})
.should('contain', 'Success')
cy.get('.task-view .details.labels-list .multiselect .input-wrapper span.tag')
.should('exist')
.should('contain', labelTitle)
}
function uploadAttachmentAndVerify(taskId: number) {
cy.intercept(`${Cypress.env('API_URL')}/tasks/${taskId}/attachments`).as('uploadAttachment')
cy.get('.task-view .action-buttons .button')
.contains('Add Attachments')
.click()
cy.get('input[type=file]#files', {timeout: 1000})
.selectFile('cypress/fixtures/image.jpg', {force: true}) // The input is not visible, but on purpose
cy.wait('@uploadAttachment')
cy.get('.attachments .attachments .files a.attachment')
.should('exist')
}
describe('Task', () => {
createFakeUserAndLogin()
let projects
let buckets
beforeEach(() => {
// UserFactory.create(1)
projects = ProjectFactory.create(1)
buckets = BucketFactory.create(1, {
project_id: projects[0].id,
})
TaskFactory.truncate()
UserProjectFactory.truncate()
})
it('Should be created new', () => {
cy.visit('/projects/1/list')
cy.get('.input[placeholder="Add a new task…"')
.type('New Task')
cy.get('.button')
.contains('Add')
.click()
cy.get('.tasks .task .tasktext')
.first()
.should('contain', 'New Task')
})
it('Inserts new tasks at the top of the project', () => {
TaskFactory.create(1)
cy.visit('/projects/1/list')
cy.get('.project-is-empty-notice')
.should('not.exist')
cy.get('.input[placeholder="Add a new task…"')
.type('New Task')
cy.get('.button')
.contains('Add')
.click()
cy.wait(1000) // Wait for the request
cy.get('.tasks .task .tasktext')
.first()
.should('contain', 'New Task')
})
it('Marks a task as done', () => {
TaskFactory.create(1)
cy.visit('/projects/1/list')
cy.get('.tasks .task .fancycheckbox')
.first()
.click()
cy.get('.global-notification')
.should('contain', 'Success')
})
it('Can add a task to favorites', () => {
TaskFactory.create(1)
cy.visit('/projects/1/list')
cy.get('.tasks .task .favorite')
.first()
.click()
cy.get('.menu-container')
.should('contain', 'Favorites')
})
it('Should show a task description icon if the task has a description', () => {
cy.intercept(Cypress.env('API_URL') + '/projects/1/tasks**').as('loadTasks')
TaskFactory.create(1, {
description: 'Lorem Ipsum',
})
cy.visit('/projects/1/list')
cy.wait('@loadTasks')
cy.get('.tasks .task .project-task-icon')
.should('exist')
})
it('Should not show a task description icon if the task has an empty description', () => {
cy.intercept(Cypress.env('API_URL') + '/projects/1/tasks**').as('loadTasks')
TaskFactory.create(1, {
description: '',
})
cy.visit('/projects/1/list')
cy.wait('@loadTasks')
cy.get('.tasks .task .project-task-icon')
.should('not.exist')
})
it('Should not show a task description icon if the task has a description containing only an empty p tag', () => {
cy.intercept(Cypress.env('API_URL') + '/projects/1/tasks**').as('loadTasks')
TaskFactory.create(1, {
description: '<p></p>',
})
cy.visit('/projects/1/list')
cy.wait('@loadTasks')
cy.get('.tasks .task .project-task-icon')
.should('not.exist')
})
describe('Task Detail View', () => {
beforeEach(() => {
TaskCommentFactory.truncate()
LabelTaskFactory.truncate()
TaskAttachmentFactory.truncate()
})
it('Shows all task details', () => {
const tasks = TaskFactory.create(1, {
id: 1,
index: 1,
description: 'Lorem ipsum dolor sit amet.',
})
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view h1.title.input')
.should('contain', tasks[0].title)
cy.get('.task-view h1.title.task-id')
.should('contain', '#1')
cy.get('.task-view h6.subtitle')
.should('contain', projects[0].title)
cy.get('.task-view .details.content.description')
.should('contain', tasks[0].description)
cy.get('.task-view .action-buttons p.created')
.should('contain', 'Created')
})
it('Shows a done label for done tasks', () => {
const tasks = TaskFactory.create(1, {
id: 1,
index: 1,
done: true,
done_at: new Date().toISOString(),
})
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view .heading .is-done')
.should('be.visible')
.should('contain', 'Done')
cy.get('.task-view .action-buttons p.created')
.scrollIntoView()
.should('be.visible')
.should('contain', 'Done')
})
it('Can mark a task as done', () => {
const tasks = TaskFactory.create(1, {
id: 1,
done: false,
})
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view .action-buttons .button')
.contains('Mark task done!')
.click()
cy.get('.task-view .heading .is-done')
.should('exist')
.should('contain', 'Done')
cy.get('.global-notification')
.should('contain', 'Success')
cy.get('.task-view .action-buttons .button')
.should('contain', 'Mark as undone')
})
it('Shows a task identifier since the project has one', () => {
const projects = ProjectFactory.create(1, {
id: 1,
identifier: 'TEST',
})
const tasks = TaskFactory.create(1, {
id: 1,
project_id: projects[0].id,
index: 1,
})
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view h1.title.task-id')
.should('contain', `${projects[0].identifier}-${tasks[0].index}`)
})
it('Can edit the description', () => {
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 button.done-edit')
.click()
cy.get('.task-view .details.content.description .tiptap__editor .tiptap.ProseMirror')
.type('{selectall}New Description')
cy.get('[data-cy="saveEditor"]')
.contains('Save')
.click()
cy.get('.task-view .details.content.description h3 span.is-small.has-text-success')
.contains('Saved!')
.should('exist')
})
it('Shows an empty editor when the description of a task is empty', () => {
const tasks = TaskFactory.create(1, {
id: 1,
description: '',
})
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view .details.content.description .tiptap.ProseMirror p')
.should('have.attr', 'data-placeholder')
cy.get('.task-view .details.content.description .tiptap button.done-edit')
.should('not.exist')
})
it('Shows a preview editor when the description of a task is not empty', () => {
const tasks = TaskFactory.create(1, {
id: 1,
description: 'Lorem Ipsum dolor sit amet',
})
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view .details.content.description .tiptap.ProseMirror p')
.should('not.have.attr', 'data-placeholder')
cy.get('.task-view .details.content.description .tiptap button.done-edit')
.should('exist')
})
it('Shows a preview editor when the description of a task contains html', () => {
const tasks = TaskFactory.create(1, {
id: 1,
description: '<p>Lorem Ipsum dolor sit amet</p>',
})
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view .details.content.description .tiptap.ProseMirror p')
.should('not.have.attr', 'data-placeholder')
cy.get('.task-view .details.content.description .tiptap button.done-edit')
.should('exist')
})
it('Can add a new comment', () => {
const tasks = TaskFactory.create(1, {
id: 1,
})
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view .comments .media.comment .tiptap__editor .tiptap.ProseMirror')
.should('be.visible')
.type('{selectall}New Comment')
cy.get('.task-view .comments .media.comment .button:not([disabled])')
.contains('Comment')
.should('be.visible')
.click()
cy.get('.task-view .comments .media.comment .tiptap__editor')
.should('contain', 'New Comment')
cy.get('.global-notification')
.should('contain', 'Success')
})
it('Can move a task to another project', () => {
const projects = ProjectFactory.create(2)
BucketFactory.create(2, {
project_id: '{increment}',
})
const tasks = TaskFactory.create(1, {
id: 1,
project_id: projects[0].id,
})
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view .action-buttons .button')
.contains('Move')
.click()
cy.get('.task-view .content.details .field .multiselect.control .input-wrapper input')
.type(`${projects[1].title}{enter}`)
// The requests happen with a 200ms timeout. Because of that, the results are not yet there when cypress
// presses enter and we can't simulate pressing on enter to select the item.
cy.get('.task-view .content.details .field .multiselect.control .search-results')
.children()
.first()
.click()
cy.get('.task-view h6.subtitle')
.should('contain', projects[1].title)
cy.get('.global-notification')
.should('contain', 'Success')
})
it('Can delete a task', () => {
const tasks = TaskFactory.create(1, {
id: 1,
project_id: 1,
})
cy.visit(`/tasks/${tasks[0].id}`)
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.url()
.should('contain', `/projects/${tasks[0].project_id}/`)
})
it('Can add an assignee to a task', () => {
const users = UserFactory.create(5)
const tasks = TaskFactory.create(1, {
id: 1,
project_id: 1,
})
UserProjectFactory.create(5, {
project_id: 1,
user_id: '{increment}',
})
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('[data-cy="taskDetail.assign"]')
.click()
cy.get('.task-view .column.assignees .multiselect input')
.type(users[1].username)
cy.get('.task-view .column.assignees .multiselect .search-results')
.children()
.first()
.click()
cy.get('.global-notification')
.should('contain', 'Success')
cy.get('.task-view .column.assignees .multiselect .input-wrapper span.assignee')
.should('exist')
})
it('Can remove an assignee from a task', () => {
const users = UserFactory.create(2)
const tasks = TaskFactory.create(1, {
id: 1,
project_id: 1,
})
UserProjectFactory.create(5, {
project_id: 1,
user_id: '{increment}',
})
TaskAssigneeFactory.create(1, {
task_id: tasks[0].id,
user_id: users[1].id,
})
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view .column.assignees .multiselect .input-wrapper span.assignee')
.get('.remove-assignee')
.click()
cy.get('.global-notification')
.should('contain', 'Success')
cy.get('.task-view .column.assignees .multiselect .input-wrapper span.assignee')
.should('not.exist')
})
it('Can add a new label to a task', () => {
const tasks = TaskFactory.create(1, {
id: 1,
project_id: 1,
})
LabelFactory.truncate()
const newLabelText = 'some new label'
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view .action-buttons .button')
.contains('Add Labels')
.should('be.visible')
.click()
cy.get('.task-view .details.labels-list .multiselect input')
.type(newLabelText)
cy.get('.task-view .details.labels-list .multiselect .search-results')
.children()
.first()
.click()
cy.get('.global-notification')
.should('contain', 'Success')
cy.get('.task-view .details.labels-list .multiselect .input-wrapper span.tag')
.should('exist')
.should('contain', newLabelText)
})
it('Can add an existing label to a task', () => {
const tasks = TaskFactory.create(1, {
id: 1,
project_id: 1,
})
const labels = LabelFactory.create(1)
LabelTaskFactory.truncate()
cy.visit(`/tasks/${tasks[0].id}`)
addLabelToTaskAndVerify(labels[0].title)
})
it('Can add a label to a task and it shows up on the kanban board afterwards', () => {
const tasks = TaskFactory.create(1, {
id: 1,
project_id: projects[0].id,
bucket_id: buckets[0].id,
})
const labels = LabelFactory.create(1)
LabelTaskFactory.truncate()
cy.visit(`/projects/${projects[0].id}/kanban`)
cy.get('.bucket .task')
.contains(tasks[0].title)
.click()
addLabelToTaskAndVerify(labels[0].title)
cy.get('.modal-content .close')
.click()
cy.get('.bucket .task')
.should('contain.text', labels[0].title)
})
it('Can remove a label from a task', () => {
const tasks = TaskFactory.create(1, {
id: 1,
project_id: 1,
})
const labels = LabelFactory.create(1)
LabelTaskFactory.create(1, {
task_id: tasks[0].id,
label_id: labels[0].id,
})
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view .details.labels-list .multiselect .input-wrapper')
.should('be.visible')
.should('contain', labels[0].title)
cy.get('.task-view .details.labels-list .multiselect .input-wrapper')
.children()
.first()
.get('[data-cy="taskDetail.removeLabel"]')
.click()
cy.get('.global-notification')
.should('contain', 'Success')
cy.get('.task-view .details.labels-list .multiselect .input-wrapper')
.should('not.contain', labels[0].title)
})
it('Can set a due 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 .datepicker-popup button')
.contains('Tomorrow')
.click()
cy.get('[data-cy="closeDatepicker"]')
.contains('Confirm')
.click()
cy.get('.task-view .columns.details .column')
.contains('Due Date')
.get('.date-input .datepicker-popup')
.should('not.exist')
cy.get('.global-notification')
.should('contain', 'Success')
})
it('Can set a due date to a specific date for a task', () => {
const tasks = TaskFactory.create(1, {
id: 1,
done: false,
})
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view .action-buttons .button')
.contains('Set Due Date')
.click()
cy.get('.task-view .columns.details .column')
.contains('Due Date')
.get('.date-input .datepicker .show')
.click()
cy.get('.datepicker-popup .flatpickr-innerContainer .flatpickr-days .flatpickr-day.today')
.click()
cy.get('[data-cy="closeDatepicker"]')
.contains('Confirm')
.click()
const today = new Date()
const day = today.toLocaleString('default', {day: 'numeric'})
const month = today.toLocaleString('default', {month: 'short'})
const year = today.toLocaleString('default', {year: 'numeric'})
const date = `${day} ${month} ${year}, 12:00:00`
cy.get('.task-view .columns.details .column')
.contains('Due Date')
.get('.date-input .datepicker-popup')
.should('not.exist')
cy.get('.task-view .columns.details .column')
.contains('Due Date')
.get('.date-input')
.should('contain.text', date)
cy.get('.global-notification')
.should('contain', 'Success')
})
it('Can change a due date to a specific date for a task', () => {
const dueDate = new Date()
dueDate.setHours(12)
dueDate.setMinutes(0)
dueDate.setSeconds(0)
dueDate.setDate(1)
const tasks = TaskFactory.create(1, {
id: 1,
done: false,
due_date: dueDate.toISOString(),
})
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view .action-buttons .button')
.contains('Set Due Date')
.click()
cy.get('.task-view .columns.details .column')
.contains('Due Date')
.get('.date-input .datepicker .show')
.click()
cy.get('.datepicker-popup .flatpickr-innerContainer .flatpickr-days .flatpickr-day.today')
.click()
cy.get('[data-cy="closeDatepicker"]')
.contains('Confirm')
.click()
const today = new Date()
const day = today.toLocaleString('default', {day: 'numeric'})
const month = today.toLocaleString('default', {month: 'short'})
const year = today.toLocaleString('default', {year: 'numeric'})
const date = `${day} ${month} ${year}, 12:00:00`
cy.get('.task-view .columns.details .column')
.contains('Due Date')
.get('.date-input .datepicker-popup')
.should('not.exist')
cy.get('.task-view .columns.details .column')
.contains('Due Date')
.get('.date-input')
.should('contain.text', date)
cy.get('.global-notification')
.should('contain', 'Success')
})
it('Can set a reminder', () => {
TaskReminderFactory.truncate()
const tasks = TaskFactory.create(1, {
id: 1,
done: false,
})
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view .action-buttons .button')
.contains('Set Reminders')
.click()
cy.get('.task-view .columns.details .column button')
.contains('Add a new reminder')
.click()
cy.get('.datepicker__quick-select-date')
.contains('Tomorrow')
.click()
cy.get('.reminder-options-popup')
.should('not.be.visible')
cy.get('.global-notification')
.should('contain', 'Success')
})
it('Allows to set a relative reminder when the task already has a due date', () => {
TaskReminderFactory.truncate()
const tasks = TaskFactory.create(1, {
id: 1,
done: false,
due_date: (new Date()).toISOString(),
})
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view .action-buttons .button')
.contains('Set Reminders')
.click()
cy.get('.task-view .columns.details .column button')
.contains('Add a new reminder')
.click()
cy.get('.datepicker__quick-select-date')
.should('not.exist')
cy.get('.reminder-options-popup .card-content')
.should('contain', '1 day before Due Date')
cy.get('.reminder-options-popup .card-content')
.contains('1 day before Due Date')
.click()
cy.get('.reminder-options-popup')
.should('not.be.visible')
cy.get('.global-notification')
.should('contain', 'Success')
})
it('Allows to set a relative reminder when the task already has a start date', () => {
TaskReminderFactory.truncate()
const tasks = TaskFactory.create(1, {
id: 1,
done: false,
start_date: (new Date()).toISOString(),
})
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view .action-buttons .button')
.contains('Set Reminders')
.click()
cy.get('.task-view .columns.details .column button')
.contains('Add a new reminder')
.click()
cy.get('.datepicker__quick-select-date')
.should('not.exist')
cy.get('.reminder-options-popup .card-content')
.should('contain', '1 day before Start Date')
cy.get('.reminder-options-popup .card-content')
.contains('1 day before Start Date')
.click()
cy.get('.reminder-options-popup')
.should('not.be.visible')
cy.get('.global-notification')
.should('contain', 'Success')
})
it('Allows to set a custom relative reminder when the task already has a due date', () => {
TaskReminderFactory.truncate()
const tasks = TaskFactory.create(1, {
id: 1,
done: false,
due_date: (new Date()).toISOString(),
})
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view .action-buttons .button')
.contains('Set Reminders')
.click()
cy.get('.task-view .columns.details .column button')
.contains('Add a new reminder')
.click()
cy.get('.datepicker__quick-select-date')
.should('not.exist')
cy.get('.reminder-options-popup .card-content')
.contains('Custom')
.click()
cy.get('.reminder-options-popup .card-content .reminder-period input')
.first()
.type('{selectall}10')
cy.get('.reminder-options-popup .card-content .reminder-period select')
.first()
.select('days')
cy.get('.reminder-options-popup .card-content button')
.contains('Confirm')
.click()
cy.get('.reminder-options-popup')
.should('not.be.visible')
cy.get('.global-notification')
.should('contain', 'Success')
})
it('Allows to set a fixed reminder when the task already has a due date', () => {
TaskReminderFactory.truncate()
const tasks = TaskFactory.create(1, {
id: 1,
done: false,
due_date: (new Date()).toISOString(),
})
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view .action-buttons .button')
.contains('Set Reminders')
.click()
cy.get('.task-view .columns.details .column button')
.contains('Add a new reminder')
.click()
cy.get('.datepicker__quick-select-date')
.should('not.exist')
cy.get('.reminder-options-popup .card-content')
.contains('Date and time')
.click()
cy.get('.datepicker__quick-select-date')
.contains('Tomorrow')
.click()
cy.get('.reminder-options-popup')
.should('not.be.visible')
cy.get('.global-notification')
.should('contain', 'Success')
})
it('Can set a priority for a task', () => {
const tasks = TaskFactory.create(1, {
id: 1,
})
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view .action-buttons .button')
.contains('Set Priority')
.click()
cy.get('.task-view .columns.details .column')
.contains('Priority')
.get('.select select')
.select('Urgent')
cy.get('.global-notification')
.should('contain', 'Success')
cy.get('.task-view .columns.details .column')
.contains('Priority')
.get('.select select')
.should('have.value', '4')
})
it('Can set the progress for a task', () => {
const tasks = TaskFactory.create(1, {
id: 1,
})
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view .action-buttons .button')
.contains('Set Progress')
.click()
cy.get('.task-view .columns.details .column')
.contains('Progress')
.get('.select select')
.select('50%')
cy.get('.global-notification')
.should('contain', 'Success')
cy.wait(200)
cy.get('.task-view .columns.details .column')
.contains('Progress')
.get('.select select')
.should('be.visible')
.should('have.value', '0.5')
})
it('Can add an attachment to a task', () => {
TaskAttachmentFactory.truncate()
const tasks = TaskFactory.create(1, {
id: 1,
})
cy.visit(`/tasks/${tasks[0].id}`)
uploadAttachmentAndVerify(tasks[0].id)
})
it('Can add an attachment to a task and see it appearing on kanban', () => {
TaskAttachmentFactory.truncate()
const tasks = TaskFactory.create(1, {
id: 1,
project_id: projects[0].id,
bucket_id: buckets[0].id,
})
const labels = LabelFactory.create(1)
LabelTaskFactory.truncate()
cy.visit(`/projects/${projects[0].id}/kanban`)
cy.get('.bucket .task')
.contains(tasks[0].title)
.click()
uploadAttachmentAndVerify(tasks[0].id)
cy.get('.modal-content .close')
.click()
cy.get('.bucket .task .footer .icon svg.fa-paperclip')
.should('exist')
})
it('Can check items off a checklist', () => {
const tasks = TaskFactory.create(1, {
id: 1,
description: `
<ul data-type="taskList">
<li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label>
<div><p>First Item</p></div>
</li>
<li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label>
<div><p>Second Item</p></div>
</li>
<li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label>
<div><p>Third Item</p></div>
</li>
<li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label>
<div><p>Fourth Item</p></div>
</li>
<li data-checked="true" data-type="taskItem"><label><input type="checkbox"><span></span></label>
<div><p>Fifth Item</p></div>
</li>
</ul>`,
})
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view .checklist-summary')
.should('contain.text', '1 of 5 tasks')
cy.get('.tiptap__editor ul > li input[type=checkbox]')
.eq(2)
.click()
cy.get('.task-view .details.content.description h3 span.is-small.has-text-success')
.contains('Saved!')
.should('exist')
cy.get('.tiptap__editor ul > li input[type=checkbox]')
.eq(2)
.should('be.checked')
cy.get('.tiptap__editor input[type=checkbox]')
.should('have.length', 5)
cy.get('.task-view .checklist-summary')
.should('contain.text', '2 of 5 tasks')
})
it('Should use the editor to render description', () => {
const tasks = TaskFactory.create(1, {
id: 1,
description: `
<h1>Lorem Ipsum</h1>
<p>Dolor sit amet</p>
<ul data-type="taskList">
<li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label>
<div><p>First Item</p></div>
</li>
<li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label>
<div><p>Second Item</p></div>
</li>
</ul>`,
})
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.tiptap__editor ul > li input[type=checkbox]')
.should('exist')
cy.get('.tiptap__editor h1')
.contains('Lorem Ipsum')
.should('exist')
cy.get('.tiptap__editor p')
.contains('Dolor sit amet')
.should('exist')
})
it('Should render an image from attachment', async () => {
TaskAttachmentFactory.truncate()
const tasks = TaskFactory.create(1, {
id: 1,
description: '',
})
cy.readFile('cypress/fixtures/image.jpg', null).then(file => {
const formData = new FormData()
formData.append('files', new Blob([file]), 'image.jpg')
cy.request({
method: 'PUT',
url: `${Cypress.env('API_URL')}/tasks/${tasks[0].id}/attachments`,
headers: {
'Authorization': `Bearer ${window.localStorage.getItem('token')}`,
'Content-Type': 'multipart/form-data',
},
body: formData,
})
.then(({body}) => {
const dec = new TextDecoder('utf-8')
const {success} = JSON.parse(dec.decode(body))
TaskFactory.create(1, {
id: 1,
description: `<img src="${Cypress.env('API_URL')}/tasks/${tasks[0].id}/attachments/${success[0].id}" alt="test image">`,
})
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.tiptap__editor img')
.should('be.visible')
.and(($img) => {
// "naturalWidth" and "naturalHeight" are set when the image loads
expect($img[0].naturalWidth).to.be.greaterThan(0)
})
})
})
})
})
})

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,74 +0,0 @@
import {UserFactory} from '../../factories/user'
import {ProjectFactory} from '../../factories/project'
const testAndAssertFailed = fixture => {
cy.intercept(Cypress.env('API_URL') + '/login*').as('login')
cy.visit('/login')
cy.get('input[id=username]').type(fixture.username)
cy.get('input[id=password]').type(fixture.password)
cy.get('.button').contains('Login').click()
cy.wait('@login')
cy.url().should('include', '/')
cy.get('div.message.danger').contains('Wrong username or password.')
}
const credentials = {
username: 'test',
password: '1234',
}
function login() {
cy.get('input[id=username]').type(credentials.username)
cy.get('input[id=password]').type(credentials.password)
cy.get('.button').contains('Login').click()
cy.url().should('include', '/')
}
context('Login', () => {
beforeEach(() => {
UserFactory.create(1, {username: credentials.username})
})
it('Should log in with the right credentials', () => {
cy.visit('/login')
login()
cy.clock(1625656161057) // 13:00
cy.get('h2').should('contain', `Hi ${credentials.username}!`)
})
it('Should fail with a bad password', () => {
const fixture = {
username: 'test',
password: '123456',
}
testAndAssertFailed(fixture)
})
it('Should fail with a bad username', () => {
const fixture = {
username: 'loremipsum',
password: '1234',
}
testAndAssertFailed(fixture)
})
it('Should redirect to /login when no user is logged in', () => {
cy.visit('/')
cy.url().should('include', '/login')
})
it('Should redirect to the previous route after logging in', () => {
const projects = ProjectFactory.create(1)
cy.visit(`/projects/${projects[0].id}/list`)
cy.url().should('include', '/login')
login()
cy.url().should('include', `/projects/${projects[0].id}/list`)
})
})

View File

@ -1,46 +0,0 @@
import {createFakeUserAndLogin} from '../../support/authenticateUser'
import {createProjects} from '../project/prepareProjects'
function logout() {
cy.get('.navbar .username-dropdown-trigger')
.click()
cy.get('.navbar .dropdown-item')
.contains('Logout')
.click()
}
describe('Log out', () => {
createFakeUserAndLogin()
it('Logs the user out', () => {
cy.visit('/')
expect(localStorage.getItem('token')).to.not.eq(null)
logout()
cy.url()
.should('contain', '/login')
.then(() => {
expect(localStorage.getItem('token')).to.eq(null)
})
})
it.skip('Should clear the project history after logging the user out', () => {
const projects = createProjects()
cy.visit(`/projects/${projects[0].id}`)
.then(() => {
expect(localStorage.getItem('projectHistory')).to.not.eq(null)
})
logout()
cy.wait(1000) // This makes re-loading of the project and associated entities (and the resulting error) visible
cy.url()
.should('contain', '/login')
.then(() => {
expect(localStorage.getItem('projectHistory')).to.eq(null)
})
})
})

View File

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

View File

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

View File

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

View File

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

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

@ -0,0 +1,20 @@
import {Factory} from '../support/factory'
import {formatISO} from "date-fns"
import faker from '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

@ -0,0 +1,19 @@
import faker from 'faker'
import {Factory} from '../support/factory'
import {formatISO} from 'date-fns'
export class NamespaceFactory extends Factory {
static table = 'namespaces'
static factory() {
const now = new Date()
return {
id: '{increment}',
title: faker.lorem.words(3),
owner_id: 1,
created: formatISO(now),
updated: formatISO(now)
}
}
}

View File

@ -1,18 +0,0 @@
import {Factory} from '../support/factory'
import {faker} from '@faker-js/faker'
export class ProjectFactory extends Factory {
static table = 'projects'
static factory() {
const now = new Date()
return {
id: '{increment}',
title: faker.lorem.words(3),
owner_id: 1,
created: now.toISOString(),
updated: now.toISOString(),
}
}
}

View File

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

View File

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

View File

@ -1,16 +0,0 @@
import {Factory} from '../support/factory'
export class TaskAttachmentFactory extends Factory {
static table = 'task_attachments'
static factory() {
const now = new Date()
return {
id: '{increment}',
task_id: 1,
file_id: 1,
created: now.toISOString(),
}
}
}

View File

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

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

View File

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

View File

@ -1,6 +1,7 @@
import {faker} from '@faker-js/faker'
import faker from 'faker'
import {Factory} from '../support/factory'
import {formatISO} from "date-fns"
export class UserFactory extends Factory {
static table = 'users'
@ -13,9 +14,8 @@ export class UserFactory extends Factory {
username: faker.lorem.word(10) + faker.datatype.uuid(),
password: '$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.', // 1234
status: 0,
issuer: 'local',
created: now.toISOString(),
updated: now.toISOString(),
created: formatISO(now),
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

@ -0,0 +1,540 @@
import {formatISO, format} from 'date-fns'
import {TaskFactory} from '../../factories/task'
import {ListFactory} from '../../factories/list'
import {UserListFactory} from '../../factories/users_list'
import {UserFactory} from '../../factories/user'
import {NamespaceFactory} from '../../factories/namespace'
import {BucketFactory} from '../../factories/bucket'
import '../../support/authenticateUser'
describe('Lists', () => {
let lists
beforeEach(() => {
UserFactory.create(1)
NamespaceFactory.create(1)
lists = ListFactory.create(1, {
title: 'First List'
})
TaskFactory.truncate()
})
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', '/namespaces/1/list')
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', () => {
const tasks = 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 .more-container .menu-list li:first-child .dropdown .dropdown-trigger')
.click()
cy.get('.namespace-container .menu.namespaces-lists .more-container .menu-list li:first-child .dropdown .dropdown-content')
.contains('Edit')
.click()
cy.get('#title')
.type(`{selectall}${newListName}`)
cy.get('footer.modal-card-foot .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 .more-container .menu-list li:first-child')
.should('contain', newListName)
.should('not.contain', lists[0].title)
cy.visit('/')
cy.get('.card-content .tasks')
.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 .more-container .menu-list li:first-child .dropdown .dropdown-trigger')
.click()
cy.get('.namespace-container .menu.namespaces-lists .more-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('.namespace-container .menu.namespaces-lists .more-container .menu-list')
.should('not.contain', lists[0].title)
cy.location('pathname')
.should('equal', '/')
})
describe('List View', () => {
it('Should be an empty list', () => {
cy.visit('/lists/1')
cy.url()
.should('contain', '/lists/1/list')
cy.get('.list-title h1')
.should('contain', 'First List')
cy.get('.list-title .dropdown')
.should('exist')
cy.get('p')
.contains('This list is currently empty.')
.should('exist')
})
it('Should navigate to the task when the title is clicked', () => {
const tasks = TaskFactory.create(5, {
id: '{increment}',
list_id: 1,
})
cy.visit('/lists/1/list')
cy.get('.tasks .task .tasktext')
.contains(tasks[0].title)
.first()
.click()
cy.url()
.should('contain', `/tasks/${tasks[0].id}`)
})
it('Should not see any elements for a list which is shared read only', () => {
UserFactory.create(2)
UserListFactory.create(1, {
list_id: 2,
user_id: 1,
right: 0,
})
const lists = ListFactory.create(2, {
owner_id: '{increment}',
namespace_id: '{increment}',
})
cy.visit(`/lists/${lists[1].id}/`)
cy.get('.list-title a.icon')
.should('not.exist')
cy.get('input.input[placeholder="Add a new task..."')
.should('not.exist')
})
it('Should only show the color of a list in the navigation and not in the list view', () => {
const lists = ListFactory.create(1, {
hex_color: '00db60',
})
TaskFactory.create(10, {
list_id: lists[0].id,
})
cy.visit(`/lists/${lists[0].id}/`)
cy.get('.menu-list li .list-menu-link .color-bubble')
.should('have.css', 'background-color', 'rgb(0, 219, 96)')
cy.get('.tasks-container .tasks .color-bubble')
.should('not.exist')
})
it('Should paginate for > 50 tasks', () => {
const tasks = TaskFactory.create(100, {
id: '{increment}',
title: i => `task${i}`,
list_id: 1,
})
cy.visit('/lists/1/list')
cy.get('.tasks-container .tasks')
.should('contain', tasks[99].title)
cy.get('.card-content .pagination .pagination-link')
.contains('2')
.click()
cy.url()
.should('contain', '?page=2')
cy.get('.tasks-container .tasks')
.should('contain', tasks[1].title)
cy.get('.tasks-container .tasks')
.should('not.contain', tasks[99].title)
})
})
describe('Table View', () => {
it('Should show a table with tasks', () => {
const tasks = TaskFactory.create(1)
cy.visit('/lists/1/table')
cy.get('.table-view table.table')
.should('exist')
cy.get('.table-view table.table')
.should('contain', tasks[0].title)
})
it('Should have working column switches', () => {
TaskFactory.create(1)
cy.visit('/lists/1/table')
cy.get('.table-view .filter-container .items .button')
.contains('Columns')
.click()
cy.get('.table-view .filter-container .card.columns-filter .card-content .fancycheckbox .check')
.contains('Priority')
.click()
cy.get('.table-view .filter-container .card.columns-filter .card-content .fancycheckbox .check')
.contains('Done')
.click()
cy.get('.table-view table.table th')
.contains('Priority')
.should('exist')
cy.get('.table-view table.table th')
.contains('Done')
.should('not.exist')
})
it('Should navigate to the task when the title is clicked', () => {
const tasks = TaskFactory.create(5, {
id: '{increment}',
list_id: 1,
})
cy.visit('/lists/1/table')
cy.get('.table-view table.table')
.contains(tasks[0].title)
.click()
cy.url()
.should('contain', `/tasks/${tasks[0].id}`)
})
})
describe('Gantt View', () => {
it('Hides tasks with no dates', () => {
const tasks = TaskFactory.create(1)
cy.visit('/lists/1/gantt')
cy.get('.gantt-chart-container .gantt-chart .tasks')
.should('not.contain', tasks[0].title)
})
it('Shows tasks from the current and next month', () => {
const now = new Date()
const nextMonth = now
nextMonth.setDate(1)
nextMonth.setMonth(now.getMonth() + 1)
cy.visit('/lists/1/gantt')
cy.get('.gantt-chart-container .gantt-chart .months')
.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('.gantt-chart-container .gantt-chart .tasks')
.should('not.be.empty')
cy.get('.gantt-chart-container .gantt-chart .tasks')
.should('contain', tasks[0].title)
})
it('Shows tasks with no dates after enabling them', () => {
TaskFactory.create(1, {
start_date: null,
end_date: null,
})
cy.visit('/lists/1/gantt')
cy.get('.gantt-chart-container .gantt-options .fancycheckbox')
.contains('Show tasks which don\'t have dates set')
.click()
cy.get('.gantt-chart-container .gantt-chart .tasks')
.should('not.be.empty')
cy.get('.gantt-chart-container .gantt-chart .tasks .task.nodate')
.should('exist')
})
it('Drags a task around', () => {
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('.gantt-chart-container .gantt-chart .tasks .task')
.first()
.trigger('mousedown', {which: 1})
.trigger('mousemove', {clientX: 500, clientY: 0})
.trigger('mouseup', {force: true})
})
})
describe('Kanban', () => {
let buckets
beforeEach(() => {
buckets = BucketFactory.create(2)
})
it('Shows all buckets with their tasks', () => {
const data = TaskFactory.create(10, {
list_id: 1,
bucket_id: 1,
})
cy.visit('/lists/1/kanban')
cy.get('.kanban .bucket .title')
.contains(buckets[0].title)
.should('exist')
cy.get('.kanban .bucket .title')
.contains(buckets[1].title)
.should('exist')
cy.get('.kanban .bucket')
.first()
.should('contain', data[0].title)
})
it('Can add a new task to a bucket', () => {
const data = TaskFactory.create(2, {
list_id: 1,
bucket_id: 1,
})
cy.visit('/lists/1/kanban')
cy.get('.kanban .bucket')
.contains(buckets[0].title)
.get('.bucket-footer .button')
.contains('Add another task')
.click()
cy.get('.kanban .bucket')
.contains(buckets[0].title)
.get('.bucket-footer .field .control input.input')
.type('New Task{enter}')
cy.get('.kanban .bucket')
.first()
.should('contain', 'New Task')
})
it('Can create a new bucket', () => {
cy.visit('/lists/1/kanban')
cy.get('.kanban .bucket.new-bucket .button')
.click()
cy.get('.kanban .bucket.new-bucket input.input')
.type('New Bucket{enter}')
cy.wait(1000) // Wait for the request to finish
cy.get('.kanban .bucket .title')
.contains('New Bucket')
.should('exist')
})
it('Can set a bucket limit', () => {
cy.visit('/lists/1/kanban')
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-trigger')
.first()
.click()
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-menu .dropdown-item')
.contains('Limit: Not Set')
.click()
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-menu .dropdown-item .field input.input')
.first()
.type(3)
cy.get('[data-cy="setBucketLimit"]')
.first()
.click()
cy.get('.kanban .bucket .bucket-header span.limit')
.contains('0/3')
.should('exist')
})
it('Can rename a bucket', () => {
cy.visit('/lists/1/kanban')
cy.get('.kanban .bucket .bucket-header .title')
.first()
.type('{selectall}New Bucket Title{enter}')
cy.get('.kanban .bucket .bucket-header .title')
.first()
.should('contain', 'New Bucket Title')
})
it('Can delete a bucket', () => {
cy.visit('/lists/1/kanban')
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-trigger')
.first()
.click()
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-menu .dropdown-item')
.contains('Delete')
.click()
cy.get('.modal-mask .modal-container .modal-content .header')
.should('contain', 'Delete the bucket')
cy.get('.modal-mask .modal-container .modal-content .actions .button')
.contains('Do it!')
.click()
cy.get('.kanban .bucket .title')
.contains(buckets[0].title)
.should('not.exist')
cy.get('.kanban .bucket .title')
.contains(buckets[1].title)
.should('exist')
})
it('Can drag tasks around', () => {
const tasks = TaskFactory.create(2, {
list_id: 1,
bucket_id: 1,
})
cy.visit('/lists/1/kanban')
cy.get('.kanban .bucket .tasks .task')
.contains(tasks[0].title)
.first()
.drag('.kanban .bucket:nth-child(2) .tasks .dropper')
cy.get('.kanban .bucket:nth-child(2) .tasks')
.should('contain', tasks[0].title)
cy.get('.kanban .bucket:nth-child(1) .tasks')
.should('not.contain', tasks[0].title)
})
it('Should navigate to the task when the task card is clicked', () => {
const tasks = TaskFactory.create(5, {
id: '{increment}',
list_id: 1,
bucket_id: 1,
})
cy.visit('/lists/1/kanban')
cy.getSettled('.kanban .bucket .tasks .task')
.contains(tasks[0].title)
.should('be.visible')
.click()
cy.url()
.should('contain', `/tasks/${tasks[0].id}`)
})
it('Should remove a task from the kanban board when moving it to another list', () => {
const lists = ListFactory.create(2)
BucketFactory.create(2, {
list_id: '{increment}',
})
const tasks = TaskFactory.create(5, {
id: '{increment}',
list_id: 1,
bucket_id: 1,
})
const task = tasks[0]
cy.visit('/lists/1/kanban')
cy.getSettled('.kanban .bucket .tasks .task')
.contains(task.title)
.should('be.visible')
.click()
cy.get('.task-view .action-buttons .button')
.contains('Move task')
.click()
cy.get('.task-view .content.details .field .multiselect.control .input-wrapper input')
.type(`${lists[1].title}{enter}`)
// The requests happen with a 200ms timeout. Because of that, the results are not yet there when cypress
// presses enter and we can't simulate pressing on enter to select the item.
cy.get('.task-view .content.details .field .multiselect.control .search-results')
.children()
.first()
.click()
cy.get('.global-notification', { timeout: 1000 })
.should('contain', 'Success')
cy.go('back')
cy.get('.kanban .bucket')
.should('not.contain', task.title)
})
})
describe('List history', () => {
it('should show a list history on the home page', () => {
const lists = ListFactory.create(6)
cy.visit('/')
cy.get('h3')
.contains('Last viewed')
.should('not.exist')
cy.visit(`/lists/${lists[0].id}`)
cy.visit(`/lists/${lists[1].id}`)
cy.visit(`/lists/${lists[2].id}`)
cy.visit(`/lists/${lists[3].id}`)
cy.visit(`/lists/${lists[4].id}`)
cy.visit(`/lists/${lists[5].id}`)
cy.visit('/')
cy.get('h3')
.contains('Last viewed')
.should('exist')
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,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.modal-card-foot .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,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')
})
})

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