Compare commits

...

70 Commits

Author SHA1 Message Date
Dominik Pschenitschni fd1d01164f feature/load-views-async (#2672)
Co-authored-by: Dominik Pschenitschni <mail@celement.de>
Reviewed-on: vikunja/frontend#2672
Co-authored-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
Co-committed-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
2022-11-13 21:52:28 +00:00
renovate 34edf0dc5f chore(deps): update dependency @vue/test-utils to v2.2.2 (#2696)
Reviewed-on: vikunja/frontend#2696
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2022-11-13 10:17:32 +00:00
Dominik Pschenitschni 4c4adfdf4e fix: reactive const assignment (#2692)
Resolves #2691

Co-authored-by: Dominik Pschenitschni <mail@celement.de>
Reviewed-on: vikunja/frontend#2692
Reviewed-by: konrad <k@knt.li>
Co-authored-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
Co-committed-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
2022-11-12 16:14:32 +00:00
Dominik Pschenitschni 06775cf4c7 fix: use scss for datemathHelp (#2690)
Co-authored-by: Dominik Pschenitschni <mail@celement.de>
Reviewed-on: vikunja/frontend#2690
Co-authored-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
Co-committed-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
2022-11-12 14:38:31 +00:00
kolaente c07954f2b8
feat(ci): use docker buildx for multiarch builds 2022-11-12 14:43:29 +01:00
kolaente 995cc12880
fix(tasks): remove a task from its bucket when it is in the first kanban bucket
Resolves https://github.com/go-vikunja/frontend/issues/89
2022-11-12 12:13:00 +01:00
Dominik Pschenitschni 293402b6fd fix: move heading styles to component (#2686)
Co-authored-by: Dominik Pschenitschni <mail@celement.de>
Reviewed-on: vikunja/frontend#2686
Co-authored-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
Co-committed-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
2022-11-12 10:52:16 +00:00
Dominik Pschenitschni 708ef2d72e feat: improve user component (#2687)
Co-authored-by: Dominik Pschenitschni <mail@celement.de>
Reviewed-on: vikunja/frontend#2687
Co-authored-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
Co-committed-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
2022-11-12 10:51:35 +00:00
Dominik Pschenitschni 4c458a1ad0 fix: move createdUpdated styles to component (#2685)
Co-authored-by: Dominik Pschenitschni <mail@celement.de>
Reviewed-on: vikunja/frontend#2685
Co-authored-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
Co-committed-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
2022-11-12 10:50:48 +00:00
Dominik Pschenitschni 02de481297 feat: use img for logo so that it's not part of the main bundle (#2684)
Co-authored-by: Dominik Pschenitschni <mail@celement.de>
Reviewed-on: vikunja/frontend#2684
Co-authored-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
Co-committed-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
2022-11-12 10:48:52 +00:00
Dominik Pschenitschni 9d604f7a3b feat: reduce ready selector specificity (#2683)
Co-authored-by: Dominik Pschenitschni <mail@celement.de>
Reviewed-on: vikunja/frontend#2683
Co-authored-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
Co-committed-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
2022-11-12 10:48:15 +00:00
Dominik Pschenitschni 0f1f131f7a feat: reduce attachments selector specificity (#2682)
Co-authored-by: Dominik Pschenitschni <mail@celement.de>
Reviewed-on: vikunja/frontend#2682
Co-authored-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
Co-committed-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
2022-11-12 10:47:46 +00:00
Dominik Pschenitschni eb4c2a4b9d feat: reduce dropdown-item selector specificity (#2680)
Co-authored-by: Dominik Pschenitschni <mail@celement.de>
Reviewed-on: vikunja/frontend#2680
Co-authored-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
Co-committed-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
2022-11-12 10:46:39 +00:00
Dominik Pschenitschni 599c1ba4b5 feat: reduce ListWrapper selector specificity (#2679)
Co-authored-by: Dominik Pschenitschni <mail@celement.de>
Reviewed-on: vikunja/frontend#2679
Co-authored-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
Co-committed-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
2022-11-12 10:46:00 +00:00
Dominik Pschenitschni 12a8f7ebe9 feat: reduce contentAuth selector specifity (#2677)
Co-authored-by: Dominik Pschenitschni <mail@celement.de>
Reviewed-on: vikunja/frontend#2677
Co-authored-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
Co-committed-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
2022-11-12 10:45:24 +00:00
Dominik Pschenitschni 9f0f0b39f8 feat: reduce multiselect selector specificity (#2678)
Co-authored-by: Dominik Pschenitschni <mail@celement.de>
Reviewed-on: vikunja/frontend#2678
Co-authored-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
Co-committed-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
2022-11-12 10:44:49 +00:00
konrad 4a550da6a6 feat: filters script setup (#2671)
Reviewed-on: vikunja/frontend#2671
2022-11-12 10:43:24 +00:00
renovate 52ba168d41 chore(deps): update dependency @types/dompurify to v2.4.0 (#2688)
Reviewed-on: vikunja/frontend#2688
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2022-11-12 10:42:19 +00:00
renovate 9c7680aa55 chore(deps): update dependency rollup to v3.3.0 (#2689)
Reviewed-on: vikunja/frontend#2689
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2022-11-12 10:41:30 +00:00
Dominik Pschenitschni 83bb030c6e [skip ci] Updated translations via Crowdin 2022-11-12 00:12:21 +00:00
Dominik Pschenitschni 163d9366d3 feat: add vite build target esnext (#2674)
Co-authored-by: Dominik Pschenitschni <mail@celement.de>
Reviewed-on: vikunja/frontend#2674
Co-authored-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
Co-committed-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
2022-11-11 14:43:23 +00:00
kolaente 5cff9988a3
chore: 0.20.1 release preperations 2022-11-11 12:02:16 +01:00
renovate a15ace0dbc fix(deps): update dependency vue to v3.2.45 2022-11-11 10:04:28 +00:00
renovate 65bb514093 chore(deps): update dependency postcss to v8.4.19 (#2673)
Reviewed-on: vikunja/frontend#2673
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2022-11-11 07:59:45 +00:00
renovate 403f1ee400 fix(deps): update sentry-javascript monorepo to v7.19.0 (#2670)
Reviewed-on: vikunja/frontend#2670
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2022-11-10 16:17:18 +00:00
Dominik Pschenitschni bb58dba8e0
feat: move select filters to dedicated components 2022-11-10 17:11:56 +01:00
Dominik Pschenitschni 4bad685f39
feat: filters script setup 2022-11-10 17:11:56 +01:00
kolaente e5f631af8d
fix(tasks): show any errors happening during task load 2022-11-10 16:44:16 +01:00
renovate 925f2aa837 fix(deps): update dependency dompurify to v2.4.1 (#2669)
Reviewed-on: vikunja/frontend#2669
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2022-11-10 14:14:01 +00:00
renovate 1de22386da chore(deps): update dependency cypress to v11 (#2659)
Reviewed-on: vikunja/frontend#2659
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2022-11-10 13:52:24 +00:00
renovate 602af9ec96 chore(deps): update pnpm to v7.15.0 (#2667)
Reviewed-on: vikunja/frontend#2667
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2022-11-10 07:29:51 +00:00
renovate 57feb65e00 fix(deps): update dependency vue to v3.2.44 (#2666)
Reviewed-on: vikunja/frontend#2666
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2022-11-10 07:29:10 +00:00
konrad 94508173dc fix: gantt route sync (#2664)
Reviewed-on: vikunja/frontend#2664
2022-11-09 19:48:17 +00:00
kolaente bd0c4d0355
feat(tests): add tests for gantt chart task detail open 2022-11-09 20:16:30 +01:00
kolaente 2952a0155f
feat(tests): add tests for gantt chart time range 2022-11-09 20:10:18 +01:00
kolaente 6055fecc5d
fix(gantt): don't try to load list NaN when opening a task from the gantt chart 2022-11-09 19:54:53 +01:00
Dominik Pschenitschni 7ec2b6c0d2
fix: gantt route sync 2022-11-09 18:39:29 +01:00
renovate 13bd434cb9 fix(deps): update dependency vue to v3.2.43 (#2663)
Reviewed-on: vikunja/frontend#2663
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2022-11-09 14:18:57 +00:00
kolaente b98d9fb7ec
fix(table): sort tasks by index instead of id 2022-11-09 14:46:58 +01:00
kolaente c2dd18edaa
fix: lint & formatting 2022-11-09 14:27:26 +01:00
kolaente d47791b957
fix: too much recursion error when opening a task from the gantt chart
Resolves F-905
Resolves https://community.vikunja.io/t/gantt-view-showing-too-much-recursion-error/935
2022-11-09 14:05:13 +01:00
renovate e6eaac1b46
fix(deps): update dependency @fortawesome/vue-fontawesome to v3.0.2 2022-11-09 12:30:50 +01:00
renovate cf2103734b fix(deps): update dependency vue to v3.2.42 2022-11-09 11:05:14 +00:00
renovate bb9c5046b3 chore(deps): update dependency sass to v1.56.1 (#2661)
Reviewed-on: vikunja/frontend#2661
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2022-11-09 10:20:32 +00:00
renovate a7a6a4c2d6 fix(deps): update vueuse to v9.5.0 (#2660)
Reviewed-on: vikunja/frontend#2660
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2022-11-09 10:19:58 +00:00
renovate 28257fcf5f chore(deps): update dependency @cypress/vite-dev-server to v4.0.1 (#2658)
Reviewed-on: vikunja/frontend#2658
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2022-11-09 10:18:57 +00:00
renovate c314d56f73 chore(deps): update dependency vitest to v0.25.1 (#2657)
Reviewed-on: vikunja/frontend#2657
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2022-11-08 16:15:14 +00:00
kolaente 612e592da7
fix: sort task alphabetically
Resolves F-906
2022-11-08 16:16:22 +01:00
konrad 1a329464ab feat(ci): improve drone config (#2637)
Reviewed-on: vikunja/frontend#2637
2022-11-08 14:56:45 +00:00
kolaente d5efe9f656
chore(ci): sign drone config 2022-11-08 15:42:41 +01:00
Dominik Pschenitschni 4a5f1a783a
fix(ci): cache folder name 2022-11-08 15:42:37 +01:00
Dominik Pschenitschni 906b3a5cdf
feat(ci): update cypress image 2022-11-08 15:42:33 +01:00
Dominik Pschenitschni 678dc8ef51
feat(ci): add kind everywhere 2022-11-08 15:42:28 +01:00
Dominik Pschenitschni da1d5eaba1
feat(ci): use 'always' for pull 2022-11-08 15:42:13 +01:00
kolaente 02448700b3
fix(quick add magic): don't parse labels, assignees or lists as date expressions if they are called that
Resolves https://community.vikunja.io/t/setting-today-label-using-quick-add-magic/969
2022-11-08 15:35:13 +01:00
renovate d9ca798aad fix(deps): update sentry-javascript monorepo to v7.18.0 2022-11-08 10:05:54 +00:00
renovate 23668e55d7 chore(deps): update dependency @cypress/vue to v5.0.1 (#2655)
Reviewed-on: vikunja/frontend#2655
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2022-11-08 07:25:00 +00:00
drone 3be9de76c5 [skip ci] Updated translations via Crowdin 2022-11-08 00:12:23 +00:00
renovate a9f41e3f37 chore(deps): update typescript-eslint monorepo to v5.42.1 (#2653)
Reviewed-on: vikunja/frontend#2653
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2022-11-07 18:29:19 +00:00
Dominik Pschenitschni f0492d49ef
feat: kanban store with composition api 2022-11-07 18:25:52 +01:00
Dominik Pschenitschni d85abbd77a feat: ListKanban script setup (#2643)
Co-authored-by: Dominik Pschenitschni <mail@celement.de>
Reviewed-on: vikunja/frontend#2643
Co-authored-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
Co-committed-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
2022-11-07 17:23:11 +00:00
renovate 5186aeb086 chore(deps): update dependency @cypress/vue to v5 (#2652)
Reviewed-on: vikunja/frontend#2652
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2022-11-07 17:16:40 +00:00
renovate 7ba421e810 chore(deps): update dependency vitest to v0.25.0 (#2650)
Reviewed-on: vikunja/frontend#2650
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2022-11-07 16:17:25 +00:00
renovate e0fd4f216f chore(deps): update dependency @cypress/vite-dev-server to v4 (#2651)
Reviewed-on: vikunja/frontend#2651
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2022-11-07 16:17:20 +00:00
Dominik Pschenitschni 5057b69382 chore: move run.sh in scripts folder (#2649)
Co-authored-by: Dominik Pschenitschni <mail@celement.de>
Reviewed-on: vikunja/frontend#2649
Reviewed-by: konrad <k@knt.li>
Co-authored-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
Co-committed-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
2022-11-07 14:33:37 +00:00
renovate 07297196f9 chore(deps): update dependency vite-plugin-pwa to v0.13.3 (#2648)
Reviewed-on: vikunja/frontend#2648
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2022-11-07 14:13:09 +00:00
Dominik Pschenitschni 7fbb6e8f70 fix: Flatpickr types (#2647)
Co-authored-by: Dominik Pschenitschni <mail@celement.de>
Reviewed-on: vikunja/frontend#2647
Co-authored-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
Co-committed-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
2022-11-07 14:05:29 +00:00
Dominik Pschenitschni 38cef79f68 fix: remove duplicate store assignment (#2644)
Co-authored-by: Dominik Pschenitschni <mail@celement.de>
Reviewed-on: vikunja/frontend#2644
Co-authored-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
Co-committed-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
2022-11-07 11:43:19 +00:00
Dominik Pschenitschni 6a93701649 feat: remove comments from prioritySelect (#2645)
Co-authored-by: Dominik Pschenitschni <mail@celement.de>
Reviewed-on: vikunja/frontend#2645
Co-authored-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
Co-committed-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
2022-11-07 11:42:32 +00:00
Dominik Pschenitschni d9a8382049 feat: simpliy editAssignees (#2646)
Co-authored-by: Dominik Pschenitschni <mail@celement.de>
Reviewed-on: vikunja/frontend#2646
Co-authored-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
Co-committed-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
2022-11-07 11:41:49 +00:00
51 changed files with 3352 additions and 3126 deletions

View File

@ -1,5 +1,6 @@
---
kind: pipeline
type: docker
name: build
trigger:
@ -22,7 +23,7 @@ steps:
# Disabled until we figure out why it is so slow
# - name: restore-cache
# image: meltwater/drone-cache:dev
# pull: true
# pull: always
# environment:
# AWS_ACCESS_KEY_ID:
# from_secret: cache_aws_access_key_id
@ -41,7 +42,7 @@ steps:
- name: dependencies
image: node:18-alpine
pull: true
pull: always
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
CYPRESS_CACHE_FOLDER: .cache/cypress
@ -53,7 +54,7 @@ steps:
- name: lint
image: node:18-alpine
pull: true
pull: always
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
commands:
@ -64,7 +65,7 @@ steps:
- name: build-prod
image: node:18-alpine
pull: true
pull: always
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
commands:
@ -75,7 +76,7 @@ steps:
- name: test-unit
image: node:18-alpine
pull: true
pull: always
commands:
- corepack enable && pnpm config set store-dir .cache/pnpm
- pnpm run test:unit
@ -85,7 +86,7 @@ steps:
- name: typecheck
failure: ignore
image: node:18-alpine
pull: true
pull: always
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
commands:
@ -95,8 +96,8 @@ steps:
- dependencies
- name: test-frontend
image: cypress/browsers:node16.14.0-chrome99-ff97
pull: true
image: cypress/browsers:node18.12.0-chrome106-ff106
pull: always
environment:
CYPRESS_API_URL: http://api:3456/api/v1
CYPRESS_TEST_SECRET: averyLongSecretToSe33dtheDB
@ -116,7 +117,7 @@ steps:
# - name: rebuild-cache
# image: meltwater/drone-cache:dev
# pull: true
# pull: always
# environment:
# AWS_ACCESS_KEY_ID:
# from_secret: cache_aws_access_key_id
@ -136,7 +137,7 @@ steps:
- name: deploy-preview
image: node:18-alpine
pull: true
pull: always
environment:
NETLIFY_AUTH_TOKEN:
from_secret: netlify_auth_token
@ -160,6 +161,7 @@ steps:
---
kind: pipeline
type: docker
name: release-latest
depends_on:
@ -179,7 +181,7 @@ steps:
# - name: restore-cache
# image: meltwater/drone-cache:dev
# pull: true
# pull: always
# environment:
# AWS_ACCESS_KEY_ID:
# from_secret: cache_aws_access_key_id
@ -197,13 +199,12 @@ steps:
- name: build
image: node:18-alpine
pull: true
group: build-static
pull: always
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
commands:
- apk add git
- corepack enable && pnpm config set store-dir .cache/.pnp
- corepack enable && pnpm config set store-dir .cache/.pnpm
- pnpm install --fetch-timeout 100000 --frozen-lockfile
- pnpm run lint
- "echo '{\"VERSION\": \"'$(git describe --tags --always --abbrev=10 | sed 's/-/+/' | sed 's/^v//' | sed 's/-g/-/')'\"}' > src/version.json"
@ -214,7 +215,7 @@ steps:
- name: static
image: kolaente/zip
pull: true
pull: always
commands:
- cd dist
- zip -r ../vikunja-frontend-unstable.zip *
@ -223,7 +224,7 @@ steps:
- name: release
image: plugins/s3
pull: true
pull: always
settings:
bucket: vikunja-releases
access_key:
@ -239,6 +240,7 @@ steps:
---
kind: pipeline
type: docker
name: release-version
depends_on:
@ -256,7 +258,7 @@ steps:
# - name: restore-cache
# image: meltwater/drone-cache:dev
# pull: true
# pull: always
# environment:
# AWS_ACCESS_KEY_ID:
# from_secret: cache_aws_access_key_id
@ -274,8 +276,7 @@ steps:
- name: build
image: node:18-alpine
pull: true
group: build-static
pull: always
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
commands:
@ -291,7 +292,7 @@ steps:
- name: static
image: kolaente/zip
pull: true
pull: always
commands:
- cd dist
- zip -r ../vikunja-frontend-${DRONE_TAG##v}.zip *
@ -300,7 +301,7 @@ steps:
- name: release
image: plugins/s3
pull: true
pull: always
settings:
bucket: vikunja-releases
access_key:
@ -316,6 +317,7 @@ steps:
---
kind: pipeline
type: docker
name: trigger-desktop-update
trigger:
@ -340,111 +342,7 @@ steps:
---
kind: pipeline
type: docker
name: docker-arm-release
depends_on:
- release-latest
- release-version
platform:
os: linux
arch: arm64
trigger:
ref:
- refs/heads/main
- "refs/tags/**"
event:
exclude:
- cron
steps:
- name: docker-unstable
image: plugins/docker:linux-arm
pull: true
settings:
username:
from_secret: docker_username
password:
from_secret: docker_password
repo: vikunja/frontend
tags: unstable-linux-arm
build_args:
- 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
name: docker-release
depends_on:
- release-latest
@ -459,8 +357,14 @@ trigger:
- cron
steps:
- name: fetch-tags
image: docker:git
commands:
- git fetch --tags
- name: docker-unstable
image: plugins/docker:linux-amd64
image: thegeeklab/drone-docker-buildx
privileged: true
pull: true
settings:
username:
@ -468,92 +372,42 @@ steps:
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: 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=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
build_args:
- USE_RELEASE=true
- RELEASE_VERSION=unstable
platforms:
- linux/386
- linux/amd64
- linux/arm/v6
- linux/arm/v7
- linux/arm64/v8
depends_on: [ fetch-tags ]
when:
ref:
- refs/heads/main
- name: manifest-release
pull: always
image: plugins/manifest
- name: docker-release
image: thegeeklab/drone-docker-buildx
privileged: true
pull: true
settings:
username:
from_secret: docker_username
password:
from_secret: docker_password
repo: vikunja/frontend
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
build_args:
- USE_RELEASE=true
- RELEASE_VERSION=${DRONE_TAG##v}
platforms:
- linux/386
- linux/amd64
- linux/arm/v6
- linux/arm/v7
- linux/arm64/v8
depends_on: [ fetch-tags ]
when:
ref:
- "refs/tags/**"
@ -576,9 +430,7 @@ depends_on:
- release-version
- release-latest
- trigger-desktop-update
- docker-arm-release
- docker-amd64-release
- docker-manifest
- docker-release
steps:
- name: notify
@ -661,6 +513,6 @@ steps:
from_secret: crowdin_key
---
kind: signature
hmac: 5dc7ab785b6e4d1611fc2851971e23c444d93d4988517f116e02e8c4d1af82f3
hmac: 9f26b5af73e3464e9ee1b5fbcb96854ca8a7e5f8d6ee2d85fd8376aad951b446
...

View File

@ -9,6 +9,135 @@ All releases can be found on https://code.vikunja.io/frontend/releases.
The releases aim at the api versions which is why there are missing versions.
## [0.20.1] - 2022-11-11
### Bug Fixes
* *(auth)* Always redirect to external openid provider if only one is enabled
* *(ci)* Cache folder name
* *(gantt)* Don't try to load list NaN when opening a task from the gantt chart
* *(kanban)* Don't allow dragging a bucket if a task input is focused
* *(quick add magic)* Don't parse labels, assignees or lists as date expressions if they are called that
* *(table)* Sort tasks by index instead of id
* *(tasks)* Show any errors happening during task load* SetModuleLoading LoadingState type ([35f4bb1](35f4bb138554d300757420261d70d1a6bf6b9cc0))
* Better kanban updateBucket types ([964aba4](964aba4824418e431955881be284e35f412e873b))
* Disable props destructure error ([d6cb965](d6cb965ea7330f80f1e3c213442a049f63cba57e))
* Missing href ([5d601ca](5d601ca4b34cd7368ff6061659617fff2836cdbc))
* Multiselect modelValue prop type ([480aa88](480aa8813ec28e1228e02ba78dd3ee3037f4928a))
* Potential issue with refs in Avatar ([3c5bfcc](3c5bfcc6f3cece0f3bd6e4f862a187c17a2c4d6c))
* CoverImageAttachmentId ([e01df4d](e01df4d36996aa281ef73ee74f3ac5316a0b8a98))
* Don't show user deletion menu entry in user settings if the server disabled it ([09b76b7](09b76b7bd476b9de653e53de579f1c533d101d4d))
* Resolve issues with vue-easymde (#2629) ([eb59ca5](eb59ca5836ae8454885827bcf28a8476600bd122))
* Remove wrong loadTask params (#2635) ([f7728e5](f7728e538408d15fcbfcd9ce02cd235447dfa6f0))
* Remove duplicate store assignment (#2644) ([38cef79](38cef79f680ddf3612376a90c69198e01283a5a0))
* Flatpickr types (#2647) ([7fbb6e8](7fbb6e8f700157238f8924ce95424d79a34b7543))
* Sort task alphabetically ([612e592](612e592da799ee6a76d32c8ebc567aeadde3ee11))
* Too much recursion error when opening a task from the gantt chart ([d47791b](d47791b95793aabf1524544494621b237479c15d))
* Lint & formatting ([c2dd18e](c2dd18edaa8ac29446845a5028d1a04c1f39fc76))
* Gantt route sync ([7ec2b6c](7ec2b6c0d28a1ae1799b1ed7a781efbf4c4542d7))
* Gantt route sync (#2664) ([9450817](94508173dcfc75d606d490a536f80e10397fb69c))
### Dependencies
* *(deps)* Update dependency vite to v3.2.1
* *(deps)* Update dependency @vue/test-utils to v2.2.1 (#2591)
* *(deps)* Update pnpm to v7.14.1 (#2593)
* *(deps)* Update dependency vue-flatpickr-component to v11
* *(deps)* Update sentry-javascript monorepo to v7.17.3
* *(deps)* Update dependency eslint-plugin-vue to v9.7.0
* *(deps)* Update dependency caniuse-lite to v1.0.30001427
* *(deps)* Update dependency blurhash to v2.0.4
* *(deps)* Update dependency vitest to v0.24.4
* *(deps)* Update dependency @types/node to v18.11.8
* *(deps)* Update dependency vite to v3.2.2
* *(deps)* Update dependency @kyvg/vue3-notification to v2.5.0
* *(deps)* Update dependency @kyvg/vue3-notification to v2.5.1
* *(deps)* Update dependency @kyvg/vue3-notification to v2.6.0 (#2612)
* *(deps)* Update typescript-eslint monorepo to v5.42.0
* *(deps)* Update dependency rollup to v3.2.4 (#2614)
* *(deps)* Update dependency @kyvg/vue3-notification to v2.6.1 (#2615)
* *(deps)* Update dependency rollup to v3.2.5 (#2618)
* *(deps)* Update dependency @cypress/vite-dev-server to v3.4.0 (#2617)
* *(deps)* Update dependency marked to v4.2.0 (#2616)
* *(deps)* Update dependency @types/node to v18.11.9 (#2619)
* *(deps)* Update dependency vitest to v0.24.5 (#2621)
* *(deps)* Update dependency @cypress/vue to v4.2.2
* *(deps)* Update dependency marked to v4.2.1 (#2625)
* *(deps)* Update pnpm to v7.14.2
* *(deps)* Update dependency esbuild to v0.15.13 (#2627)
* *(deps)* Update sentry-javascript monorepo to v7.17.4 (#2628)
* *(deps)* Pin dependency @types/codemirror to 5.60.5
* *(deps)* Update dependency vite-plugin-pwa to v0.13.2 (#2632)
* *(deps)* Update dependency sass to v1.56.0 (#2633)
* *(deps)* Update dependency marked to v4.2.2 (#2636)
* *(deps)* Update dependency eslint to v8.27.0
* *(deps)* Update dependency caniuse-lite to v1.0.30001430 (#2639)
* *(deps)* Update dependency netlify-cli to v12.1.0 (#2640)
* *(deps)* Update dependency vite to v3.2.3
* *(deps)* Update dependency @vitejs/plugin-legacy to v2.3.1 (#2641)
* *(deps)* Update dependency vite-plugin-pwa to v0.13.3 (#2648)
* *(deps)* Update dependency @cypress/vite-dev-server to v4 (#2651)
* *(deps)* Update dependency vitest to v0.25.0 (#2650)
* *(deps)* Update dependency @cypress/vue to v5 (#2652)
* *(deps)* Update typescript-eslint monorepo to v5.42.1 (#2653)
* *(deps)* Update dependency @cypress/vue to v5.0.1 (#2655)
* *(deps)* Update sentry-javascript monorepo to v7.18.0
* *(deps)* Update dependency vitest to v0.25.1 (#2657)
* *(deps)* Update dependency @cypress/vite-dev-server to v4.0.1 (#2658)
* *(deps)* Update vueuse to v9.5.0 (#2660)
* *(deps)* Update dependency sass to v1.56.1 (#2661)
* *(deps)* Update dependency vue to v3.2.42
* *(deps)* Update dependency @fortawesome/vue-fontawesome to v3.0.2
* *(deps)* Update dependency vue to v3.2.43 (#2663)
* *(deps)* Update dependency vue to v3.2.44 (#2666)
* *(deps)* Update pnpm to v7.15.0 (#2667)
* *(deps)* Update dependency cypress to v11 (#2659)
* *(deps)* Update dependency dompurify to v2.4.1 (#2669)
### Features
* *(ci)* Use 'always' for pull
* *(ci)* Add kind everywhere
* *(ci)* Update cypress image
* *(ci)* Improve drone config (#2637)
* *(tests)* Add tests for gantt chart time range
* *(tests)* Add tests for gantt chart task detail open* Task store with composition api (#2610) ([839d331](839d331bf51f9a0e9742b9972dbd6a88fa38f1c3))
* Auth store with composition api (#2602) ([825ba10](825ba100f0c05e1ab98d401157c30aad8658afa6))
* Config store with composition api (#2604) ([15ef86d](15ef86d597ceb8731febf789f1b812a339273e40))
* Base store with composition api (#2601) ([b4f4fd4](b4f4fd45a4c98629de182033e808cf7b22a1fe4a))
* Attachments store with composition api (#2603) ([a50eca8](a50eca852fcb841166baa07a6cc405eeb70c6e9d))
* Namespaces store with composition api (#2607) ([0832184](08321842220798b478ffaef7e9e11c527cb5b3bd))
* Lists store with composition api (#2606) ([5ae8bac](5ae8bace820b05d3ad05f40ab51164ec2c35c068))
* Label store with composition api (#2605) ([1002579](1002579173bd4b89e157c78ac607abd7969d85bc))
* Type improvements ([599e28e](599e28e5e5d56e4ced338ec1c79fea7d4576b85a))
* Type global components and especially icon prop ([a2c1702](a2c1702eef64dd779c86940898bd49fc2c96233f))
* Rework BaseButton ([e8c6afc](e8c6afce7298267f2f77ece0a746218c2eb3f7b7))
* Rework XButton ([4cd0e90](4cd0e90feaab05a2275e92affda23dde7453013f))
* Rework dropdown-item ([02deb0b](02deb0beddbc9221bdcafd0d09cee383571dae55))
* Rework popup ([0b58973](0b58973d872d8d54c9a829a06c8535a7a7115613))
* SingleTaskInList script setup (#2463) ([44e6981](44e6981759261cdada6388384cbad96e5401b8a9))
* Add type info ([0182695](0182695cda1252a65df3f48fdc316e82cd7fadbd))
* Rename http-common to fetcher (#2620) ([096daad](096daad80a9c089e732116ce3b8aa4310a611368))
* Improved types (#2547) ([0ff0d8c](0ff0d8c5b89bd6a8b628ddbe6074f61797b6b9c1))
* MigrateService script setup (#2432) ([8b7b4d6](8b7b4d61a3b9dd01ab58b7e7dd30bf649b62fcf6))
* Sticky action buttons (#2622) ([f4bc2b9](f4bc2b94f0466a357361a69cfb3562e84d1ea439))
* Simpliy editAssignees (#2646) ([d9a8382](d9a83820495f34ddbd776f70cabdc24bbb1c3f32))
* Remove comments from prioritySelect (#2645) ([6a93701](6a93701649d35622d13dda969aae4aedf145d4d0))
* ListKanban script setup (#2643) ([d85abbd](d85abbd77a8197e977fdbfec0ee309736cce05fa))
* Kanban store with composition api ([f0492d4](f0492d49ef5cd99d95085deec066cec85f4688b3))
### Miscellaneous Tasks
* *(ci)* Sign drone config* Remove comment ([1101fcb](1101fcb3fff1fce102a7418b1e2734a71cdf84e2))
* Improve multiselect hover types ([caa29c1](caa29c152d35b28658773b838de0a8909d0e509f))
* Remove unused processModel in services (#2624) ([7f00c7d](7f00c7dabd1e55ec0e9a86ca495f702a38ddb18d))
* Inline simple helper (#2631) ([e49f960](e49f960aea2ead5baca6965649821db6584cbac2))
* Move run.sh in scripts folder (#2649) ([5057b69](5057b69382ca65659b624206b381d8f1500bae82))
### Other
* *(other)* [skip ci] Updated translations via Crowdin
## [0.20.0] - 2022-10-28
### Bug Fixes

View File

@ -1,5 +1,5 @@
# Stage 1: Build application
FROM node:18-alpine AS compile-image
FROM --platform=$BUILDPLATFORM node:18-alpine AS compile-image
WORKDIR /build
@ -29,7 +29,7 @@ RUN \
FROM nginx:alpine
COPY nginx.conf /etc/nginx/nginx.conf
COPY run.sh /run.sh
COPY scripts/run.sh /run.sh
# copy compiled files from stage 1
COPY --from=compile-image /build/dist /usr/share/nginx/html

View File

@ -4,7 +4,7 @@
[![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.20.0-brightgreen.svg)](https://dl.vikunja.io)
[![Download](https://img.shields.io/badge/download-v0.20.1-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.

View File

@ -62,7 +62,7 @@ describe('List View Gantt', () => {
it('Drags a task around', () => {
cy.intercept('**/api/v1/tasks/*')
.as('taskUpdate')
const now = new Date()
TaskFactory.create(1, {
start_date: formatISO(now),
@ -77,4 +77,49 @@ describe('List View Gantt', () => {
.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('/lists/1/gantt')
cy.get('.list-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('/lists/1/gantt?dateFrom=2022-09-25&dateTo=2022-11-05')
cy.get('.g-timeunits-container')
.should('contain', 'September 2022')
.should('contain', 'October 2022')
.should('contain', 'November 2022')
cy.get('.list-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('/lists/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

@ -204,4 +204,37 @@ describe('List View Kanban', () => {
cy.get('.list-kanban .filter-container .base-button')
.should('exist')
})
it('Should remove a task from the board when deleting it', () => {
const lists = ListFactory.create(1)
const buckets = BucketFactory.create(2, {
list_id: lists[0].id,
})
const tasks = TaskFactory.create(5, {
list_id: 1,
bucket_id: buckets[0].id,
})
const task = tasks[0]
cy.visit('/lists/1/kanban')
cy.getSettled('.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.getSettled('.kanban .bucket .tasks')
.should('not.contain', task.title)
})
})

View File

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

View File

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

View File

@ -21,17 +21,17 @@
"@fortawesome/fontawesome-svg-core": "6.2.0",
"@fortawesome/free-regular-svg-icons": "6.2.0",
"@fortawesome/free-solid-svg-icons": "6.2.0",
"@fortawesome/vue-fontawesome": "3.0.1",
"@fortawesome/vue-fontawesome": "3.0.2",
"@github/hotkey": "2.0.1",
"@infectoone/vue-ganttastic": "2.1.2",
"@kyvg/vue3-notification": "2.6.1",
"@sentry/tracing": "7.17.4",
"@sentry/vue": "7.17.4",
"@sentry/tracing": "7.19.0",
"@sentry/vue": "7.19.0",
"@types/is-touch-device": "1.0.0",
"@types/lodash.clonedeep": "4.5.7",
"@types/sortablejs": "1.15.0",
"@vueuse/core": "9.4.0",
"@vueuse/router": "9.4.0",
"@vueuse/core": "9.5.0",
"@vueuse/router": "9.5.0",
"axios": "0.27.2",
"blurhash": "2.0.4",
"bulma-css-variables": "0.9.33",
@ -39,7 +39,7 @@
"codemirror": "5.65.9",
"date-fns": "2.29.3",
"dayjs": "1.11.6",
"dompurify": "2.4.0",
"dompurify": "2.4.1",
"easymde": "2.18.0",
"fast-deep-equal": "3.1.3",
"flatpickr": "4.6.13",
@ -56,7 +56,7 @@
"snake-case": "3.0.4",
"sortablejs": "1.15.0",
"ufo": "0.8.6",
"vue": "3.2.41",
"vue": "3.2.45",
"vue-advanced-cropper": "2.8.6",
"vue-flatpickr-component": "11.0.1",
"vue-i18n": "9.2.2",
@ -66,49 +66,49 @@
},
"devDependencies": {
"@4tw/cypress-drag-drop": "2.2.1",
"@cypress/vite-dev-server": "3.4.0",
"@cypress/vue": "4.2.2",
"@cypress/vite-dev-server": "4.0.1",
"@cypress/vue": "5.0.1",
"@faker-js/faker": "7.6.0",
"@rushstack/eslint-patch": "1.2.0",
"@types/codemirror": "5.60.5",
"@types/dompurify": "2.3.4",
"@types/dompurify": "2.4.0",
"@types/flexsearch": "0.7.3",
"@types/lodash.debounce": "4.0.7",
"@types/marked": "4.0.7",
"@types/node": "18.11.9",
"@types/postcss-preset-env": "7.7.0",
"@typescript-eslint/eslint-plugin": "5.42.0",
"@typescript-eslint/parser": "5.42.0",
"@typescript-eslint/eslint-plugin": "5.42.1",
"@typescript-eslint/parser": "5.42.1",
"@vitejs/plugin-legacy": "2.3.1",
"@vitejs/plugin-vue": "3.2.0",
"@vue/eslint-config-typescript": "11.0.2",
"@vue/test-utils": "2.2.1",
"@vue/test-utils": "2.2.2",
"@vue/tsconfig": "0.1.3",
"autoprefixer": "10.4.13",
"browserslist": "4.21.4",
"caniuse-lite": "1.0.30001430",
"csstype": "3.1.1",
"cypress": "10.11.0",
"cypress": "11.0.1",
"esbuild": "0.15.13",
"eslint": "8.27.0",
"eslint-plugin-vue": "9.7.0",
"express": "4.18.2",
"happy-dom": "7.6.6",
"netlify-cli": "12.1.0",
"postcss": "8.4.18",
"postcss": "8.4.19",
"postcss-preset-env": "7.8.2",
"rollup": "3.2.5",
"rollup": "3.3.0",
"rollup-plugin-visualizer": "5.8.3",
"sass": "1.56.0",
"sass": "1.56.1",
"typescript": "4.8.4",
"vite": "3.2.3",
"vite-plugin-pwa": "0.13.2",
"vite-plugin-pwa": "0.13.3",
"vite-svg-loader": "3.6.0",
"vitest": "0.24.5",
"vitest": "0.25.1",
"vue-tsc": "1.0.9",
"wait-on": "6.0.1",
"workbox-cli": "6.5.4"
},
"license": "AGPL-3.0-or-later",
"packageManager": "pnpm@7.14.2"
"packageManager": "pnpm@7.15.0"
}

File diff suppressed because it is too large Load Diff

View File

@ -110,13 +110,13 @@
</template>
<script lang="ts" setup>
import { formatDate } from '@/helpers/time/formatDate'
import {formatDate} from '@/helpers/time/formatDate'
import BaseButton from '@/components/base/BaseButton.vue'
const exampleDate = formatDate(new Date(), 'yyyy-MM-dd')
</script>
<style scoped>
<style scoped lang="scss">
.how-it-works-modal {
font-size: 1rem;
}

View File

@ -1,16 +1,16 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useNow } from '@vueuse/core'
import {computed} from 'vue'
import {useNow} from '@vueuse/core'
import LogoFull from '@/assets/logo-full.svg?component'
import LogoFullPride from '@/assets/logo-full-pride.svg?component'
import LogoFull from '@/assets/logo-full.svg?url'
import LogoFullPride from '@/assets/logo-full-pride.svg?url'
const now = useNow()
const Logo = computed(() => now.value.getMonth() === 5 ? LogoFullPride : LogoFull)
const logoUrl = computed(() => now.value.getMonth() === 5 ? LogoFullPride : LogoFull)
</script>
<template>
<Logo alt="Vikunja" class="logo" />
<img alt="Vikunja" :src="logoUrl" class="logo" />
</template>
<style lang="scss" scoped>

View File

@ -154,41 +154,43 @@ labelStore.loadAllLabels()
@media screen and (max-width: $tablet) {
padding-top: $navbar-height;
}
}
.app-content {
z-index: 10;
position: relative;
padding-top: 1rem;
.app-content {
z-index: 10;
position: relative;
padding-top: 1rem;
@media screen {
padding: $navbar-height + 1.5rem 1.5rem 1rem 1.5rem;
}
@media screen {
padding: $navbar-height + 1.5rem 1.5rem 1rem 1.5rem;
}
// Used to make sure the spinner is always in the middle while loading
> .loader-container {
min-height: calc(100vh - #{$navbar-height + 1.5rem + 1rem});
}
@media screen and (max-width: $tablet) {
margin-left: 0;
padding-top: 1.5rem;
min-height: calc(100vh - 4rem);
}
@media screen and (max-width: $tablet) {
margin-left: 0;
padding-top: 1.5rem;
min-height: calc(100vh - 4rem);
}
@media screen {
&.is-menu-enabled {
margin-left: $navbar-width;
@media screen {
&.is-menu-enabled {
margin-left: $navbar-width;
@media screen and (max-width: $tablet) {
min-width: 100%;
margin-left: 0;
}
@media screen and (max-width: $tablet) {
min-width: 100%;
margin-left: 0;
}
}
}
.card {
background: var(--white);
}
// Used to make sure the spinner is always in the middle while loading
> .loader-container {
min-height: calc(100vh - #{$navbar-height + 1.5rem + 1rem});
}
// FIXME: This should be somehow defined inside Card.vue
.card {
background: var(--white);
}
}

View File

@ -0,0 +1,63 @@
<template>
<multiselect
v-model="selectedLists"
:search-results="foundLists"
:loading="listService.loading"
:multiple="true"
:placeholder="$t('list.search')"
label="title"
@search="findLists"
/>
</template>
<script setup lang="ts">
import {computed, ref, shallowReactive, watchEffect, type PropType} from 'vue'
import Multiselect from '@/components/input/multiselect.vue'
import type {IList} from '@/modelTypes/IList'
import ListService from '@/services/list'
import {includesById} from '@/helpers/utils'
const props = defineProps({
modelValue: {
type: Array as PropType<IList[]>,
default: () => [],
},
})
const emit = defineEmits<{
(e: 'update:modelValue', value: IList[]): void
}>()
const lists = ref<IList[]>([])
watchEffect(() => {
lists.value = props.modelValue
})
const selectedLists = computed({
get() {
return lists.value
},
set: (value) => {
lists.value = value
emit('update:modelValue', value)
},
})
const listService = shallowReactive(new ListService())
const foundLists = ref<IList[]>([])
async function findLists(query: string) {
if (query === '') {
foundLists.value = []
return
}
const response = await listService.getAll({}, {s: query}) as IList[]
// Filter selected items from the results
foundLists.value = response.filter(({id}) => !includesById(lists.value, id))
}
</script>

View File

@ -0,0 +1,63 @@
<template>
<multiselect
v-model="selectedNamespaces"
:search-results="foundNamespaces"
:loading="namespaceService.loading"
:multiple="true"
:placeholder="$t('namespace.search')"
label="namespace"
@search="findNamespaces"
/>
</template>
<script setup lang="ts">
import {computed, ref, shallowReactive, watchEffect, type PropType} from 'vue'
import Multiselect from '@/components/input/multiselect.vue'
import type {INamespace} from '@/modelTypes/INamespace'
import NamespaceService from '@/services/namespace'
import {includesById} from '@/helpers/utils'
const props = defineProps({
modelValue: {
type: Array as PropType<INamespace[]>,
default: () => [],
},
})
const emit = defineEmits<{
(e: 'update:modelValue', value: INamespace[]): void
}>()
const namespaces = ref<INamespace[]>([])
watchEffect(() => {
namespaces.value = props.modelValue
})
const selectedNamespaces = computed({
get() {
return namespaces.value
},
set: (value) => {
namespaces.value = value
emit('update:modelValue', value)
},
})
const namespaceService = shallowReactive(new NamespaceService())
const foundNamespaces = ref<INamespace[]>([])
async function findNamespaces(query: string) {
if (query === '') {
foundNamespaces.value = []
return
}
const response = await namespaceService.getAll({}, {s: query}) as INamespace[]
// Filter selected items from the results
foundNamespaces.value = response.filter(({id}) => !includesById(namespaces.value, id))
}
</script>

View File

@ -0,0 +1,63 @@
<template>
<multiselect
v-model="selectedUsers"
:search-results="foundUsers"
:loading="userService.loading"
:multiple="true"
:placeholder="$t('team.edit.search')"
label="username"
@search="findUsers"
/>
</template>
<script setup lang="ts">
import {computed, ref, shallowReactive, watchEffect, type PropType} from 'vue'
import Multiselect from '@/components/input/multiselect.vue'
import type {IUser} from '@/modelTypes/IUser'
import UserService from '@/services/user'
import {includesById} from '@/helpers/utils'
const props = defineProps({
modelValue: {
type: Array as PropType<IUser[]>,
default: () => [],
},
})
const emit = defineEmits<{
(e: 'update:modelValue', value: IUser[]): void
}>()
const users = ref<IUser[]>([])
watchEffect(() => {
users.value = props.modelValue
})
const selectedUsers = computed({
get() {
return users.value
},
set: (value) => {
users.value = value
emit('update:modelValue', value)
},
})
const userService = shallowReactive(new UserService())
const foundUsers = ref<IUser[]>([])
async function findUsers(query: string) {
if (query === '') {
foundUsers.value = []
return
}
const response = await userService.getAll({}, {s: query}) as IUser[]
// Filter selected items from the results
foundUsers.value = response.filter(({id}) => !includesById(users.value, id))
}
</script>

View File

@ -9,7 +9,8 @@
<div class="control" :class="{'is-loading': loading || localLoading}">
<div
class="input-wrapper input"
:class="{'has-multiple': hasMultiple}">
:class="{'has-multiple': hasMultiple}"
>
<template v-if="Array.isArray(internalValue)">
<template v-for="(item, key) in internalValue">
<slot name="tag" :item="item">
@ -38,7 +39,7 @@
<transition name="fade">
<div class="search-results" :class="{'search-results-inline': inline}" v-if="searchResultsVisible">
<BaseButton
class="is-fullwidth"
class="search-result-button is-fullwidth"
v-for="(data, index) in filteredSearchResults"
:key="index"
:ref="(el) => setResult(el, index)"
@ -58,7 +59,7 @@
<BaseButton
v-if="creatableAvailable"
class="is-fullwidth"
class="search-result-button is-fullwidth"
:ref="(el) => setResult(el, filteredSearchResults.length)"
@keydown.up.prevent="() => preSelect(filteredSearchResults.length - 1)"
@keydown.down.prevent="() => preSelect(filteredSearchResults.length + 1)"
@ -434,122 +435,125 @@ function focus() {
.control.is-loading::after {
top: .75rem;
}
}
&.has-search-results .input-wrapper {
border-radius: $radius $radius 0 0;
border-color: var(--primary) !important;
background: var(--white) !important;
.input-wrapper {
padding: 0;
background: var(--white);
border-color: var(--grey-200);
flex-wrap: wrap;
height: auto;
&, &:focus-within {
border-bottom-color: var(--grey-200) !important;
}
&:hover {
border-color: var(--grey-300) !important;
}
.input-wrapper {
padding: 0;
background: var(--white);
border-color: var(--grey-200);
flex-wrap: wrap;
.input {
display: flex;
max-width: 100%;
width: 100%;
align-items: center;
border: none !important;
background: transparent;
height: auto;
&:hover {
border-color: var(--grey-300) !important;
}
.input {
display: flex;
max-width: 100%;
width: 100%;
align-items: center;
border: none !important;
background: transparent;
height: auto;
&::placeholder {
font-style: normal !important;
}
}
&.has-multiple .input {
max-width: 250px;
input {
padding-left: 0;
}
}
&:focus-within {
border-color: var(--primary) !important;
background: var(--white) !important;
}
.loader {
margin: 0 .5rem;
&::placeholder {
font-style: normal !important;
}
}
.search-results {
background: var(--white);
border-radius: 0 0 $radius $radius;
border: 1px solid var(--primary);
border-top: none;
&.has-multiple .input {
max-width: 250px;
max-height: 50vh;
overflow-x: auto;
position: absolute;
z-index: 100;
max-width: 100%;
min-width: 100%;
&-inline {
position: static;
input {
padding-left: 0;
}
}
button {
background: transparent;
text-align: left;
box-shadow: none;
border-radius: 0;
text-transform: none;
font-family: $family-sans-serif;
font-weight: normal;
padding: .5rem;
border: none;
cursor: pointer;
color: var(--grey-800);
&:focus-within {
border-color: var(--primary) !important;
background: var(--white) !important;
}
display: flex;
justify-content: space-between;
align-items: center;
overflow: hidden;
.search-result {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
padding: .5rem .75rem;
}
.hint-text {
font-size: .75rem;
color: transparent;
transition: color $transition;
padding-left: .5rem;
}
&:focus, &:hover {
background: var(--grey-100);
box-shadow: none !important;
.hint-text {
color: var(--text);
}
}
&:active {
background: var(--grey-200);
}
}
// doesn't seem to be used. maybe inside the slot?
.loader {
margin: 0 .5rem;
}
}
.has-search-results .input-wrapper {
border-radius: $radius $radius 0 0;
border-color: var(--primary) !important;
background: var(--white) !important;
&, &:focus-within {
border-bottom-color: var(--grey-200) !important;
}
}
.search-results {
background: var(--white);
border-radius: 0 0 $radius $radius;
border: 1px solid var(--primary);
border-top: none;
max-height: 50vh;
overflow-x: auto;
position: absolute;
z-index: 100;
max-width: 100%;
min-width: 100%;
}
.search-results-inline {
position: static;
}
.search-result-button {
background: transparent;
text-align: left;
box-shadow: none;
border-radius: 0;
text-transform: none;
font-family: $family-sans-serif;
font-weight: normal;
padding: .5rem;
border: none;
cursor: pointer;
color: var(--grey-800);
display: flex;
justify-content: space-between;
align-items: center;
overflow: hidden;
&:focus,
&:hover {
background: var(--grey-100);
box-shadow: none !important;
.hint-text {
color: var(--text);
}
}
&:active {
background: var(--grey-200);
}
}
.search-result {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
padding: .5rem .75rem;
}
.hint-text {
font-size: .75rem;
color: transparent;
transition: color $transition;
padding-left: .5rem;
}
</style>

View File

@ -5,34 +5,42 @@
>
<div class="switch-view-container">
<div class="switch-view">
<router-link
<BaseButton
v-shortcut="'g l'"
:title="$t('keyboardShortcuts.list.switchToListView')"
class="switch-view-button"
:class="{'is-active': viewName === 'list'}"
:to="{ name: 'list.list', params: { listId } }">
:to="{ name: 'list.list', params: { listId } }"
>
{{ $t('list.list.title') }}
</router-link>
<router-link
</BaseButton>
<BaseButton
v-shortcut="'g g'"
:title="$t('keyboardShortcuts.list.switchToGanttView')"
class="switch-view-button"
:class="{'is-active': viewName === 'gantt'}"
:to="{ name: 'list.gantt', params: { listId } }">
:to="{ name: 'list.gantt', params: { listId } }"
>
{{ $t('list.gantt.title') }}
</router-link>
<router-link
</BaseButton>
<BaseButton
v-shortcut="'g t'"
:title="$t('keyboardShortcuts.list.switchToTableView')"
class="switch-view-button"
:class="{'is-active': viewName === 'table'}"
:to="{ name: 'list.table', params: { listId } }">
:to="{ name: 'list.table', params: { listId } }"
>
{{ $t('list.table.title') }}
</router-link>
<router-link
</BaseButton>
<BaseButton
v-shortcut="'g k'"
:title="$t('keyboardShortcuts.list.switchToKanbanView')"
class="switch-view-button"
:class="{'is-active': viewName === 'kanban'}"
:to="{ name: 'list.kanban', params: { listId } }">
:to="{ name: 'list.kanban', params: { listId } }"
>
{{ $t('list.kanban.title') }}
</router-link>
</BaseButton>
</div>
<slot name="header" />
</div>
@ -50,6 +58,7 @@
import {ref, computed, watch} from 'vue'
import {useRoute} from 'vue-router'
import BaseButton from '@/components/base/BaseButton.vue'
import Message from '@/components/misc/message.vue'
import ListModel from '@/models/list'
@ -131,7 +140,7 @@ watch(
const list = new ListModel(listData)
try {
const loadedList = await listService.value.get(list)
await baseStore.handleSetCurrentList({list: loadedList})
baseStore.handleSetCurrentList({list: loadedList})
} finally {
loadedListId.value = props.listId
}
@ -158,35 +167,32 @@ watch(
height: $switch-view-height;
margin: 0 auto 1rem;
padding: .5rem;
a {
padding: .25rem .5rem;
display: block;
border-radius: $radius;
transition: all 100ms;
&:not(:last-child) {
margin-right: .5rem;
}
&.is-active,
&:hover {
color: var(--switch-view-color);
}
&.is-active {
background: var(--primary);
font-weight: bold;
box-shadow: var(--shadow-xs);
}
&:hover {
background: var(--primary);
}
}
}
.switch-view-button {
padding: .25rem .5rem;
display: block;
border-radius: $radius;
transition: all 100ms;
&:not(:last-child) {
margin-right: .5rem;
}
&:hover {
color: var(--switch-view-color);
background: var(--primary);
}
&.is-active {
color: var(--switch-view-color);
background: var(--primary);
font-weight: bold;
box-shadow: var(--shadow-xs);
}
}
// FIXME: this should be in notification and set via a prop
.is-archived .notification.is-warning {
margin-bottom: 1rem;
}

View File

@ -13,11 +13,14 @@
>
{{ $t('filters.attributes.requireAll') }}
</fancycheckbox>
<fancycheckbox v-model="filters.done" @update:model-value="setDoneFilter">
<fancycheckbox
v-model="filters.done"
@update:model-value="setDoneFilter"
>
{{ $t('filters.attributes.showDoneTasks') }}
</fancycheckbox>
<fancycheckbox
v-if="!$route.name.includes('list.kanban') || !$route.name.includes('list.table')"
v-if="!['list.kanban', 'list.table'].includes($route.name as string)"
v-model="sortAlphabetically"
@update:model-value="change()"
>
@ -40,9 +43,9 @@
<label class="label">{{ $t('task.attributes.priority') }}</label>
<div class="control single-value-control">
<priority-select
:disabled="!filters.usePriority || undefined"
v-model.number="filters.priority"
@update:model-value="setPriority"
:disabled="!filters.usePriority || undefined"
/>
<fancycheckbox
v-model="filters.usePriority"
@ -132,16 +135,10 @@
<div class="field">
<label class="label">{{ $t('task.attributes.assignees') }}</label>
<div class="control">
<multiselect
:loading="usersService.loading"
:placeholder="$t('team.edit.search')"
@search="query => find('users', query)"
:search-results="foundusers"
@select="() => add('users', 'assignees')"
label="username"
:multiple="true"
@remove="() => remove('users', 'assignees')"
v-model="users"
<SelectUser
v-model="entities.users"
@select="changeMultiselectFilter('users', 'assignees')"
@remove="changeMultiselectFilter('users', 'assignees')"
/>
</div>
</div>
@ -149,41 +146,32 @@
<div class="field">
<label class="label">{{ $t('task.attributes.labels') }}</label>
<div class="control labels-list">
<edit-labels v-model="labels" @update:model-value="changeLabelFilter"/>
<edit-labels
v-model="entities.labels"
@update:model-value="changeLabelFilter"
/>
</div>
</div>
<template
v-if="$route.name === 'filters.create' || $route.name === 'list.edit' || $route.name === 'filter.settings.edit'">
v-if="['filters.create', 'list.edit', 'filter.settings.edit'].includes($route.name as string)">
<div class="field">
<label class="label">{{ $t('list.lists') }}</label>
<div class="control">
<multiselect
:loading="listsService.loading"
:placeholder="$t('list.search')"
@search="query => find('lists', query)"
:search-results="foundlists"
@select="() => add('lists', 'list_id')"
label="title"
@remove="() => remove('lists', 'list_id')"
:multiple="true"
v-model="lists"
<SelectList
v-model="entities.lists"
@select="changeMultiselectFilter('lists', 'list_id')"
@remove="changeMultiselectFilter('lists', 'list_id')"
/>
</div>
</div>
<div class="field">
<label class="label">{{ $t('namespace.namespaces') }}</label>
<div class="control">
<multiselect
:loading="namespaceService.loading"
:placeholder="$t('namespace.search')"
@search="query => find('namespace', query)"
:search-results="foundnamespace"
@select="() => add('namespace', 'namespace')"
label="title"
@remove="() => remove('namespace', 'namespace')"
:multiple="true"
v-model="namespace"
<SelectNamespace
v-model="entities.namespace"
@select="changeMultiselectFilter('namespace', 'namespace')"
@remove="changeMultiselectFilter('namespace', 'namespace')"
/>
</div>
</div>
@ -192,28 +180,39 @@
</template>
<script lang="ts">
import {defineComponent} from 'vue'
export const ALPHABETICAL_SORT = 'title'
</script>
<script setup lang="ts">
import {computed, nextTick, onMounted, reactive, ref, shallowReactive, toRefs, watch} from 'vue'
import {camelCase} from 'camel-case'
import type {ILabel} from '@/modelTypes/ILabel'
import type {IUser} from '@/modelTypes/IUser'
import type {INamespace} from '@/modelTypes/INamespace'
import type {IList} from '@/modelTypes/IList'
import {useLabelStore} from '@/stores/labels'
import DatepickerWithRange from '@/components/date/datepickerWithRange.vue'
import Fancycheckbox from '@/components/input/fancycheckbox.vue'
import {includesById} from '@/helpers/utils'
import PrioritySelect from '@/components/tasks/partials/prioritySelect.vue'
import PercentDoneSelect from '@/components/tasks/partials/percentDoneSelect.vue'
import Multiselect from '@/components/input/multiselect.vue'
import EditLabels from '@/components/tasks/partials/editLabels.vue'
import Fancycheckbox from '@/components/input/fancycheckbox.vue'
import SelectUser from '@/components/input/SelectUser.vue'
import SelectList from '@/components/input/SelectList.vue'
import SelectNamespace from '@/components/input/SelectNamespace.vue'
import {parseDateOrString} from '@/helpers/time/parseDateOrString'
import {dateIsValid, formatISO} from '@/helpers/time/formatDate'
import {objectToSnakeCase} from '@/helpers/case'
import UserService from '@/services/user'
import ListService from '@/services/list'
import NamespaceService from '@/services/namespace'
import EditLabels from '@/components/tasks/partials/editLabels.vue'
import {dateIsValid, formatISO} from '@/helpers/time/formatDate'
import {objectToSnakeCase} from '@/helpers/case'
// FIXME: do not use this here for now. instead create new version from DEFAULT_PARAMS
import {getDefaultParams} from '@/composables/useTaskList'
import {camelCase} from 'camel-case'
// FIXME: merge with DEFAULT_PARAMS in taskList.js
const DEFAULT_PARAMS = {
@ -225,7 +224,7 @@ const DEFAULT_PARAMS = {
filter_include_nulls: true,
filter_concat: 'or',
s: '',
}
} as const
const DEFAULT_FILTERS = {
done: false,
@ -242,395 +241,350 @@ const DEFAULT_FILTERS = {
labels: '',
list_id: '',
namespace: '',
}
} as const
export const ALPHABETICAL_SORT = 'title'
export default defineComponent({
name: 'filters',
components: {
DatepickerWithRange,
EditLabels,
PrioritySelect,
Fancycheckbox,
PercentDoneSelect,
Multiselect,
const props = defineProps({
modelValue: {
required: true,
},
data() {
return {
params: DEFAULT_PARAMS,
filters: DEFAULT_FILTERS,
usersService: new UserService(),
foundusers: [],
users: [],
labelQuery: '',
labels: [],
listsService: new ListService(),
foundlists: [],
lists: [],
namespaceService: new NamespaceService(),
foundnamespace: [],
namespace: [],
}
},
mounted() {
this.filters.requireAllFilters = this.params.filter_concat === 'and'
},
props: {
modelValue: {
required: true,
},
hasTitle: {
type: Boolean,
default: false,
},
},
emits: ['update:modelValue'],
watch: {
modelValue: {
handler(value) {
// FIXME: filters should only be converted to snake case in
// the last moment
this.params = objectToSnakeCase(value)
this.prepareFilters()
},
immediate: true,
},
},
computed: {
sortAlphabetically: {
get() {
return this.params?.sort_by?.find(sortBy => sortBy === ALPHABETICAL_SORT) !== undefined
},
set(sortAlphabetically) {
this.params.sort_by = sortAlphabetically
? [ALPHABETICAL_SORT]
: getDefaultParams().sort_by
this.change()
},
},
foundLabels() {
const labelStore = useLabelStore()
return labelStore.filterLabelsByQuery(this.labels, this.labelQuery)
},
},
methods: {
change() {
const params = {...this.params}
params.filter_value = params.filter_value.map(v => v instanceof Date ? v.toISOString() : v)
this.$emit('update:modelValue', params)
},
prepareFilters() {
this.prepareDone()
this.prepareDate('due_date', 'dueDate')
this.prepareDate('start_date', 'startDate')
this.prepareDate('end_date', 'endDate')
this.prepareSingleValue('priority', 'priority', 'usePriority', true)
this.prepareSingleValue('percent_done', 'percentDone', 'usePercentDone', true)
this.prepareDate('reminders')
this.prepareRelatedObjectFilter('users', 'assignees')
this.prepareRelatedObjectFilter('lists', 'list_id')
this.prepareRelatedObjectFilter('namespace')
this.prepareSingleValue('labels')
const labels = typeof this.filters.labels === 'string'
? this.filters.labels
: ''
const labelIds = labels.split(',').map(i => parseInt(i))
const labelStore = useLabelStore()
this.labels = labelStore.getLabelsByIds(labelIds)
},
removePropertyFromFilter(propertyName) {
// Because of the way arrays work, we can only ever remove one element at once.
// To remove multiple filter elements of the same name this function has to be called multiple times.
for (const i in this.params.filter_by) {
if (this.params.filter_by[i] === propertyName) {
this.params.filter_by.splice(i, 1)
this.params.filter_comparator.splice(i, 1)
this.params.filter_value.splice(i, 1)
break
}
}
},
setDateFilter(filterName, {dateFrom, dateTo}) {
dateFrom = parseDateOrString(dateFrom, null)
dateTo = parseDateOrString(dateTo, null)
// Only filter if we have a date
if (dateFrom !== null && dateTo !== null) {
// Check if we already have values in params and only update them if we do
let foundStart = false
let foundEnd = false
this.params.filter_by.forEach((f, i) => {
if (f === filterName && this.params.filter_comparator[i] === 'greater_equals') {
foundStart = true
this.params.filter_value[i] = dateFrom
}
if (f === filterName && this.params.filter_comparator[i] === 'less_equals') {
foundEnd = true
this.params.filter_value[i] = dateTo
}
})
if (!foundStart) {
this.params.filter_by.push(filterName)
this.params.filter_comparator.push('greater_equals')
this.params.filter_value.push(dateFrom)
}
if (!foundEnd) {
this.params.filter_by.push(filterName)
this.params.filter_comparator.push('less_equals')
this.params.filter_value.push(dateTo)
}
this.filters[camelCase(filterName)] = {
// Passing the dates as string values avoids an endless loop between values changing
// in the datepicker (bubbling up to here) and changing here and bubbling down to the
// datepicker (because there's a new date instance every time this function gets called).
// See https://kolaente.dev/vikunja/frontend/issues/2384
dateFrom: dateIsValid(dateFrom) ? formatISO(dateFrom) : dateFrom,
dateTo: dateIsValid(dateTo) ? formatISO(dateTo) : dateTo,
}
this.change()
return
}
this.removePropertyFromFilter(filterName)
this.removePropertyFromFilter(filterName)
this.change()
},
prepareDate(filterName, variableName) {
if (typeof this.params.filter_by === 'undefined') {
return
}
let foundDateStart = false
let foundDateEnd = false
for (const i in this.params.filter_by) {
if (this.params.filter_by[i] === filterName && this.params.filter_comparator[i] === 'greater_equals') {
foundDateStart = i
}
if (this.params.filter_by[i] === filterName && this.params.filter_comparator[i] === 'less_equals') {
foundDateEnd = i
}
if (foundDateStart !== false && foundDateEnd !== false) {
break
}
}
if (foundDateStart !== false && foundDateEnd !== false) {
const startDate = new Date(this.params.filter_value[foundDateStart])
const endDate = new Date(this.params.filter_value[foundDateEnd])
this.filters[variableName] = {
dateFrom: !isNaN(startDate)
? `${startDate.getFullYear()}-${startDate.getMonth() + 1}-${startDate.getDate()}`
: this.params.filter_value[foundDateStart],
dateTo: !isNaN(endDate)
? `${endDate.getFullYear()}-${endDate.getMonth() + 1}-${endDate.getDate()}`
: this.params.filter_value[foundDateEnd],
}
}
},
setSingleValueFilter(filterName, variableName, useVariableName = '', comparator = 'equals') {
if (useVariableName !== '' && !this.filters[useVariableName]) {
this.removePropertyFromFilter(filterName)
return
}
let found = false
this.params.filter_by.forEach((f, i) => {
if (f === filterName) {
found = true
this.params.filter_value[i] = this.filters[variableName]
}
})
if (!found) {
this.params.filter_by.push(filterName)
this.params.filter_comparator.push(comparator)
this.params.filter_value.push(this.filters[variableName])
}
this.change()
},
/**
*
* @param filterName The filter name in the api.
* @param variableName The name of the variable in this.filters.
* @param useVariableName The name of the variable of the "Use this filter" variable. Will only be set if the parameter is not null.
* @param isNumber Toggles if the value should be parsed as a number.
*/
prepareSingleValue(filterName, variableName = null, useVariableName = null, isNumber = false) {
if (variableName === null) {
variableName = filterName
}
let found = false
for (const i in this.params.filter_by) {
if (this.params.filter_by[i] === filterName) {
found = i
break
}
}
if (found === false && useVariableName !== null) {
this.filters[useVariableName] = false
return
}
if (isNumber) {
this.filters[variableName] = Number(this.params.filter_value[found])
} else {
this.filters[variableName] = this.params.filter_value[found]
}
if (useVariableName !== null) {
this.filters[useVariableName] = true
}
},
prepareDone() {
// Set filters.done based on params
if (typeof this.params.filter_by === 'undefined') {
return
}
this.filters.done = this.params.filter_by.some((f) => f === 'done') === false
},
async prepareRelatedObjectFilter(kind, filterName = null, servicePrefix = null) {
if (filterName === null) {
filterName = kind
}
if (servicePrefix === null) {
servicePrefix = kind
}
this.prepareSingleValue(filterName)
if (typeof this.filters[filterName] === 'undefined' || this.filters[filterName] === '') {
return
}
// Don't load things if we already have something loaded.
// This is not the most ideal solution because it prevents a re-population when filters are changed
// from the outside. It is still fine because we're not changing them from the outside, other than
// loading them initially.
if (this[kind].length > 0) {
return
}
this[kind] = await this[`${servicePrefix}Service`].getAll({}, {s: this.filters[filterName]})
},
setDoneFilter() {
if (this.filters.done) {
this.removePropertyFromFilter('done')
} else {
this.params.filter_by.push('done')
this.params.filter_comparator.push('equals')
this.params.filter_value.push('false')
}
this.change()
},
setFilterConcat() {
if (this.filters.requireAllFilters) {
this.params.filter_concat = 'and'
} else {
this.params.filter_concat = 'or'
}
this.change()
},
setPriority() {
this.setSingleValueFilter('priority', 'priority', 'usePriority')
},
setPercentDoneFilter() {
this.setSingleValueFilter('percent_done', 'percentDone', 'usePercentDone')
},
clear(kind) {
this[`found${kind}`] = []
},
async find(kind, query) {
if (query === '') {
this.clear(kind)
return
}
const response = await this[`${kind}Service`].getAll({}, {s: query})
// Filter users from the results who are already assigned
this[`found${kind}`] = response.filter(({id}) => !includesById(this[kind], id))
},
add(kind, filterName) {
this.$nextTick(() => {
this.changeMultiselectFilter(kind, filterName)
})
},
remove(kind, filterName) {
this.$nextTick(() => {
this.changeMultiselectFilter(kind, filterName)
})
},
changeMultiselectFilter(kind, filterName) {
if (this[kind].length === 0) {
this.removePropertyFromFilter(filterName)
this.change()
return
}
const ids = []
this[kind].forEach(u => {
ids.push(kind === 'users' ? u.username : u.id)
})
this.filters[filterName] = ids.join(',')
this.setSingleValueFilter(filterName, filterName, '', 'in')
},
findLabels(query) {
this.labelQuery = query
},
addLabel() {
this.$nextTick(() => {
this.changeLabelFilter()
})
},
removeLabel(label) {
this.$nextTick(() => {
for (const l in this.labels) {
if (this.labels[l].id === label.id) {
this.labels.splice(l, 1)
}
break
}
this.changeLabelFilter()
})
},
changeLabelFilter() {
if (this.labels.length === 0) {
this.removePropertyFromFilter('labels')
this.change()
return
}
const labelIDs = []
this.labels.forEach(u => {
labelIDs.push(u.id)
})
this.filters.labels = labelIDs.join(',')
this.setSingleValueFilter('labels', 'labels', '', 'in')
},
hasTitle: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['update:modelValue'])
const {modelValue} = toRefs(props)
const labelStore = useLabelStore()
const params = ref({...DEFAULT_PARAMS})
const filters = ref({...DEFAULT_FILTERS})
const services = {
users: shallowReactive(new UserService()),
lists: shallowReactive(new ListService()),
namespace: shallowReactive(new NamespaceService()),
}
interface Entities {
users: IUser[]
labels: ILabel[]
lists: IList[]
namespace: INamespace[]
}
type EntityType = 'users' | 'labels' | 'lists' | 'namespace'
const entities: Entities = reactive({
users: [],
labels: [],
lists: [],
namespace: [],
})
onMounted(() => {
filters.value.requireAllFilters = params.value.filter_concat === 'and'
})
watch(
modelValue,
(value) => {
// FIXME: filters should only be converted to snake case in
// the last moment
params.value = objectToSnakeCase(value)
prepareFilters()
},
{immediate: true},
)
const sortAlphabetically = computed({
get() {
return params.value?.sort_by?.find(sortBy => sortBy === ALPHABETICAL_SORT) !== undefined
},
set(sortAlphabetically) {
params.value.sort_by = sortAlphabetically
? [ALPHABETICAL_SORT]
: getDefaultParams().sort_by
change()
},
})
function change() {
const newParams = {...params.value}
newParams.filter_value = newParams.filter_value.map(v => v instanceof Date ? v.toISOString() : v)
emit('update:modelValue', newParams)
}
function prepareFilters() {
prepareDone()
prepareDate('due_date', 'dueDate')
prepareDate('start_date', 'startDate')
prepareDate('end_date', 'endDate')
prepareSingleValue('priority', 'priority', 'usePriority', true)
prepareSingleValue('percent_done', 'percentDone', 'usePercentDone', true)
prepareDate('reminders')
prepareRelatedObjectFilter('users', 'assignees')
prepareRelatedObjectFilter('lists', 'list_id')
prepareRelatedObjectFilter('namespace')
prepareSingleValue('labels')
const newLabels = typeof filters.value.labels === 'string'
? filters.value.labels
: ''
const labelIds = newLabels.split(',').map(i => parseInt(i))
entities.labels = labelStore.getLabelsByIds(labelIds)
}
function removePropertyFromFilter(filterName) {
// Because of the way arrays work, we can only ever remove one element at once.
// To remove multiple filter elements of the same name this function has to be called multiple times.
for (const i in params.value.filter_by) {
if (params.value.filter_by[i] === filterName) {
params.value.filter_by.splice(i, 1)
params.value.filter_comparator.splice(i, 1)
params.value.filter_value.splice(i, 1)
break
}
}
}
function setDateFilter(filterName, {dateFrom, dateTo}) {
dateFrom = parseDateOrString(dateFrom, null)
dateTo = parseDateOrString(dateTo, null)
// Only filter if we have a date
if (dateFrom !== null && dateTo !== null) {
// Check if we already have values in params and only update them if we do
let foundStart = false
let foundEnd = false
params.value.filter_by.forEach((f, i) => {
if (f === filterName && params.value.filter_comparator[i] === 'greater_equals') {
foundStart = true
params.value.filter_value[i] = dateFrom
}
if (f === filterName && params.value.filter_comparator[i] === 'less_equals') {
foundEnd = true
params.value.filter_value[i] = dateTo
}
})
if (!foundStart) {
params.value.filter_by.push(filterName)
params.value.filter_comparator.push('greater_equals')
params.value.filter_value.push(dateFrom)
}
if (!foundEnd) {
params.value.filter_by.push(filterName)
params.value.filter_comparator.push('less_equals')
params.value.filter_value.push(dateTo)
}
filters.value[camelCase(filterName)] = {
// Passing the dates as string values avoids an endless loop between values changing
// in the datepicker (bubbling up to here) and changing here and bubbling down to the
// datepicker (because there's a new date instance every time this function gets called).
// See https://kolaente.dev/vikunja/frontend/issues/2384
dateFrom: dateIsValid(dateFrom) ? formatISO(dateFrom) : dateFrom,
dateTo: dateIsValid(dateTo) ? formatISO(dateTo) : dateTo,
}
change()
return
}
removePropertyFromFilter(filterName)
removePropertyFromFilter(filterName)
change()
}
function prepareDate(filterName, variableName) {
if (typeof params.value.filter_by === 'undefined') {
return
}
let foundDateStart = false
let foundDateEnd = false
for (const i in params.value.filter_by) {
if (params.value.filter_by[i] === filterName && params.value.filter_comparator[i] === 'greater_equals') {
foundDateStart = i
}
if (params.value.filter_by[i] === filterName && params.value.filter_comparator[i] === 'less_equals') {
foundDateEnd = i
}
if (foundDateStart !== false && foundDateEnd !== false) {
break
}
}
if (foundDateStart !== false && foundDateEnd !== false) {
const startDate = new Date(params.value.filter_value[foundDateStart])
const endDate = new Date(params.value.filter_value[foundDateEnd])
filters.value[variableName] = {
dateFrom: !isNaN(startDate)
? `${startDate.getFullYear()}-${startDate.getMonth() + 1}-${startDate.getDate()}`
: params.value.filter_value[foundDateStart],
dateTo: !isNaN(endDate)
? `${endDate.getFullYear()}-${endDate.getMonth() + 1}-${endDate.getDate()}`
: params.value.filter_value[foundDateEnd],
}
}
}
function setSingleValueFilter(filterName, variableName, useVariableName = '', comparator = 'equals') {
if (useVariableName !== '' && !filters.value[useVariableName]) {
removePropertyFromFilter(filterName)
return
}
let found = false
params.value.filter_by.forEach((f, i) => {
if (f === filterName) {
found = true
params.value.filter_value[i] = filters.value[variableName]
}
})
if (!found) {
params.value.filter_by.push(filterName)
params.value.filter_comparator.push(comparator)
params.value.filter_value.push(filters.value[variableName])
}
change()
}
function prepareSingleValue(
/** The filter name in the api. */
filterName,
/** The name of the variable in filters ref. */
variableName = null,
/** The name of the variable of the "Use this filter" variable. Will only be set if the parameter is not null. */
useVariableName = null,
/** Toggles if the value should be parsed as a number. */
isNumber = false,
) {
if (variableName === null) {
variableName = filterName
}
let found = false
for (const i in params.value.filter_by) {
if (params.value.filter_by[i] === filterName) {
found = i
break
}
}
if (found === false && useVariableName !== null) {
filters.value[useVariableName] = false
return
}
if (isNumber) {
filters.value[variableName] = Number(params.value.filter_value[found])
} else {
filters.value[variableName] = params.value.filter_value[found]
}
if (useVariableName !== null) {
filters.value[useVariableName] = true
}
}
function prepareDone() {
// Set filters.done based on params
if (typeof params.value.filter_by === 'undefined') {
return
}
filters.value.done = params.value.filter_by.some((f) => f === 'done') === false
}
async function prepareRelatedObjectFilter(kind: EntityType, filterName = null, servicePrefix: Omit<EntityType, 'labels'> | null = null) {
if (filterName === null) {
filterName = kind
}
if (servicePrefix === null) {
servicePrefix = kind
}
prepareSingleValue(filterName)
if (typeof filters.value[filterName] === 'undefined' || filters.value[filterName] === '') {
return
}
// Don't load things if we already have something loaded.
// This is not the most ideal solution because it prevents a re-population when filters are changed
// from the outside. It is still fine because we're not changing them from the outside, other than
// loading them initially.
if (entities[kind].length > 0) {
return
}
entities[kind] = await services[servicePrefix].getAll({}, {s: filters.value[filterName]})
}
function setDoneFilter() {
if (filters.value.done) {
removePropertyFromFilter('done')
} else {
params.value.filter_by.push('done')
params.value.filter_comparator.push('equals')
params.value.filter_value.push('false')
}
change()
}
function setFilterConcat() {
if (filters.value.requireAllFilters) {
params.value.filter_concat = 'and'
} else {
params.value.filter_concat = 'or'
}
change()
}
function setPriority() {
setSingleValueFilter('priority', 'priority', 'usePriority')
}
function setPercentDoneFilter() {
setSingleValueFilter('percent_done', 'percentDone', 'usePercentDone')
}
async function changeMultiselectFilter(kind: EntityType, filterName) {
await nextTick()
if (entities[kind].length === 0) {
removePropertyFromFilter(filterName)
change()
return
}
const ids = entities[kind].map(u => kind === 'users' ? u.username : u.id)
filters.value[filterName] = ids.join(',')
setSingleValueFilter(filterName, filterName, '', 'in')
}
function changeLabelFilter() {
if (entities.labels.length === 0) {
removePropertyFromFilter('labels')
change()
return
}
const labelIDs = entities.labels.map(u => u.id)
filters.value.labels = labelIDs.join(',')
setSingleValueFilter('labels', 'labels', '', 'in')
}
</script>
<style lang="scss" scoped>

View File

@ -29,10 +29,6 @@ defineProps<DropDownItemProps>()
line-height: 1.5;
padding: $item-padding;
position: relative;
}
a.dropdown-item,
button.dropdown-item {
text-align: inherit;
white-space: nowrap;
width: 100%;
@ -40,33 +36,29 @@ button.dropdown-item {
align-items: center;
justify-content: left !important;
&:hover {
background-color: var(--grey-100) !important;
}
&.is-active {
background-color: var(--link);
color: var(--link-invert);
}
.icon {
padding-right: .5rem;
}
.icon:not(.has-text-success) {
color: var(--grey-300) !important;
}
&.has-text-danger .icon {
color: var(--danger) !important;
&:hover:not(.is-disabled) {
background-color: var(--grey-100);
}
&.is-disabled {
cursor: not-allowed;
&:hover {
background-color: transparent;
}
}
}
.icon {
padding-right: .5rem;
&:not(.has-text-success) {
color: var(--grey-300) !important;
}
}
.has-text-danger .icon {
color: var(--danger) !important;
}
</style>

View File

@ -66,19 +66,8 @@ import {computed, onBeforeUnmount, onMounted, ref, toRefs, useAttrs, watch, watc
const props = defineProps({
modelValue: {
type: [String, Number, Date, Array] as PropType<DateOption | DateOption[]>,
type: [String, Number, Date, Array] as PropType<DateOption | DateOption[] | null>,
default: null,
required: true,
// validator(value) {
// return (
// value === null ||
// value instanceof Date ||
// typeof value === 'string' ||
// value instanceof String ||
// value instanceof Array ||
// typeof value === 'number'
// );
// }
},
// https://flatpickr.js.org/options/
config: {

View File

@ -3,7 +3,7 @@
<div class="offline" style="height: 0;width: 0;"></div>
<div class="app offline" v-if="!online">
<div class="offline-message">
<h1>{{ $t('offline.title') }}</h1>
<h1 class="title">{{ $t('offline.title') }}</h1>
<p>{{ $t('offline.text') }}</p>
</div>
</div>
@ -128,14 +128,14 @@ load()
bottom: 5vh;
color: $white;
padding: 0 1rem;
}
h1 {
font-weight: bold;
font-size: 1.5rem;
text-align: center;
color: $white;
font-weight: 700 !important;
font-size: 1.5rem;
}
.title {
font-weight: bold;
font-size: 1.5rem;
text-align: center;
color: $white;
font-weight: 700 !important;
font-size: 1.5rem;
}
</style>

View File

@ -1,23 +1,27 @@
<template>
<div :class="{'is-inline': isInline}" class="user">
<div
class="user"
:class="{'is-inline': isInline}"
>
<img
:height="avatarSize"
:src="getAvatarUrl(user, avatarSize)"
:width="avatarSize"
alt=""
:alt="'Avatar of ' + displayName"
class="avatar"
v-tooltip="getDisplayName(user)"/>
<span class="username" v-if="showUsername">{{ getDisplayName(user) }}</span>
v-tooltip="displayName"
/>
<span class="username" v-if="showUsername">{{ displayName }}</span>
</div>
</template>
<script lang="ts" setup>
import type {PropType} from 'vue'
import {computed, type PropType} from 'vue'
import {getAvatarUrl, getDisplayName} from '@/models/user'
import type {IUser} from '@/modelTypes/IUser'
defineProps({
const props = defineProps({
user: {
type: Object as PropType<IUser>,
required: true,
@ -38,6 +42,8 @@ defineProps({
default: false,
},
})
const displayName = computed(() => getDisplayName(props.user))
</script>
<style lang="scss" scoped>
@ -47,12 +53,11 @@ defineProps({
&.is-inline {
display: inline;
}
}
img {
border-radius: 100%;
vertical-align: middle;
margin-right: .5rem;
}
.avatar {
border-radius: 100%;
vertical-align: middle;
margin-right: .5rem;
}
</style>

View File

@ -334,35 +334,35 @@ async function setCoverImage(attachment: IAttachment | null) {
&.hidden {
display: none;
}
}
.drop-hint {
position: absolute;
bottom: 0;
left: 0;
right: 0;
.drop-hint {
position: absolute;
bottom: 0;
left: 0;
right: 0;
.icon {
width: 100%;
font-size: 5rem;
height: auto;
text-shadow: var(--shadow-md);
animation: bounce 2s infinite;
.icon {
width: 100%;
font-size: 5rem;
height: auto;
text-shadow: var(--shadow-md);
animation: bounce 2s infinite;
@media (prefers-reduced-motion: reduce) {
animation: none;
}
@media (prefers-reduced-motion: reduce) {
animation: none;
}
}
.hint {
margin: .5rem auto 2rem;
border-radius: 2px;
box-shadow: var(--shadow-md);
background: var(--primary);
padding: 1rem;
color: var(--white);
width: 100%;
max-width: 300px;
}
.hint {
margin: .5rem auto 2rem;
border-radius: 2px;
box-shadow: var(--shadow-md);
background: var(--primary);
padding: 1rem;
color: var(--white);
width: 100%;
max-width: 300px;
}
}

View File

@ -46,3 +46,11 @@ const updatedFormatted = computed(() => formatDateLong(task.value.updated))
const doneSince = computed(() => formatDateSince(task.value.doneAt))
const doneFormatted = computed(() => formatDateLong(task.value.doneAt))
</script>
<style lang="scss" scoped>
.created {
font-size: .75rem;
color: var(--grey-500);
text-align: right;
}
</style>

View File

@ -9,7 +9,6 @@
label="name"
:select-placeholder="$t('task.assignee.selectPlaceholder')"
v-model="assignees"
ref="userSearchInputRef"
>
<template #tag="{item: user}">
<span class="assignee">
@ -106,7 +105,7 @@ async function removeAssignee(user: IUser) {
async function findUser(query: string) {
if (query === '') {
clearAllFoundUsers()
foundUsers.value = []
return
}
@ -121,10 +120,6 @@ async function findUser(query: string) {
return u
})
}
function clearAllFoundUsers() {
foundUsers.value = []
}
</script>
<style lang="scss" scoped>
@ -140,19 +135,20 @@ function clearAllFoundUsers() {
margin-right: 0;
}
.remove-assignee {
position: absolute;
top: 4px;
left: 2px;
color: var(--danger);
background: var(--white);
padding: 0 4px;
display: block;
border-radius: 100%;
font-size: .75rem;
width: 18px;
height: 18px;
z-index: 100;
}
}
.remove-assignee {
position: absolute;
top: 4px;
left: 2px;
color: var(--danger);
background: var(--white);
padding: 0 4px;
display: block;
border-radius: 100%;
font-size: .75rem;
width: 18px;
height: 18px;
z-index: 100;
}
</style>

View File

@ -128,7 +128,6 @@ async function createAndAddLabel(title: string) {
return
}
const labelStore = useLabelStore()
const newLabel = await labelStore.createLabel(new LabelModel({title}))
addLabel(newLabel, false)
labels.value.push(newLabel)

View File

@ -85,9 +85,7 @@ const showSavedMessage = ref(false)
async function save(title: string) {
// We only want to save if the title was actually changed.
// Because the contenteditable does not have a change event
// we're building it ourselves and only continue
// if the task title changed.
// so we only continue if the task title changed.
if (title === props.task.title) {
return
}
@ -110,6 +108,36 @@ async function save(title: string) {
</script>
<style lang="scss" scoped>
.heading {
display: flex;
justify-content: space-between;
text-transform: none;
align-items: center;
@media screen and (max-width: $tablet) {
flex-direction: column;
align-items: start;
}
}
.title {
margin-bottom: 0;
}
.title.input {
// 1.8rem is the font-size, 1.125 is the line-height, .3rem padding everywhere, 1px border around the whole thing.
min-height: calc(1.8rem * 1.125 + .6rem + 2px);
@media screen and (max-width: $tablet) {
margin: 0 -.3rem .5rem -.3rem; // the title has 0.3rem padding - this make the text inside of it align with the rest
}
}
.title.task-id {
color: var(--grey-400);
white-space: nowrap;
}
.heading__done {
margin-left: .5rem;
}

View File

@ -2,12 +2,13 @@
<Multiselect
class="control is-expanded"
:placeholder="$t('list.search')"
@search="findLists"
:search-results="foundLists"
@select="select"
label="title"
v-model="list"
:select-placeholder="$t('list.searchSelect')"
:model-value="list"
@update:model-value="Object.assign(list, $event)"
@select="select"
@search="findLists"
>
<template #searchResult="{option}">
<span class="list-namespace-title search-result">{{ namespace((option as IList).namespaceId) }} ></span>
@ -20,12 +21,16 @@
import {reactive, ref, watch} from 'vue'
import type {PropType} from 'vue'
import {useI18n} from 'vue-i18n'
import ListModel from '@/models/list'
import type {IList} from '@/modelTypes/IList'
import Multiselect from '@/components/input/multiselect.vue'
import type {INamespace} from '@/modelTypes/INamespace'
import {useListStore} from '@/stores/lists'
import {useNamespaceStore} from '@/stores/namespaces'
import type { INamespace } from '@/modelTypes/INamespace'
import ListModel from '@/models/list'
import Multiselect from '@/components/input/multiselect.vue'
const props = defineProps({
modelValue: {

View File

@ -28,13 +28,10 @@ const props = defineProps({
default: false,
},
})
const emit = defineEmits(['update:modelValue'])
const priority = ref(0)
// FIXME: store value outside
// Set the priority to the :value every time it changes from the outside
watch(
() => props.modelValue,
(value) => {

View File

@ -1,55 +1,71 @@
import {computed, ref, watch, type Ref} from 'vue'
import {useRouter, type RouteLocationNormalized, type RouteLocationRaw} from 'vue-router'
import cloneDeep from 'lodash.clonedeep'
import equal from 'fast-deep-equal/es6'
export type Filters = Record<string, any>
export function useRouteFilters<CurrentFilters extends Filters>(
route: Ref<RouteLocationNormalized>,
getDefaultFilters: (route: RouteLocationNormalized) => CurrentFilters,
routeToFilters: (route: RouteLocationNormalized) => CurrentFilters,
filtersToRoute: (filters: CurrentFilters) => RouteLocationRaw,
) {
route: Ref<RouteLocationNormalized>,
getDefaultFilters: (route: RouteLocationNormalized) => CurrentFilters,
routeToFilters: (route: RouteLocationNormalized) => CurrentFilters,
filtersToRoute: (filters: CurrentFilters) => RouteLocationRaw,
) {
const router = useRouter()
const filters = ref<CurrentFilters>(routeToFilters(route.value))
const routeFromFiltersFullPath = computed(() => router.resolve(filtersToRoute(filters.value)).fullPath)
watch(() => cloneDeep(route.value), (route, oldRoute) => {
if (
route.name !== oldRoute.name ||
routeFromFiltersFullPath.value === route.fullPath
) {
return
}
filters.value = routeToFilters(route)
})
watch(
route,
(route, oldRoute) => {
if (
route?.name !== oldRoute?.name ||
routeFromFiltersFullPath.value === route.fullPath
) {
return
}
watch(
filters,
async () => {
if (routeFromFiltersFullPath.value !== route.value.fullPath) {
await router.push(routeFromFiltersFullPath.value)
}
},
// only apply new route after all filters have changed in component cycle
{flush: 'post'},
)
filters.value = routeToFilters(route)
},
{
immediate: true, // set the filter from the initial route
},
)
const hasDefaultFilters = computed(() => {
return equal(filters.value, getDefaultFilters(route.value))
})
watch(
filters,
async () => {
if (routeFromFiltersFullPath.value !== route.value.fullPath) {
await router.push(routeFromFiltersFullPath.value)
}
},
// only apply new route after all filters have changed in component cycle
{
deep: true,
flush: 'post',
},
)
const hasDefaultFilters = ref(false)
watch(
[filters, route],
([filters, route]) => {
hasDefaultFilters.value = equal(filters, getDefaultFilters(route))
},
{
deep: true,
immediate: true,
},
)
function setDefaultFilters() {
filters.value = getDefaultFilters(route.value)
}
return {
filters,
hasDefaultFilters,
setDefaultFilters,
}
return {
filters,
hasDefaultFilters,
setDefaultFilters,
}
}

View File

@ -3,6 +3,7 @@ import {useRoute} from 'vue-router'
import TaskCollectionService from '@/services/taskCollection'
import type {ITask} from '@/modelTypes/ITask'
import {error} from '@/message'
// FIXME: merge with DEFAULT_PARAMS in filters.vue
export const getDefaultParams = () => ({
@ -76,7 +77,11 @@ export function useTaskList(listId, sortByDefault = SORT_BY_DEFAULT) {
const tasks = ref<ITask[]>([])
async function loadTasks() {
tasks.value = []
tasks.value = await taskCollectionService.getAll(...getAllTasksParams.value)
try {
tasks.value = await taskCollectionService.getAll(...getAllTasksParams.value)
} catch (e) {
error(e)
}
return tasks.value
}

View File

@ -1,19 +1,20 @@
import type {IBucket} from '@/modelTypes/IBucket'
import type {IList} from '@/modelTypes/IList'
const key = 'collapsedBuckets'
const getAllState = () => {
const saved = localStorage.getItem(key)
if (saved === null) {
return {}
}
export type CollapsedBuckets = {[id: IBucket['id']]: boolean}
return JSON.parse(saved)
function getAllState() {
const saved = localStorage.getItem(key)
return saved === null
? {}
: JSON.parse(saved)
}
export const saveCollapsedBucketState = (
listId: IList['id'],
collapsedBuckets,
collapsedBuckets: CollapsedBuckets,
) => {
const state = getAllState()
state[listId] = collapsedBuckets
@ -25,11 +26,9 @@ export const saveCollapsedBucketState = (
localStorage.setItem(key, JSON.stringify(state))
}
export const getCollapsedBucketState = (listId : IList['id']) => {
export function getCollapsedBucketState(listId : IList['id']) {
const state = getAllState()
if (typeof state[listId] !== 'undefined') {
return state[listId]
}
return {}
return typeof state[listId] !== 'undefined'
? state[listId]
: {}
}

View File

@ -1,8 +1,16 @@
import {format} from 'date-fns'
import {DATEFNS_DATE_FORMAT_KEBAB} from '@/constants/date'
import type {DateISO} from '@/types/DateISO'
import type {DateKebab} from '@/types/DateKebab'
// ✅ Format a date to YYYY-MM-DD (or any other format)
function padTo2Digits(num: number) {
return num.toString().padStart(2, '0')
}
export function isoToKebabDate(isoDate: DateISO) {
return format(new Date(isoDate), DATEFNS_DATE_FORMAT_KEBAB) as DateKebab
const date = new Date(isoDate)
return [
date.getFullYear(),
padTo2Digits(date.getMonth() + 1), // January is 0, but we want it to be 1
padTo2Digits(date.getDate()),
].join('-') as DateKebab
}

View File

@ -22,7 +22,7 @@ export function parseDateProp(kebabDate: DateKebab | undefined): string | undefi
if (!dateValuesAreValid) {
throw new Error('Invalid date values')
}
return new Date(year, month, date).toISOString() as DateISO
return new Date(year, month - 1, date).toISOString() as DateISO
} catch(e) {
// ignore nonsense route queries
return

View File

@ -6,7 +6,7 @@ export function findById<T extends {id: string | number}>(array : T[], id : stri
return array.find(({id: currentId}) => currentId === id)
}
export function includesById(array: [], id: string | number) {
export function includesById(array: any[], id: string | number) {
return array.some(({id: currentId}) => currentId === id)
}

View File

@ -32,7 +32,7 @@
"usernameEmail": "Gebruikersnaam of e-mailadres",
"usernamePlaceholder": "bv. frederick",
"email": "Email address",
"emailPlaceholder": "bijv. frederic@vikunja.io",
"emailPlaceholder": "bv. frederic{'@'}vikunja.io",
"password": "Wachtwoord",
"passwordPlaceholder": "bv. •••••••••••",
"forgotPassword": "Wachtwoord vergeten?",

File diff suppressed because it is too large Load Diff

View File

@ -566,6 +566,13 @@ describe('Parse Task Text', () => {
expect(result.labels).toHaveLength(1)
expect(result.labels[0]).toBe('label with space')
})
it('should not parse labels called date expressions as dates', () => {
const result = parseTaskText('Lorem Ipsum *today')
expect(result.text).toBe('Lorem Ipsum')
expect(result.labels).toHaveLength(1)
expect(result.labels[0]).toBe('today')
})
})
describe('List', () => {
@ -593,6 +600,12 @@ describe('Parse Task Text', () => {
expect(result.text).toBe('Lorem Ipsum +list2 +list3')
expect(result.list).toBe('list1')
})
it('should parse a list that\'s called like a date as list', () => {
const result = parseTaskText(`Lorem Ipsum +today`)
expect(result.text).toBe('Lorem Ipsum')
expect(result.list).toBe('today')
})
})
describe('Priority', () => {
@ -657,6 +670,13 @@ describe('Parse Task Text', () => {
expect(result.assignees).toHaveLength(1)
expect(result.assignees[0]).toBe('user with long name')
})
it('should parse an assignee who is called like a date as assignee', () => {
const result = parseTaskText(`Lorem Ipsum @today`)
expect(result.text).toBe('Lorem Ipsum')
expect(result.assignees).toHaveLength(1)
expect(result.assignees[0]).toBe('today')
})
})
describe('Recurring Dates', () => {

View File

@ -72,15 +72,19 @@ export const parseTaskText = (text: string, prefixesMode: PrefixMode = PrefixMod
}
result.labels = getItemsFromPrefix(text, prefixes.label)
result.text = cleanupItemText(result.text, result.labels, prefixes.label)
const lists: string[] = getItemsFromPrefix(text, prefixes.list)
const lists: string[] = getItemsFromPrefix(result.text, prefixes.list)
result.list = lists.length > 0 ? lists[0] : null
result.text = result.list !== null ? cleanupItemText(result.text, [result.list], prefixes.list) : result.text
result.priority = getPriority(text, prefixes.priority)
result.priority = getPriority(result.text, prefixes.priority)
result.text = result.priority !== null ? cleanupItemText(result.text, [String(result.priority)], prefixes.priority) : result.text
result.assignees = getItemsFromPrefix(text, prefixes.assignee)
result.assignees = getItemsFromPrefix(result.text, prefixes.assignee)
result.text = cleanupItemText(result.text, result.assignees, prefixes.assignee)
const {textWithoutMatched, repeats} = getRepeats(text)
const {textWithoutMatched, repeats} = getRepeats(result.text)
result.text = textWithoutMatched
result.repeats = repeats
@ -117,7 +121,10 @@ const getItemsFromPrefix = (text: string, prefix: string): string[] => {
// Only until the next space
itemText = p.split(' ')[0]
}
items.push(itemText)
if(itemText !== '') {
items.push(itemText)
}
})
return Array.from(new Set(items))

View File

@ -11,74 +11,74 @@ import {useListStore} from '@/stores/lists'
import {useAuthStore} from '@/stores/auth'
import {useBaseStore} from '@/stores/base'
import HomeComponent from '../views/Home.vue'
import NotFoundComponent from '../views/404.vue'
import About from '../views/About.vue'
import HomeComponent from '@/views/Home.vue'
import NotFoundComponent from '@/views/404.vue'
const About = () => import('@/views/About.vue')
// User Handling
import LoginComponent from '../views/user/Login.vue'
import RegisterComponent from '../views/user/Register.vue'
import OpenIdAuth from '../views/user/OpenIdAuth.vue'
import DataExportDownload from '../views/user/DataExportDownload.vue'
import LoginComponent from '@/views/user/Login.vue'
import RegisterComponent from '@/views/user/Register.vue'
import OpenIdAuth from '@/views/user/OpenIdAuth.vue'
const DataExportDownload = () => import('@/views/user/DataExportDownload.vue')
// Tasks
import UpcomingTasksComponent from '../views/tasks/ShowTasks.vue'
import LinkShareAuthComponent from '../views/sharing/LinkSharingAuth.vue'
import ListNamespaces from '../views/namespaces/ListNamespaces.vue'
const TaskDetailView = () => import('../views/tasks/TaskDetailView.vue')
import UpcomingTasksComponent from '@/views/tasks/ShowTasks.vue'
import LinkShareAuthComponent from '@/views/sharing/LinkSharingAuth.vue'
const ListNamespaces = () => import('@/views/namespaces/ListNamespaces.vue')
const TaskDetailView = () => import('@/views/tasks/TaskDetailView.vue')
// Team Handling
import ListTeamsComponent from '../views/teams/ListTeams.vue'
const ListTeamsComponent = () => import('@/views/teams/ListTeams.vue')
// Label Handling
import ListLabelsComponent from '../views/labels/ListLabels.vue'
import NewLabelComponent from '../views/labels/NewLabel.vue'
const ListLabelsComponent = () => import('@/views/labels/ListLabels.vue')
const NewLabelComponent = () => import('@/views/labels/NewLabel.vue')
// Migration
const MigrationComponent = () => import('@/views/migrate/Migration.vue')
const MigrationHandlerComponent = () => import('@/views/migrate/MigrationHandler.vue')
// List Views
import ListList from '../views/list/ListList.vue'
const ListGantt = () => import('../views/list/ListGantt.vue')
import ListTable from '../views/list/ListTable.vue'
import ListKanban from '../views/list/ListKanban.vue'
const ListInfo = () => import('../views/list/ListInfo.vue')
const ListList = () => import('@/views/list/ListList.vue')
const ListGantt = () => import('@/views/list/ListGantt.vue')
const ListTable = () => import('@/views/list/ListTable.vue')
const ListKanban = () => import('@/views/list/ListKanban.vue')
const ListInfo = () => import('@/views/list/ListInfo.vue')
// List Settings
import ListSettingEdit from '../views/list/settings/edit.vue'
import ListSettingBackground from '../views/list/settings/background.vue'
import ListSettingDuplicate from '../views/list/settings/duplicate.vue'
import ListSettingShare from '../views/list/settings/share.vue'
import ListSettingDelete from '../views/list/settings/delete.vue'
import ListSettingArchive from '../views/list/settings/archive.vue'
const ListSettingEdit = () => import('@/views/list/settings/edit.vue')
const ListSettingBackground = () => import('@/views/list/settings/background.vue')
const ListSettingDuplicate = () => import('@/views/list/settings/duplicate.vue')
const ListSettingShare = () => import('@/views/list/settings/share.vue')
const ListSettingDelete = () => import('@/views/list/settings/delete.vue')
const ListSettingArchive = () => import('@/views/list/settings/archive.vue')
// Namespace Settings
import NamespaceSettingEdit from '../views/namespaces/settings/edit.vue'
import NamespaceSettingShare from '../views/namespaces/settings/share.vue'
import NamespaceSettingArchive from '../views/namespaces/settings/archive.vue'
import NamespaceSettingDelete from '../views/namespaces/settings/delete.vue'
const NamespaceSettingEdit = () => import('@/views/namespaces/settings/edit.vue')
const NamespaceSettingShare = () => import('@/views/namespaces/settings/share.vue')
const NamespaceSettingArchive = () => import('@/views/namespaces/settings/archive.vue')
const NamespaceSettingDelete = () => import('@/views/namespaces/settings/delete.vue')
// Saved Filters
import FilterNew from '@/views/filters/FilterNew.vue'
import FilterEdit from '@/views/filters/FilterEdit.vue'
import FilterDelete from '@/views/filters/FilterDelete.vue'
const FilterNew = () => import('@/views/filters/FilterNew.vue')
const FilterEdit = () => import('@/views/filters/FilterEdit.vue')
const FilterDelete = () => import('@/views/filters/FilterDelete.vue')
const PasswordResetComponent = () => import('../views/user/PasswordReset.vue')
const GetPasswordResetComponent = () => import('../views/user/RequestPasswordReset.vue')
const UserSettingsComponent = () => import('../views/user/Settings.vue')
const UserSettingsAvatarComponent = () => import('../views/user/settings/Avatar.vue')
const UserSettingsCaldavComponent = () => import('../views/user/settings/Caldav.vue')
const UserSettingsDataExportComponent = () => import('../views/user/settings/DataExport.vue')
const UserSettingsDeletionComponent = () => import('../views/user/settings/Deletion.vue')
const UserSettingsEmailUpdateComponent = () => import('../views/user/settings/EmailUpdate.vue')
const UserSettingsGeneralComponent = () => import('../views/user/settings/General.vue')
const UserSettingsPasswordUpdateComponent = () => import('../views/user/settings/PasswordUpdate.vue')
const UserSettingsTOTPComponent = () => import('../views/user/settings/TOTP.vue')
const PasswordResetComponent = () => import('@/views/user/PasswordReset.vue')
const GetPasswordResetComponent = () => import('@/views/user/RequestPasswordReset.vue')
const UserSettingsComponent = () => import('@/views/user/Settings.vue')
const UserSettingsAvatarComponent = () => import('@/views/user/settings/Avatar.vue')
const UserSettingsCaldavComponent = () => import('@/views/user/settings/Caldav.vue')
const UserSettingsDataExportComponent = () => import('@/views/user/settings/DataExport.vue')
const UserSettingsDeletionComponent = () => import('@/views/user/settings/Deletion.vue')
const UserSettingsEmailUpdateComponent = () => import('@/views/user/settings/EmailUpdate.vue')
const UserSettingsGeneralComponent = () => import('@/views/user/settings/General.vue')
const UserSettingsPasswordUpdateComponent = () => import('@/views/user/settings/PasswordUpdate.vue')
const UserSettingsTOTPComponent = () => import('@/views/user/settings/TOTP.vue')
// List Handling
const NewListComponent = () => import('../views/list/NewList.vue')
const NewListComponent = () => import('@/views/list/NewList.vue')
// Namespace Handling
const NewNamespaceComponent = () => import('../views/namespaces/NewNamespace.vue')
const NewNamespaceComponent = () => import('@/views/namespaces/NewNamespace.vue')
const EditTeamComponent = () => import('../views/teams/EditTeam.vue')
const NewTeamComponent = () => import('../views/teams/NewTeam.vue')
const EditTeamComponent = () => import('@/views/teams/EditTeam.vue')
const NewTeamComponent = () => import('@/views/teams/NewTeam.vue')
const router = createRouter({
history: createWebHistory(),

View File

@ -1,3 +1,4 @@
import {computed, readonly, ref} from 'vue'
import {defineStore, acceptHMRUpdate} from 'pinia'
import cloneDeep from 'lodash.clonedeep'
@ -10,15 +11,15 @@ import TaskCollectionService from '@/services/taskCollection'
import {setModuleLoading} from '@/stores/helper'
import type { ITask } from '@/modelTypes/ITask'
import type { IList } from '@/modelTypes/IList'
import type { IBucket } from '@/modelTypes/IBucket'
import type {ITask} from '@/modelTypes/ITask'
import type {IList} from '@/modelTypes/IList'
import type {IBucket} from '@/modelTypes/IBucket'
const TASKS_PER_BUCKET = 25
function getTaskIndicesById(state: KanbanState, taskId: ITask['id']) {
function getTaskIndicesById(buckets: IBucket[], taskId: ITask['id']) {
let taskIndex
const bucketIndex = state.buckets.findIndex(({ tasks }) => {
const bucketIndex = buckets.findIndex(({ tasks }) => {
taskIndex = findIndexById(tasks, taskId)
return taskIndex !== -1
})
@ -29,381 +30,365 @@ function getTaskIndicesById(state: KanbanState, taskId: ITask['id']) {
}
}
const addTaskToBucketAndSort = (state: KanbanState, task: ITask) => {
const bucketIndex = findIndexById(state.buckets, task.bucketId)
if(typeof state.buckets[bucketIndex] === 'undefined') {
const addTaskToBucketAndSort = (buckets: IBucket[], task: ITask) => {
const bucketIndex = findIndexById(buckets, task.bucketId)
if(typeof buckets[bucketIndex] === 'undefined') {
return
}
state.buckets[bucketIndex].tasks.push(task)
state.buckets[bucketIndex].tasks.sort((a, b) => a.kanbanPosition > b.kanbanPosition ? 1 : -1)
}
export interface KanbanState {
buckets: IBucket[],
listId: IList['id'],
bucketLoading: {
[id: IBucket['id']]: boolean
},
taskPagesPerBucket: {
[id: IBucket['id']]: number
},
allTasksLoadedForBucket: {
[id: IBucket['id']]: boolean
},
isLoading: boolean,
buckets[bucketIndex].tasks.push(task)
buckets[bucketIndex].tasks.sort((a, b) => a.kanbanPosition > b.kanbanPosition ? 1 : -1)
}
/**
* This store is intended to hold the currently active kanban view.
* It should hold only the current buckets.
*/
export const useKanbanStore = defineStore('kanban', {
state: () : KanbanState => ({
buckets: [],
listId: 0,
bucketLoading: {},
taskPagesPerBucket: {},
allTasksLoadedForBucket: {},
isLoading: false,
}),
export const useKanbanStore = defineStore('kanban', () => {
const buckets = ref<IBucket[]>([])
const listId = ref<IList['id']>(0)
const bucketLoading = ref<{[id: IBucket['id']]: boolean}>({})
const taskPagesPerBucket = ref<{[id: IBucket['id']]: number}>({})
const allTasksLoadedForBucket = ref<{[id: IBucket['id']]: boolean}>({})
const isLoading = ref(false)
getters: {
getBucketById(state) {
return (bucketId: IBucket['id']) => findById(state.buckets, bucketId)
},
getTaskById(state) {
return (id: ITask['id']) => {
const { bucketIndex, taskIndex } = getTaskIndicesById(state, id)
return {
bucketIndex,
taskIndex,
task: bucketIndex !== null && taskIndex !== null && state.buckets[bucketIndex]?.tasks?.[taskIndex] || null,
}
const getBucketById = computed(() => (bucketId: IBucket['id']): IBucket | undefined => findById(buckets.value, bucketId))
const getTaskById = computed(() => {
return (id: ITask['id']) => {
const { bucketIndex, taskIndex } = getTaskIndicesById(buckets.value, id)
return {
bucketIndex,
taskIndex,
task: bucketIndex !== null && taskIndex !== null && buckets.value[bucketIndex]?.tasks?.[taskIndex] || null,
}
},
},
}
})
actions: {
setIsLoading(isLoading: boolean) {
this.isLoading = isLoading
},
function setIsLoading(newIsLoading: boolean) {
isLoading.value = newIsLoading
}
setListId(listId: IList['id']) {
this.listId = Number(listId)
},
function setListId(newListId: IList['id']) {
listId.value = Number(newListId)
}
setBuckets(buckets: IBucket[]) {
this.buckets = buckets
buckets.forEach(b => {
this.taskPagesPerBucket[b.id] = 1
this.allTasksLoadedForBucket[b.id] = false
})
},
function setBuckets(newBuckets: IBucket[]) {
buckets.value = newBuckets
newBuckets.forEach(b => {
taskPagesPerBucket.value[b.id] = 1
allTasksLoadedForBucket.value[b.id] = false
})
}
addBucket(bucket: IBucket) {
this.buckets.push(bucket)
},
function addBucket(bucket: IBucket) {
buckets.value.push(bucket)
}
removeBucket(bucket: IBucket) {
const bucketIndex = findIndexById(this.buckets, bucket.id)
this.buckets.splice(bucketIndex, 1)
},
function removeBucket(newBucket: IBucket) {
const bucketIndex = findIndexById(buckets.value, newBucket.id)
buckets.value.splice(bucketIndex, 1)
}
setBucketById(bucket: IBucket) {
const bucketIndex = findIndexById(this.buckets, bucket.id)
this.buckets[bucketIndex] = bucket
},
function setBucketById(newBucket: IBucket) {
const bucketIndex = findIndexById(buckets.value, newBucket.id)
buckets.value[bucketIndex] = newBucket
}
setBucketByIndex({
bucketIndex,
bucket,
} : {
bucketIndex: number,
bucket: IBucket
}) {
this.buckets[bucketIndex] = bucket
},
function setBucketByIndex({
bucketIndex,
bucket,
} : {
bucketIndex: number,
bucket: IBucket
}) {
buckets.value[bucketIndex] = bucket
}
setTaskInBucketByIndex({
bucketIndex,
taskIndex,
task,
} : {
bucketIndex: number,
taskIndex: number,
task: ITask
}) {
const bucket = this.buckets[bucketIndex]
bucket.tasks[taskIndex] = task
this.buckets[bucketIndex] = bucket
},
function setTaskInBucketByIndex({
bucketIndex,
taskIndex,
task,
} : {
bucketIndex: number,
taskIndex: number,
task: ITask
}) {
const bucket = buckets.value[bucketIndex]
bucket.tasks[taskIndex] = task
buckets.value[bucketIndex] = bucket
}
setTasksInBucketByBucketId({
bucketId,
tasks,
} : {
bucketId: IBucket['id'],
tasks: ITask[],
}) {
const bucketIndex = findIndexById(this.buckets, bucketId)
this.buckets[bucketIndex] = {
...this.buckets[bucketIndex],
tasks,
}
},
setTaskInBucket(task: ITask) {
// If this gets invoked without any tasks actually loaded, we can save the hassle of finding the task
if (this.buckets.length === 0) {
return
}
function setTaskInBucket(task: ITask) {
// If this gets invoked without any tasks actually loaded, we can save the hassle of finding the task
if (buckets.value.length === 0) {
return
}
let found = false
let found = false
const findAndUpdate = b => {
for (const t in this.buckets[b].tasks) {
if (this.buckets[b].tasks[t].id === task.id) {
const bucket = this.buckets[b]
bucket.tasks[t] = task
const findAndUpdate = b => {
for (const t in buckets.value[b].tasks) {
if (buckets.value[b].tasks[t].id === task.id) {
const bucket = buckets.value[b]
bucket.tasks[t] = task
if (bucket.id !== task.bucketId) {
bucket.tasks.splice(t, 1)
addTaskToBucketAndSort(this, task)
}
this.buckets[b] = bucket
found = true
return
if (bucket.id !== task.bucketId) {
bucket.tasks.splice(t, 1)
addTaskToBucketAndSort(buckets.value, task)
}
buckets.value[b] = bucket
found = true
return
}
}
}
for (const b in this.buckets) {
if (this.buckets[b].id === task.bucketId) {
findAndUpdate(b)
if (found) {
return
}
}
}
for (const b in this.buckets) {
for (const b in buckets.value) {
if (buckets.value[b].id === task.bucketId) {
findAndUpdate(b)
if (found) {
return
}
}
},
}
addTaskToBucket(task: ITask) {
const bucketIndex = findIndexById(this.buckets, task.bucketId)
const oldBucket = this.buckets[bucketIndex]
const newBucket = {
...oldBucket,
tasks: [
...oldBucket.tasks,
task,
],
}
this.buckets[bucketIndex] = newBucket
},
addTasksToBucket({tasks, bucketId}: {
tasks: ITask[];
bucketId: IBucket['id'];
}) {
const bucketIndex = findIndexById(this.buckets, bucketId)
const oldBucket = this.buckets[bucketIndex]
const newBucket = {
...oldBucket,
tasks: [
...oldBucket.tasks,
...tasks,
],
}
this.buckets[bucketIndex] = newBucket
},
removeTaskInBucket(task: ITask) {
// If this gets invoked without any tasks actually loaded, we can save the hassle of finding the task
if (this.buckets.length === 0) {
for (const b in buckets.value) {
findAndUpdate(b)
if (found) {
return
}
}
}
const { bucketIndex, taskIndex } = getTaskIndicesById(this, task.id)
function addTaskToBucket(task: ITask) {
const bucketIndex = findIndexById(buckets.value, task.bucketId)
const oldBucket = buckets.value[bucketIndex]
const newBucket = {
...oldBucket,
tasks: [
...oldBucket.tasks,
task,
],
}
buckets.value[bucketIndex] = newBucket
}
if (
!bucketIndex ||
this.buckets[bucketIndex]?.id !== task.bucketId ||
!taskIndex ||
(this.buckets[bucketIndex]?.tasks[taskIndex]?.id !== task.id)
) {
return
}
this.buckets[bucketIndex].tasks.splice(taskIndex, 1)
},
function addTasksToBucket({tasks, bucketId}: {
tasks: ITask[];
bucketId: IBucket['id'];
}) {
const bucketIndex = findIndexById(buckets.value, bucketId)
const oldBucket = buckets.value[bucketIndex]
const newBucket = {
...oldBucket,
tasks: [
...oldBucket.tasks,
...tasks,
],
}
buckets.value[bucketIndex] = newBucket
}
setBucketLoading({bucketId, loading}: {bucketId: IBucket['id'], loading: boolean}) {
this.bucketLoading[bucketId] = loading
},
function removeTaskInBucket(task: ITask) {
// If this gets invoked without any tasks actually loaded, we can save the hassle of finding the task
if (buckets.value.length === 0) {
return
}
setTasksLoadedForBucketPage({bucketId, page}: {bucketId: IBucket['id'], page: number}) {
this.taskPagesPerBucket[bucketId] = page
},
const { bucketIndex, taskIndex } = getTaskIndicesById(buckets.value, task.id)
setAllTasksLoadedForBucket(bucketId: IBucket['id']) {
this.allTasksLoadedForBucket[bucketId] = true
},
async loadBucketsForList({listId, params}: {listId: IList['id'], params}) {
const cancel = setModuleLoading(this)
// Clear everything to prevent having old buckets in the list if loading the buckets from this list takes a few moments
this.setBuckets([])
params.per_page = TASKS_PER_BUCKET
const bucketService = new BucketService()
try {
const buckets = await bucketService.getAll({listId}, params)
this.setBuckets(buckets)
this.setListId(listId)
return buckets
} finally {
cancel()
}
},
async loadNextTasksForBucket(
{listId, ps = {}, bucketId} :
{listId: IList['id'], ps, bucketId: IBucket['id']},
if (
bucketIndex === null ||
buckets.value[bucketIndex]?.id !== task.bucketId ||
taskIndex === null ||
(buckets.value[bucketIndex]?.tasks[taskIndex]?.id !== task.id)
) {
const isLoading = this.bucketLoading[bucketId] ?? false
if (isLoading) {
return
}
return
}
buckets.value[bucketIndex].tasks.splice(taskIndex, 1)
}
const page = (this.taskPagesPerBucket[bucketId] ?? 1) + 1
function setBucketLoading({bucketId, loading}: {bucketId: IBucket['id'], loading: boolean}) {
bucketLoading.value[bucketId] = loading
}
const alreadyLoaded = this.allTasksLoadedForBucket[bucketId] ?? false
if (alreadyLoaded) {
return
}
function setTasksLoadedForBucketPage({bucketId, page}: {bucketId: IBucket['id'], page: number}) {
taskPagesPerBucket.value[bucketId] = page
}
const cancel = setModuleLoading(this)
this.setBucketLoading({bucketId: bucketId, loading: true})
function setAllTasksLoadedForBucket(bucketId: IBucket['id']) {
allTasksLoadedForBucket.value[bucketId] = true
}
const params = JSON.parse(JSON.stringify(ps))
async function loadBucketsForList({listId, params}: {listId: IList['id'], params}) {
const cancel = setModuleLoading(this, setIsLoading)
params.sort_by = 'kanban_position'
params.order_by = 'asc'
// Clear everything to prevent having old buckets in the list if loading the buckets from this list takes a few moments
setBuckets([])
let hasBucketFilter = false
for (const f in params.filter_by) {
if (params.filter_by[f] === 'bucket_id') {
hasBucketFilter = true
if (params.filter_value[f] !== bucketId) {
params.filter_value[f] = bucketId
}
break
const bucketService = new BucketService()
try {
const newBuckets = await bucketService.getAll({listId}, {
...params,
per_page: TASKS_PER_BUCKET,
})
setBuckets(newBuckets)
setListId(listId)
return newBuckets
} finally {
cancel()
}
}
async function loadNextTasksForBucket(
{listId, ps = {}, bucketId} :
{listId: IList['id'], ps, bucketId: IBucket['id']},
) {
const isLoading = bucketLoading.value[bucketId] ?? false
if (isLoading) {
return
}
const page = (taskPagesPerBucket.value[bucketId] ?? 1) + 1
const alreadyLoaded = allTasksLoadedForBucket.value[bucketId] ?? false
if (alreadyLoaded) {
return
}
const cancel = setModuleLoading(this, setIsLoading)
setBucketLoading({bucketId: bucketId, loading: true})
const params = JSON.parse(JSON.stringify(ps))
params.sort_by = 'kanban_position'
params.order_by = 'asc'
let hasBucketFilter = false
for (const f in params.filter_by) {
if (params.filter_by[f] === 'bucket_id') {
hasBucketFilter = true
if (params.filter_value[f] !== bucketId) {
params.filter_value[f] = bucketId
}
break
}
}
if (!hasBucketFilter) {
params.filter_by = [...(params.filter_by ?? []), 'bucket_id']
params.filter_value = [...(params.filter_value ?? []), bucketId]
params.filter_comparator = [...(params.filter_comparator ?? []), 'equals']
if (!hasBucketFilter) {
params.filter_by = [...(params.filter_by ?? []), 'bucket_id']
params.filter_value = [...(params.filter_value ?? []), bucketId]
params.filter_comparator = [...(params.filter_comparator ?? []), 'equals']
}
params.per_page = TASKS_PER_BUCKET
const taskService = new TaskCollectionService()
try {
const tasks = await taskService.getAll({listId}, params, page)
addTasksToBucket({tasks, bucketId: bucketId})
setTasksLoadedForBucketPage({bucketId, page})
if (taskService.totalPages <= page) {
setAllTasksLoadedForBucket(bucketId)
}
return tasks
} finally {
cancel()
setBucketLoading({bucketId, loading: false})
}
}
params.per_page = TASKS_PER_BUCKET
async function createBucket(bucket: IBucket) {
const cancel = setModuleLoading(this, setIsLoading)
const taskService = new TaskCollectionService()
try {
const tasks = await taskService.getAll({listId}, params, page)
this.addTasksToBucket({tasks, bucketId: bucketId})
this.setTasksLoadedForBucketPage({bucketId, page})
if (taskService.totalPages <= page) {
this.setAllTasksLoadedForBucket(bucketId)
}
return tasks
} finally {
cancel()
this.setBucketLoading({bucketId, loading: false})
}
},
const bucketService = new BucketService()
try {
const createdBucket = await bucketService.create(bucket)
addBucket(createdBucket)
return createdBucket
} finally {
cancel()
}
}
async createBucket(bucket: IBucket) {
const cancel = setModuleLoading(this)
async function deleteBucket({bucket, params}: {bucket: IBucket, params}) {
const cancel = setModuleLoading(this, setIsLoading)
const bucketService = new BucketService()
try {
const createdBucket = await bucketService.create(bucket)
this.addBucket(createdBucket)
return createdBucket
} finally {
cancel()
}
},
const bucketService = new BucketService()
try {
const response = await bucketService.delete(bucket)
removeBucket(bucket)
// We reload all buckets because tasks are being moved from the deleted bucket
loadBucketsForList({listId: bucket.listId, params})
return response
} finally {
cancel()
}
}
async deleteBucket({bucket, params}: {bucket: IBucket, params}) {
const cancel = setModuleLoading(this)
async function updateBucket(updatedBucketData: Partial<IBucket>) {
const cancel = setModuleLoading(this, setIsLoading)
const bucketService = new BucketService()
try {
const response = await bucketService.delete(bucket)
this.removeBucket(bucket)
// We reload all buckets because tasks are being moved from the deleted bucket
this.loadBucketsForList({listId: bucket.listId, params})
return response
} finally {
cancel()
}
},
const bucketIndex = findIndexById(buckets.value, updatedBucketData.id)
const oldBucket = cloneDeep(buckets.value[bucketIndex])
async updateBucket(updatedBucketData: Partial<IBucket>) {
const cancel = setModuleLoading(this)
const updatedBucket = {
...oldBucket,
...updatedBucketData,
}
const bucketIndex = findIndexById(this.buckets, updatedBucketData.id)
const oldBucket = cloneDeep(this.buckets[bucketIndex])
setBucketByIndex({bucketIndex, bucket: updatedBucket})
const bucketService = new BucketService()
try {
const returnedBucket = await bucketService.update(updatedBucket)
setBucketByIndex({bucketIndex, bucket: returnedBucket})
return returnedBucket
} catch(e) {
// restore original state
setBucketByIndex({bucketIndex, bucket: oldBucket})
const updatedBucket = {
...oldBucket,
...updatedBucketData,
}
throw e
} finally {
cancel()
}
}
this.setBucketByIndex({bucketIndex, bucket: updatedBucket})
const bucketService = new BucketService()
try {
const returnedBucket = await bucketService.update(updatedBucket)
this.setBucketByIndex({bucketIndex, bucket: returnedBucket})
return returnedBucket
} catch(e) {
// restore original state
this.setBucketByIndex({bucketIndex, bucket: oldBucket})
async function updateBucketTitle({ id, title }: { id: IBucket['id'], title: IBucket['title'] }) {
const bucket = findById(buckets.value, id)
throw e
} finally {
cancel()
}
},
if (bucket?.title === title) {
// bucket title has not changed
return
}
async updateBucketTitle({ id, title }: { id: IBucket['id'], title: IBucket['title'] }) {
const bucket = findById(this.buckets, id)
await updateBucket({ id, title })
success({message: i18n.global.t('list.kanban.bucketTitleSavedSuccess')})
}
return {
buckets: readonly(buckets),
isLoading: readonly(isLoading),
getBucketById,
getTaskById,
if (bucket?.title === title) {
// bucket title has not changed
return
}
await this.updateBucket({ id, title })
success({message: i18n.global.t('list.kanban.bucketTitleSavedSuccess')})
},
},
setBuckets,
setBucketById,
setTaskInBucketByIndex,
setTaskInBucket,
addTaskToBucket,
removeTaskInBucket,
loadBucketsForList,
loadNextTasksForBucket,
createBucket,
deleteBucket,
updateBucket,
updateBucketTitle,
}
})
// support hot reloading

View File

@ -65,7 +65,6 @@ async function newLabel() {
}
showError.value = false
const labelStore = useLabelStore()
const newLabel = await labelStore.createLabel(label.value)
router.push({
name: 'labels.index',

View File

@ -57,7 +57,7 @@ import {useBaseStore} from '@/stores/base'
import {useAuthStore} from '@/stores/auth'
import Foo from '@/components/misc/flatpickr/Flatpickr.vue'
import ListWrapper from './ListWrapper.vue'
import ListWrapper from '@/components/list/ListWrapper.vue'
import Fancycheckbox from '@/components/input/fancycheckbox.vue'
import TaskForm from '@/components/tasks/TaskForm.vue'
@ -88,9 +88,15 @@ const {
updateTask,
} = useGanttFilters(route)
const today = new Date(new Date().setHours(0,0,0,0))
const defaultTaskStartDate: DateISO = new Date(today).toISOString()
const defaultTaskEndDate: DateISO = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 7, 23,59,0,0).toISOString()
const DEFAULT_DATE_RANGE_DAYS = 7
const today = new Date()
const defaultTaskStartDate: DateISO = new Date(today.setHours(0, 0, 0, 0)).toISOString()
const defaultTaskEndDate: DateISO = new Date(new Date(
today.getFullYear(),
today.getMonth(),
today.getDate() + DEFAULT_DATE_RANGE_DAYS,
).setHours(23, 59, 0, 0)).toISOString()
async function addGanttTask(title: ITask['title']) {
return await addTask({

View File

@ -1,12 +1,13 @@
<template>
<ListWrapper class="list-kanban" :list-id="listId" viewName="kanban">
<ListWrapper
class="list-kanban"
:list-id="listId"
viewName="kanban"
>
<template #header>
<div class="filter-container" v-if="!isSavedFilter(listId)">
<div class="filter-container" v-if="!isSavedFilter(list)">
<div class="items">
<filter-popup
v-model="params"
@update:modelValue="loadBuckets"
/>
<filter-popup v-model="params" />
</div>
</div>
</template>
@ -18,7 +19,7 @@
class="kanban kanban-bucket-container loader-container"
>
<draggable
v-bind="dragOptions"
v-bind="DRAG_OPTIONS"
:modelValue="buckets"
@update:modelValue="updateBuckets"
@end="updateBucketPosition"
@ -26,7 +27,7 @@
group="buckets"
:disabled="!canWrite || newTaskInputFocused"
tag="ul"
:item-key="({id}) => `bucket${id}`"
:item-key="({id}: IBucket) => `bucket${id}`"
:component-data="bucketDraggableComponentData"
>
<template #item="{element: bucket, index: bucketIndex }">
@ -43,9 +44,9 @@
<icon icon="check-double"/>
</span>
<h2
@keydown.enter.prevent.stop="$event.target.blur()"
@keydown.esc.prevent.stop="$event.target.blur()"
@blur="saveBucketTitle(bucket.id, $event.target.textContent)"
@keydown.enter.prevent.stop="($event.target as HTMLElement).blur()"
@keydown.esc.prevent.stop="($event.target as HTMLElement).blur()"
@blur="saveBucketTitle(bucket.id, ($event.target as HTMLElement).textContent as string)"
@click="focusBucketTitle"
class="title input"
:contenteditable="(bucketTitleEditable && canWrite && !collapsedBuckets[bucket.id]) ? true : undefined"
@ -71,7 +72,7 @@
@keyup.esc="() => showSetLimitInput = false"
@keyup.enter="() => showSetLimitInput = false"
:value="bucket.limit"
@input="(event) => setBucketLimit(bucket.id, parseInt(event.target.value))"
@input="(event) => setBucketLimit(bucket.id, parseInt((event.target as HTMLInputElement).value))"
class="input"
type="number"
min="0"
@ -122,7 +123,7 @@
</div>
<draggable
v-bind="dragOptions"
v-bind="DRAG_OPTIONS"
:modelValue="bucket.tasks"
@update:modelValue="(tasks) => updateTasks(bucket.id, tasks)"
@start="() => dragstart(bucket)"
@ -131,7 +132,7 @@
:disabled="!canWrite"
:data-bucket-index="bucketIndex"
tag="ul"
:item-key="(task) => `bucket${bucket.id}-task${task.id}`"
:item-key="(task: ITask) => `bucket${bucket.id}-task${task.id}`"
:component-data="getTaskDraggableTaskComponentData(bucket)"
>
<template #footer>
@ -184,7 +185,7 @@
:disabled="loading || undefined"
@blur="() => showNewBucketInput = false"
@keyup.enter="createNewBucket"
@keyup.esc="$event.target.blur()"
@keyup.esc="($event.target as HTMLInputElement).blur()"
class="input"
:placeholder="$t('list.kanban.addBucketPlaceholder')"
type="text"
@ -224,27 +225,35 @@
</ListWrapper>
</template>
<script lang="ts">
import {defineComponent} from 'vue'
<script setup lang="ts">
import {computed, nextTick, ref, watch, type PropType} from 'vue'
import {useI18n} from 'vue-i18n'
import draggable from 'zhyswan-vuedraggable'
import cloneDeep from 'lodash.clonedeep'
import {mapState} from 'pinia'
import BucketModel from '../../models/bucket'
import {RIGHTS as Rights} from '@/constants/rights'
import ListWrapper from './ListWrapper.vue'
import FilterPopup from '@/components/list/partials/filter-popup.vue'
import Dropdown from '@/components/misc/dropdown.vue'
import {getCollapsedBucketState, saveCollapsedBucketState} from '@/helpers/saveCollapsedBucketState'
import {calculateItemPosition} from '../../helpers/calculateItemPosition'
import KanbanCard from '@/components/tasks/partials/kanban-card.vue'
import DropdownItem from '@/components/misc/dropdown-item.vue'
import {isSavedFilter} from '@/services/savedFilter'
import BucketModel from '@/models/bucket'
import type {IBucket} from '@/modelTypes/IBucket'
import type {IList} from '@/modelTypes/IList'
import type {ITask} from '@/modelTypes/ITask'
import {useBaseStore} from '@/stores/base'
import {useTaskStore} from '@/stores/tasks'
import {useKanbanStore} from '@/stores/kanban'
import ListWrapper from '@/components/list/ListWrapper.vue'
import FilterPopup from '@/components/list/partials/filter-popup.vue'
import KanbanCard from '@/components/tasks/partials/kanban-card.vue'
import Dropdown from '@/components/misc/dropdown.vue'
import DropdownItem from '@/components/misc/dropdown-item.vue'
import {getCollapsedBucketState, saveCollapsedBucketState, type CollapsedBuckets} from '@/helpers/saveCollapsedBucketState'
import {calculateItemPosition} from '@/helpers/calculateItemPosition'
import {isSavedFilter} from '@/services/savedFilter'
import {success} from '@/message'
const DRAG_OPTIONS = {
// sortable options
animation: 150,
@ -252,382 +261,346 @@ const DRAG_OPTIONS = {
dragClass: 'task-dragging',
delayOnTouchOnly: true,
delay: 150,
}
} as const
const MIN_SCROLL_HEIGHT_PERCENT = 0.25
export default defineComponent({
name: 'Kanban',
components: {
DropdownItem,
ListWrapper,
KanbanCard,
Dropdown,
FilterPopup,
draggable,
},
props: {
listId: {
type: Number,
required: true,
},
},
data() {
return {
taskContainerRefs: {},
dragOptions: DRAG_OPTIONS,
drag: false,
dragBucket: false,
sourceBucket: 0,
showBucketDeleteModal: false,
bucketToDelete: 0,
bucketTitleEditable: false,
newTaskText: '',
showNewTaskInput: {},
newBucketTitle: '',
showNewBucketInput: false,
newTaskError: {},
showSetLimitInput: false,
collapsedBuckets: {},
newTaskInputFocused: false,
// We're using this to show the loading animation only at the task when updating it
taskUpdating: {},
oneTaskUpdating: false,
params: {
filter_by: [],
filter_value: [],
filter_comparator: [],
filter_concat: 'and',
},
}
},
watch: {
loadBucketParameter: {
handler: 'loadBuckets',
immediate: true,
},
},
computed: {
getTaskDraggableTaskComponentData() {
return (bucket) => ({
ref: (el) => this.setTaskContainerRef(bucket.id, el),
onScroll: (event) => this.handleTaskContainerScroll(bucket.id, bucket.listId, event.target),
type: 'transition-group',
name: !this.drag ? 'move-card' : null,
class: [
'tasks',
{'dragging-disabled': !this.canWrite},
],
})
},
loadBucketParameter() {
return {
listId: this.listId,
params: this.params,
}
},
bucketDraggableComponentData() {
return {
type: 'transition-group',
name: !this.dragBucket ? 'move-bucket' : null,
class: [
'kanban-bucket-container',
{'dragging-disabled': !this.canWrite},
],
}
},
...mapState(useBaseStore, {
canWrite: state => state.currentList.maxRight > Rights.READ,
list: state => state.currentList,
}),
...mapState(useKanbanStore, {
buckets: state => state.buckets,
loadedListId: state => state.listId,
loading: state => state.isLoading,
}),
...mapState(useTaskStore, {
taskLoading: state => state.isLoading,
}),
},
methods: {
isSavedFilter,
loadBuckets() {
const {listId, params} = this.loadBucketParameter
this.collapsedBuckets = getCollapsedBucketState(listId)
console.debug(`Loading buckets, loadedListId = ${this.loadedListId}, $attrs = ${this.$attrs} $route.params =`, this.$route.params)
useKanbanStore().loadBucketsForList({listId, params})
},
setTaskContainerRef(id, el) {
if (!el) return
this.taskContainerRefs[id] = el
},
handleTaskContainerScroll(id, listId, el) {
if (!el) {
return
}
const scrollTopMax = el.scrollHeight - el.clientHeight
const threshold = el.scrollTop + el.scrollTop * MIN_SCROLL_HEIGHT_PERCENT
if (scrollTopMax > threshold) {
return
}
useKanbanStore().loadNextTasksForBucket({
listId: listId,
params: this.params,
bucketId: id,
})
},
updateTasks(bucketId, tasks) {
const kanbanStore = useKanbanStore()
const newBucket = {
...kanbanStore.getBucketById(bucketId),
tasks,
}
kanbanStore.setBucketById(newBucket)
},
async updateTaskPosition(e) {
this.drag = false
// While we could just pass the bucket index in through the function call, this would not give us the
// new bucket id when a task has been moved between buckets, only the new bucket. Using the data-bucket-id
// of the drop target works all the time.
const bucketIndex = parseInt(e.to.dataset.bucketIndex)
const newBucket = this.buckets[bucketIndex]
// HACK:
// this is a hacky workaround for a known problem of vue.draggable.next when using the footer slot
// the problem: https://github.com/SortableJS/vue.draggable.next/issues/108
// This hack doesn't remove the problem that the ghost item is still displayed below the footer
// It just makes releasing the item possible.
// The newIndex of the event doesn't count in the elements of the footer slot.
// This is why in case the length of the tasks is identical with the newIndex
// we have to remove 1 to get the correct index.
const newTaskIndex = newBucket.tasks.length === e.newIndex
? e.newIndex - 1
: e.newIndex
const task = newBucket.tasks[newTaskIndex]
const taskBefore = newBucket.tasks[newTaskIndex - 1] ?? null
const taskAfter = newBucket.tasks[newTaskIndex + 1] ?? null
this.taskUpdating[task.id] = true
const newTask = cloneDeep(task) // cloning the task to avoid pinia store manipulation
newTask.bucketId = newBucket.id
newTask.kanbanPosition = calculateItemPosition(
taskBefore !== null ? taskBefore.kanbanPosition : null,
taskAfter !== null ? taskAfter.kanbanPosition : null,
)
try {
const taskStore = useTaskStore()
await taskStore.update(newTask)
// Make sure the first and second task don't both get position 0 assigned
if(newTaskIndex === 0 && taskAfter !== null && taskAfter.kanbanPosition === 0) {
const taskAfterAfter = newBucket.tasks[newTaskIndex + 2] ?? null
const newTaskAfter = cloneDeep(taskAfter) // cloning the task to avoid pinia store manipulation
newTaskAfter.bucketId = newBucket.id
newTaskAfter.kanbanPosition = calculateItemPosition(
0,
taskAfterAfter !== null ? taskAfterAfter.kanbanPosition : null,
)
await taskStore.update(newTaskAfter)
}
} finally {
this.taskUpdating[task.id] = false
this.oneTaskUpdating = false
}
},
toggleShowNewTaskInput(bucketId) {
this.showNewTaskInput[bucketId] = !this.showNewTaskInput[bucketId]
this.newTaskInputFocused = false
},
async addTaskToBucket(bucketId) {
if (this.newTaskText === '') {
this.newTaskError[bucketId] = true
return
}
this.newTaskError[bucketId] = false
const task = await useTaskStore().createNewTask({
title: this.newTaskText,
bucketId,
listId: this.listId,
})
this.newTaskText = ''
useKanbanStore().addTaskToBucket(task)
this.scrollTaskContainerToBottom(bucketId)
},
scrollTaskContainerToBottom(bucketId) {
const bucketEl = this.taskContainerRefs[bucketId]
if (!bucketEl) {
return
}
bucketEl.scrollTop = bucketEl.scrollHeight
},
async createNewBucket() {
if (this.newBucketTitle === '') {
return
}
const newBucket = new BucketModel({
title: this.newBucketTitle,
listId: this.listId,
})
await useKanbanStore().createBucket(newBucket)
this.newBucketTitle = ''
this.showNewBucketInput = false
},
deleteBucketModal(bucketId) {
if (this.buckets.length <= 1) {
return
}
this.bucketToDelete = bucketId
this.showBucketDeleteModal = true
},
async deleteBucket() {
const bucket = new BucketModel({
id: this.bucketToDelete,
listId: this.listId,
})
try {
await useKanbanStore().deleteBucket({
bucket,
params: this.params,
})
this.$message.success({message: this.$t('list.kanban.deleteBucketSuccess')})
} finally {
this.showBucketDeleteModal = false
}
},
focusBucketTitle(e) {
// This little helper allows us to drag a bucket around at the title without focusing on it right away.
this.bucketTitleEditable = true
this.$nextTick(() => e.target.focus())
},
async saveBucketTitle(bucketId, bucketTitle) {
const updatedBucketData = {
id: bucketId,
title: bucketTitle,
}
await useKanbanStore().updateBucketTitle(updatedBucketData)
this.bucketTitleEditable = false
},
updateBuckets(value) {
// (1) buckets get updated in store and tasks positions get invalidated
useKanbanStore().setBuckets(value)
},
updateBucketPosition(e) {
// (2) bucket positon is changed
this.dragBucket = false
const bucket = this.buckets[e.newIndex]
const bucketBefore = this.buckets[e.newIndex - 1] ?? null
const bucketAfter = this.buckets[e.newIndex + 1] ?? null
const updatedData = {
id: bucket.id,
position: calculateItemPosition(
bucketBefore !== null ? bucketBefore.position : null,
bucketAfter !== null ? bucketAfter.position : null,
),
}
useKanbanStore().updateBucket(updatedData)
},
async setBucketLimit(bucketId, limit) {
if (limit < 0) {
return
}
const kanbanStore = useKanbanStore()
const newBucket = {
...kanbanStore.getBucketById(bucketId),
limit,
}
await kanbanStore.updateBucket(newBucket)
this.$message.success({message: this.$t('list.kanban.bucketLimitSavedSuccess')})
},
shouldAcceptDrop(bucket) {
return (
// When dragging from a bucket who has its limit reached, dragging should still be possible
bucket.id === this.sourceBucket ||
// If there is no limit set, dragging & dropping should always work
bucket.limit === 0 ||
// Disallow dropping to buckets which have their limit reached
bucket.tasks.length < bucket.limit
)
},
dragstart(bucket) {
this.drag = true
this.sourceBucket = bucket.id
},
async toggleDoneBucket(bucket) {
const newBucket = {
...bucket,
isDoneBucket: !bucket.isDoneBucket,
}
await useKanbanStore().updateBucket(newBucket)
this.$message.success({message: this.$t('list.kanban.doneBucketSavedSuccess')})
},
collapseBucket(bucket) {
this.collapsedBuckets[bucket.id] = true
saveCollapsedBucketState(this.listId, this.collapsedBuckets)
},
unCollapseBucket(bucket) {
if (!this.collapsedBuckets[bucket.id]) {
return
}
this.collapsedBuckets[bucket.id] = false
saveCollapsedBucketState(this.listId, this.collapsedBuckets)
},
const props = defineProps({
listId: {
type: Number as PropType<IList['id']>,
required: true,
},
})
const {t} = useI18n({useScope: 'global'})
const baseStore = useBaseStore()
const kanbanStore = useKanbanStore()
const taskStore = useTaskStore()
const taskContainerRefs = ref<{[id: IBucket['id']]: HTMLElement}>({})
const drag = ref(false)
const dragBucket = ref(false)
const sourceBucket = ref(0)
const showBucketDeleteModal = ref(false)
const bucketToDelete = ref(0)
const bucketTitleEditable = ref(false)
const newTaskText = ref('')
const showNewTaskInput = ref<{[id: IBucket['id']]: boolean}>({})
const newBucketTitle = ref('')
const showNewBucketInput = ref(false)
const newTaskError = ref<{[id: IBucket['id']]: boolean}>({})
const newTaskInputFocused = ref(false)
const showSetLimitInput = ref(false)
const collapsedBuckets = ref<CollapsedBuckets>({})
// We're using this to show the loading animation only at the task when updating it
const taskUpdating = ref<{[id: ITask['id']]: boolean}>({})
const oneTaskUpdating = ref(false)
const params = ref({
filter_by: [],
filter_value: [],
filter_comparator: [],
filter_concat: 'and',
})
const getTaskDraggableTaskComponentData = computed(() => (bucket: IBucket) => {
return {
ref: (el: HTMLElement) => setTaskContainerRef(bucket.id, el),
onScroll: (event: Event) => handleTaskContainerScroll(bucket.id, bucket.listId, event.target as HTMLElement),
type: 'transition-group',
name: !drag.value ? 'move-card' : null,
class: [
'tasks',
{'dragging-disabled': !canWrite.value},
],
}
})
const bucketDraggableComponentData = computed(() => ({
type: 'transition-group',
name: !dragBucket.value ? 'move-bucket' : null,
class: [
'kanban-bucket-container',
{'dragging-disabled': !canWrite.value},
],
}))
const canWrite = computed(() => baseStore.currentList.maxRight > Rights.READ)
const list = computed(() => baseStore.currentList)
const buckets = computed(() => kanbanStore.buckets)
const loading = computed(() => kanbanStore.isLoading)
const taskLoading = computed(() => taskStore.isLoading)
watch(
() => ({
listId: props.listId,
params: params.value,
}),
({listId, params}) => {
collapsedBuckets.value = getCollapsedBucketState(listId)
kanbanStore.loadBucketsForList({listId, params})
},
{
immediate: true,
deep: true,
},
)
function setTaskContainerRef(id: IBucket['id'], el: HTMLElement) {
if (!el) return
taskContainerRefs.value[id] = el
}
function handleTaskContainerScroll(id: IBucket['id'], listId: IList['id'], el: HTMLElement) {
if (!el) {
return
}
const scrollTopMax = el.scrollHeight - el.clientHeight
const threshold = el.scrollTop + el.scrollTop * MIN_SCROLL_HEIGHT_PERCENT
if (scrollTopMax > threshold) {
return
}
kanbanStore.loadNextTasksForBucket({
listId: listId,
params: params.value,
bucketId: id,
})
}
function updateTasks(bucketId: IBucket['id'], tasks: IBucket['tasks']) {
const bucket = kanbanStore.getBucketById(bucketId)
if (bucket === undefined) {
return
}
kanbanStore.setBucketById({
...bucket,
tasks,
})
}
async function updateTaskPosition(e) {
drag.value = false
// While we could just pass the bucket index in through the function call, this would not give us the
// new bucket id when a task has been moved between buckets, only the new bucket. Using the data-bucket-id
// of the drop target works all the time.
const bucketIndex = parseInt(e.to.dataset.bucketIndex)
const newBucket = buckets.value[bucketIndex]
// HACK:
// this is a hacky workaround for a known problem of vue.draggable.next when using the footer slot
// the problem: https://github.com/SortableJS/vue.draggable.next/issues/108
// This hack doesn't remove the problem that the ghost item is still displayed below the footer
// It just makes releasing the item possible.
// The newIndex of the event doesn't count in the elements of the footer slot.
// This is why in case the length of the tasks is identical with the newIndex
// we have to remove 1 to get the correct index.
const newTaskIndex = newBucket.tasks.length === e.newIndex
? e.newIndex - 1
: e.newIndex
const task = newBucket.tasks[newTaskIndex]
const taskBefore = newBucket.tasks[newTaskIndex - 1] ?? null
const taskAfter = newBucket.tasks[newTaskIndex + 1] ?? null
taskUpdating.value[task.id] = true
const newTask = cloneDeep(task) // cloning the task to avoid pinia store manipulation
newTask.bucketId = newBucket.id
newTask.kanbanPosition = calculateItemPosition(
taskBefore !== null ? taskBefore.kanbanPosition : null,
taskAfter !== null ? taskAfter.kanbanPosition : null,
)
try {
await taskStore.update(newTask)
// Make sure the first and second task don't both get position 0 assigned
if(newTaskIndex === 0 && taskAfter !== null && taskAfter.kanbanPosition === 0) {
const taskAfterAfter = newBucket.tasks[newTaskIndex + 2] ?? null
const newTaskAfter = cloneDeep(taskAfter) // cloning the task to avoid pinia store manipulation
newTaskAfter.bucketId = newBucket.id
newTaskAfter.kanbanPosition = calculateItemPosition(
0,
taskAfterAfter !== null ? taskAfterAfter.kanbanPosition : null,
)
await taskStore.update(newTaskAfter)
}
} finally {
taskUpdating.value[task.id] = false
oneTaskUpdating.value = false
}
}
function toggleShowNewTaskInput(bucketId: IBucket['id']) {
showNewTaskInput.value[bucketId] = !showNewTaskInput.value[bucketId]
newTaskInputFocused.value = false
}
async function addTaskToBucket(bucketId: IBucket['id']) {
if (newTaskText.value === '') {
newTaskError.value[bucketId] = true
return
}
newTaskError.value[bucketId] = false
const task = await taskStore.createNewTask({
title: newTaskText.value,
bucketId,
listId: props.listId,
})
newTaskText.value = ''
kanbanStore.addTaskToBucket(task)
scrollTaskContainerToBottom(bucketId)
}
function scrollTaskContainerToBottom(bucketId: IBucket['id']) {
const bucketEl = taskContainerRefs.value[bucketId]
if (!bucketEl) {
return
}
bucketEl.scrollTop = bucketEl.scrollHeight
}
async function createNewBucket() {
if (newBucketTitle.value === '') {
return
}
await kanbanStore.createBucket(new BucketModel({
title: newBucketTitle.value,
listId: props.listId,
}))
newBucketTitle.value = ''
showNewBucketInput.value = false
}
function deleteBucketModal(bucketId: IBucket['id']) {
if (buckets.value.length <= 1) {
return
}
bucketToDelete.value = bucketId
showBucketDeleteModal.value = true
}
async function deleteBucket() {
try {
await kanbanStore.deleteBucket({
bucket: new BucketModel({
id: bucketToDelete.value,
listId: props.listId,
}),
params: params.value,
})
success({message: t('list.kanban.deleteBucketSuccess')})
} finally {
showBucketDeleteModal.value = false
}
}
/** This little helper allows us to drag a bucket around at the title without focusing on it right away. */
async function focusBucketTitle(e: Event) {
bucketTitleEditable.value = true
await nextTick()
const target = e.target as HTMLInputElement
target.focus()
}
async function saveBucketTitle(bucketId: IBucket['id'], bucketTitle: string) {
await kanbanStore.updateBucketTitle({
id: bucketId,
title: bucketTitle,
})
bucketTitleEditable.value = false
}
function updateBuckets(value: IBucket[]) {
// (1) buckets get updated in store and tasks positions get invalidated
kanbanStore.setBuckets(value)
}
// TODO: fix type
function updateBucketPosition(e: {newIndex: number}) {
// (2) bucket positon is changed
dragBucket.value = false
const bucket = buckets.value[e.newIndex]
const bucketBefore = buckets.value[e.newIndex - 1] ?? null
const bucketAfter = buckets.value[e.newIndex + 1] ?? null
kanbanStore.updateBucket({
id: bucket.id,
position: calculateItemPosition(
bucketBefore !== null ? bucketBefore.position : null,
bucketAfter !== null ? bucketAfter.position : null,
),
})
}
async function setBucketLimit(bucketId: IBucket['id'], limit: number) {
if (limit < 0) {
return
}
await kanbanStore.updateBucket({
...kanbanStore.getBucketById(bucketId),
limit,
})
success({message: t('list.kanban.bucketLimitSavedSuccess')})
}
function shouldAcceptDrop(bucket: IBucket) {
return (
// When dragging from a bucket who has its limit reached, dragging should still be possible
bucket.id === sourceBucket.value ||
// If there is no limit set, dragging & dropping should always work
bucket.limit === 0 ||
// Disallow dropping to buckets which have their limit reached
bucket.tasks.length < bucket.limit
)
}
function dragstart(bucket: IBucket) {
drag.value = true
sourceBucket.value = bucket.id
}
async function toggleDoneBucket(bucket: IBucket) {
await kanbanStore.updateBucket({
...bucket,
isDoneBucket: !bucket.isDoneBucket,
})
success({message: t('list.kanban.doneBucketSavedSuccess')})
}
function collapseBucket(bucket: IBucket) {
collapsedBuckets.value[bucket.id] = true
saveCollapsedBucketState(props.listId, collapsedBuckets.value)
}
function unCollapseBucket(bucket: IBucket) {
if (!collapsedBuckets.value[bucket.id]) {
return
}
collapsedBuckets.value[bucket.id] = false
saveCollapsedBucketState(props.listId, collapsedBuckets.value)
}
</script>
<style lang="scss">

View File

@ -41,7 +41,7 @@
</div>
<filter-popup
v-model="params"
@update:modelValue="loadTasks()"
@update:modelValue="prepareFiltersAndLoadTasks()"
/>
</div>
</div>
@ -143,7 +143,7 @@ import {ref, computed, toRef, nextTick, onMounted, type PropType, watch} from 'v
import draggable from 'zhyswan-vuedraggable'
import {useRoute, useRouter} from 'vue-router'
import ListWrapper from './ListWrapper.vue'
import ListWrapper from '@/components/list/ListWrapper.vue'
import BaseButton from '@/components/base/BaseButton.vue'
import ButtonLink from '@/components/misc/ButtonLink.vue'
import EditTask from '@/components/tasks/edit-task.vue'
@ -221,7 +221,7 @@ const {
loadTasks,
searchTerm,
params,
// sortByParam,
sortByParam,
} = useTaskList(toRef(props, 'listId'), {position: 'asc' })
@ -327,6 +327,15 @@ async function saveTaskPosition(e) {
const updatedTask = await taskStore.update(newTask)
tasks.value[e.newIndex] = updatedTask
}
function prepareFiltersAndLoadTasks() {
if(isAlphabeticalSorting.value) {
sortByParam.value = {}
sortByParam.value[ALPHABETICAL_SORT] = 'asc'
}
loadTasks()
}
</script>
<style lang="scss" scoped>

View File

@ -15,7 +15,7 @@
</template>
<template #content="{isOpen}">
<card class="columns-filter" :class="{'is-open': isOpen}">
<fancycheckbox v-model="activeColumns.id">#</fancycheckbox>
<fancycheckbox v-model="activeColumns.index">#</fancycheckbox>
<fancycheckbox v-model="activeColumns.done">
{{ $t('task.attributes.done') }}
</fancycheckbox>
@ -67,9 +67,9 @@
<table class="table has-actions is-hoverable is-fullwidth mb-0">
<thead>
<tr>
<th v-if="activeColumns.id">
<th v-if="activeColumns.index">
#
<Sort :order="sortBy.id" @click="sort('id')"/>
<Sort :order="sortBy.index" @click="sort('index')"/>
</th>
<th v-if="activeColumns.done">
{{ $t('task.attributes.done') }}
@ -120,7 +120,7 @@
</thead>
<tbody>
<tr :key="t.id" v-for="t in tasks">
<td v-if="activeColumns.id">
<td v-if="activeColumns.index">
<router-link :to="taskDetailRoutes[t.id]">
<template v-if="t.identifier === ''">
#{{ t.index }}
@ -184,7 +184,7 @@ import {toRef, computed, type Ref} from 'vue'
import {useStorage} from '@vueuse/core'
import ListWrapper from './ListWrapper.vue'
import ListWrapper from '@/components/list/ListWrapper.vue'
import Done from '@/components/misc/Done.vue'
import User from '@/components/misc/user.vue'
import PriorityLabel from '@/components/tasks/partials/priorityLabel.vue'
@ -200,7 +200,7 @@ import {useTaskList} from '@/composables/useTaskList'
import type {ITask} from '@/modelTypes/ITask'
const ACTIVE_COLUMNS_DEFAULT = {
id: true,
index: true,
done: true,
title: true,
priority: false,
@ -225,7 +225,7 @@ const props = defineProps({
type Order = 'asc' | 'desc' | 'none'
interface SortBy {
id: Order
index: Order
done?: Order
title?: Order
priority?: Order
@ -238,7 +238,7 @@ interface SortBy {
}
const SORT_BY_DEFAULT: SortBy = {
id: 'desc',
index: 'desc',
}
const activeColumns = useStorage('tableViewColumns', {...ACTIVE_COLUMNS_DEFAULT})

View File

@ -98,8 +98,8 @@ export function useGanttFilters(route: Ref<RouteLocationNormalized>): UseGanttFi
setDefaultFilters,
} = useRouteFilters<GanttFilters>(
route,
ganttRouteToFilters,
ganttGetDefaultFilters,
ganttRouteToFilters,
ganttFiltersToRoute,
)

View File

@ -8,7 +8,12 @@
}"
>
<div class="task-view">
<Heading v-model:task="task" :can-write="canWrite" ref="heading"/>
<Heading
:task="task"
@update:task="Object.assign(task, $event)"
:can-write="canWrite"
ref="heading"
/>
<h6 class="subtitle" v-if="parent && parent.namespace && parent.list">
{{ getNamespaceTitle(parent.namespace) }} >
<router-link :to="{ name: 'list.index', params: { listId: parent.list.id } }">
@ -61,14 +66,14 @@
<div class="date-input">
<datepicker
v-model="task.dueDate"
@close-on-change="() => saveTask()"
@close-on-change="saveTask()"
:choose-date-label="$t('task.detail.chooseDueDate')"
:disabled="taskService.loading || !canWrite"
:ref="e => setFieldRef('dueDate', e)"
/>
<BaseButton
@click="() => {task.dueDate = null;saveTask()}"
v-if="task.dueDate && canWrite"
@click="() => {task.dueDate = null;saveTask()}"
class="remove">
<span class="icon is-small">
<icon icon="times"></icon>
@ -101,7 +106,7 @@
<div class="date-input">
<datepicker
v-model="task.startDate"
@close-on-change="() => saveTask()"
@close-on-change="saveTask()"
:choose-date-label="$t('task.detail.chooseStartDate')"
:disabled="taskService.loading || !canWrite"
:ref="e => setFieldRef('startDate', e)"
@ -128,7 +133,7 @@
<div class="date-input">
<datepicker
v-model="task.endDate"
@close-on-change="() => saveTask()"
@close-on-change="saveTask()"
:choose-date-label="$t('task.detail.chooseEndDate')"
:disabled="taskService.loading || !canWrite"
:ref="e => setFieldRef('endDate', e)"
@ -179,8 +184,11 @@
<repeat-after
:disabled="!canWrite"
:ref="e => setFieldRef('repeatAfter', e)"
v-model="task"
@update:model-value="saveTask"
:model-value="task"
@update:model-value="(newTask) => {
Object.assign(task, newTask)
saveTask()
}"
/>
</div>
</transition>
@ -219,7 +227,8 @@
<!-- Description -->
<div class="details content description">
<description
v-model="task"
:model-value="task"
@update:modelValue="Object.assign(task, $event)"
:can-write="canWrite"
:attachment-upload="attachmentUpload"
/>
@ -818,36 +827,7 @@ $flash-background-duration: 750ms;
color: var(--grey-400);
}
:deep(.heading) {
display: flex;
justify-content: space-between;
text-transform: none;
align-items: center;
@media screen and (max-width: $tablet) {
flex-direction: column;
align-items: start;
}
.title {
margin-bottom: 0;
}
.title.input {
// 1.8rem is the font-size, 1.125 is the line-height, .3rem padding everywhere, 1px border around the whole thing.
min-height: calc(1.8rem * 1.125 + .6rem + 2px);
@media screen and (max-width: $tablet) {
margin: 0 -.3rem .5rem -.3rem; // the title has 0.3rem padding - this make the text inside of it align with the rest
}
}
.title.task-id {
color: var(--grey-400);
white-space: nowrap;
}
}
.date-input {
display: flex;
@ -995,12 +975,6 @@ $flash-background-duration: 750ms;
}
}
.created {
font-size: .75rem;
color: var(--grey-500);
text-align: right;
}
.checklist-summary {
padding-left: .25rem;
}

View File

@ -132,6 +132,7 @@ export default defineConfig({
strictPort: true,
},
build: {
target: 'esnext',
rollupOptions: {
plugins: [
visualizer({