forked from vikunja/frontend
Compare commits
428 Commits
90ecea74c7
...
7d5cde53e3
Author | SHA1 | Date |
---|---|---|
renovate | 7d5cde53e3 | |
renovate | 8361640559 | |
renovate | 517a6cea1e | |
renovate | ed4dd93bba | |
renovate | d50de97490 | |
renovate | aa719d3a68 | |
kolaente | 463d22b07c | |
renovate | 33494cab6b | |
renovate | 8fa922a0ca | |
renovate | e5815e21cb | |
renovate | 529b47e488 | |
renovate | 63c3e4ea58 | |
renovate | d52e917357 | |
kolaente | b2da4fd126 | |
kolaente | 83fb8c3ded | |
Dominik Pschenitschni | b44d11cfc0 | |
renovate | d4133b9e78 | |
renovate | c478926038 | |
renovate | 00e40a0f53 | |
renovate | 0567ba2a47 | |
Dominik Pschenitschni | 3b95824f58 | |
renovate | 963f3bfb07 | |
renovate | d1c05eb3fb | |
renovate | 2326e50d5d | |
drone | b7fa1a3ca1 | |
renovate | a3e1e43ec7 | |
renovate | 39f163df4a | |
renovate | f0e8ff93ff | |
Dominik Pschenitschni | 3ee0bc345d | |
renovate | b4ffee8929 | |
renovate | e3c3d3ee53 | |
renovate | 67e7b94f5d | |
renovate | 6bbddeae8c | |
renovate | 94a0e1e25f | |
renovate | 4df9bc33df | |
drone | 5c64e8a2d7 | |
renovate | e10791f28c | |
drone | 44b58ff34b | |
renovate | da17f78d30 | |
renovate | 61cdb7a91f | |
renovate | 1b8ed9417a | |
renovate | 4657da8c90 | |
renovate | 6cd2908040 | |
renovate | 4dd99ae6fc | |
renovate | 0d5fa1326d | |
renovate | dd692de7c4 | |
Dominik Pschenitschni | 93d95b0821 | |
Dominik Pschenitschni | 422e731fe0 | |
Dominik Pschenitschni | 7db79ff04e | |
renovate | 59cc241226 | |
renovate | 2ac2e95cf2 | |
renovate | f8ce3d6ed6 | |
drone | 93f33d9647 | |
renovate | 6d32b22da3 | |
renovate | b333898595 | |
renovate | ccc633f3d9 | |
renovate | d39b0675d3 | |
renovate | 274092bfc4 | |
renovate | cb2c032e60 | |
renovate | fdf294bcb3 | |
renovate | 58baa5960c | |
renovate | e948678e42 | |
Dominik Pschenitschni | 5ccedc6f67 | |
Dominik Pschenitschni | 74ad98de68 | |
Dominik Pschenitschni | 3282f55c34 | |
Dominik Pschenitschni | d9984b28f7 | |
Dominik Pschenitschni | 4fc7b9c67e | |
Dominik Pschenitschni | ff9efe7889 | |
Dominik Pschenitschni | 66be0e6ac4 | |
Dominik Pschenitschni | da8df8b667 | |
Dominik Pschenitschni | 42e9f306e8 | |
Angelo Delicato | 4b47478440 | |
Dominik Pschenitschni | b42e4cca59 | |
Dominik Pschenitschni | 33d4efecc4 | |
Dominik Pschenitschni | 45ec1623d5 | |
Dominik Pschenitschni | 8ef309243d | |
Dominik Pschenitschni | 3aaacf4533 | |
renovate | 0350e37fbb | |
renovate | 244c436202 | |
drone | 18d0c8ba2c | |
kolaente | 3891d5b876 | |
Dominik Pschenitschni | 98b38af43c | |
konrad | 77ff0aa256 | |
renovate | 2ab26ee7c5 | |
renovate | 58f38bcfc3 | |
renovate | bcb5190365 | |
renovate | c99d09c83e | |
renovate | f49ea9752d | |
renovate | a56683cdc2 | |
renovate | 7f4af63003 | |
Dominik Pschenitschni | 8c44ed83e6 | |
renovate | b388677eaf | |
renovate | bd7430b405 | |
renovate | 4baed8fe79 | |
renovate | fdbe4e8314 | |
renovate | 4a7f839449 | |
renovate | c359f4d4dd | |
renovate | 79d6212e48 | |
renovate | e541213872 | |
Dominik Pschenitschni | fd1d01164f | |
renovate | 34edf0dc5f | |
Dominik Pschenitschni | 631a19fa92 | |
Dominik Pschenitschni | fba402fcd0 | |
Dominik Pschenitschni | 4c4adfdf4e | |
Dominik Pschenitschni | 06775cf4c7 | |
kolaente | c07954f2b8 | |
kolaente | 995cc12880 | |
Dominik Pschenitschni | 293402b6fd | |
Dominik Pschenitschni | 708ef2d72e | |
Dominik Pschenitschni | 4c458a1ad0 | |
Dominik Pschenitschni | 02de481297 | |
Dominik Pschenitschni | 9d604f7a3b | |
Dominik Pschenitschni | 0f1f131f7a | |
Dominik Pschenitschni | eb4c2a4b9d | |
Dominik Pschenitschni | 599c1ba4b5 | |
Dominik Pschenitschni | 12a8f7ebe9 | |
Dominik Pschenitschni | 9f0f0b39f8 | |
konrad | 4a550da6a6 | |
renovate | 52ba168d41 | |
renovate | 9c7680aa55 | |
Dominik Pschenitschni | 83bb030c6e | |
Dominik Pschenitschni | 163d9366d3 | |
kolaente | 5cff9988a3 | |
renovate | a15ace0dbc | |
renovate | 65bb514093 | |
renovate | 403f1ee400 | |
Dominik Pschenitschni | bb58dba8e0 | |
Dominik Pschenitschni | 4bad685f39 | |
kolaente | e5f631af8d | |
renovate | 925f2aa837 | |
renovate | 1de22386da | |
renovate | 602af9ec96 | |
renovate | 57feb65e00 | |
konrad | 94508173dc | |
kolaente | bd0c4d0355 | |
kolaente | 2952a0155f | |
kolaente | 6055fecc5d | |
Dominik Pschenitschni | 7ec2b6c0d2 | |
renovate | 13bd434cb9 | |
kolaente | b98d9fb7ec | |
kolaente | c2dd18edaa | |
kolaente | d47791b957 | |
renovate | e6eaac1b46 | |
renovate | cf2103734b | |
renovate | bb9c5046b3 | |
renovate | a7a6a4c2d6 | |
renovate | 28257fcf5f | |
renovate | c314d56f73 | |
kolaente | 612e592da7 | |
konrad | 1a329464ab | |
kolaente | d5efe9f656 | |
Dominik Pschenitschni | 4a5f1a783a | |
Dominik Pschenitschni | 906b3a5cdf | |
Dominik Pschenitschni | 678dc8ef51 | |
Dominik Pschenitschni | da1d5eaba1 | |
kolaente | 02448700b3 | |
renovate | d9ca798aad | |
renovate | 23668e55d7 | |
drone | 3be9de76c5 | |
renovate | a9f41e3f37 | |
Dominik Pschenitschni | f0492d49ef | |
Dominik Pschenitschni | d85abbd77a | |
renovate | 5186aeb086 | |
renovate | 7ba421e810 | |
renovate | e0fd4f216f | |
Dominik Pschenitschni | 5057b69382 | |
renovate | 07297196f9 | |
Dominik Pschenitschni | 7fbb6e8f70 | |
Dominik Pschenitschni | 38cef79f68 | |
Dominik Pschenitschni | 6a93701649 | |
Dominik Pschenitschni | d9a8382049 | |
renovate | 66e60a4e6a | |
renovate | 0f67a78ec8 | |
renovate | 722802fb2e | |
renovate | 752ead3a75 | |
renovate | b0b261d647 | |
renovate | 442a14242c | |
Dominik Pschenitschni | 66c0c322a2 | |
Dominik Pschenitschni | f4bc2b94f0 | |
Dominik Pschenitschni | f7728e5384 | |
kolaente | 78b765ddc4 | |
kolaente | f967bcb205 | |
Dominik Pschenitschni | e49f960aea | |
renovate | 98cb878250 | |
renovate | 03f2b253b8 | |
renovate | 69c0726b9d | |
Dominik Pschenitschni | eb59ca5836 | |
Dominik Pschenitschni | 8b7b4d61a3 | |
renovate | 0ed7114260 | |
renovate | dda162c16f | |
Dominik Pschenitschni | eeb562314e | |
Dominik Pschenitschni | 7f00c7dabd | |
konrad | 0ff0d8c5b8 | |
renovate | 9c9a5d08ff | |
renovate | 9a2b88d295 | |
kolaente | 09b76b7bd4 | |
renovate | f72c847e99 | |
renovate | 8ea899fa26 | |
Dominik Pschenitschni | e01df4d369 | |
Dominik Pschenitschni | 096daad80a | |
Dominik Pschenitschni | 3c5bfcc6f3 | |
Dominik Pschenitschni | 0182695cda | |
Dominik Pschenitschni | 44e6981759 | |
renovate | 15b64c7e8a | |
renovate | 9c357cb83e | |
renovate | 0cf9b7595a | |
renovate | 21dce0d8a8 | |
renovate | 218b96b230 | |
Dominik Pschenitschni | d19c48a4f5 | |
Dominik Pschenitschni | 480aa8813e | |
Dominik Pschenitschni | caa29c152d | |
Dominik Pschenitschni | 1101fcb3ff | |
Dominik Pschenitschni | 5d601ca4b3 | |
Dominik Pschenitschni | 53c9a9bc9c | |
Dominik Pschenitschni | d6cb965ea7 | |
Dominik Pschenitschni | 964aba4824 | |
Dominik Pschenitschni | 35f4bb1385 | |
Dominik Pschenitschni | 0b58973d87 | |
Dominik Pschenitschni | 02deb0bedd | |
Dominik Pschenitschni | 4cd0e90fea | |
Dominik Pschenitschni | e8c6afce72 | |
Dominik Pschenitschni | a2c1702eef | |
Dominik Pschenitschni | 599e28e5e5 | |
Dominik Pschenitschni | 1002579173 | |
Dominik Pschenitschni | 5ae8bace82 | |
Dominik Pschenitschni | 0832184222 | |
Dominik Pschenitschni | a50eca852f | |
Dominik Pschenitschni | b4f4fd45a4 | |
Dominik Pschenitschni | 15ef86d597 | |
Dominik Pschenitschni | 825ba100f0 | |
Dominik Pschenitschni | 839d331bf5 | |
renovate | 1798388e31 | |
renovate | c3f8dcefb6 | |
renovate | 816292e86a | |
renovate | ea1c7f1a7e | |
renovate | 6cb17c1267 | |
renovate | cbb2cf2951 | |
renovate | 85e1b36b00 | |
renovate | c9b9367c0b | |
renovate | a14644c156 | |
renovate | 189b5ee8aa | |
renovate | 61ed47fab4 | |
renovate | f18c03fa4d | |
renovate | 0e219b48a3 | |
renovate | 9ee05d5583 | |
renovate | 6d20e762ee | |
drone | 5143e09d2b | |
drone | b428523c89 | |
renovate | 6ef0a0ded9 | |
kolaente | bd7fc44722 | |
kolaente | 549e7b4310 | |
renovate | 89a125599e | |
kolaente | da2a7a224e | |
drone | da478a49d1 | |
kolaente | 98943377b8 | |
renovate | d28bbb7dc0 | |
Dominik Pschenitschni | 386fd79b49 | |
Dominik Pschenitschni | 9807858436 | |
Dominik Pschenitschni | 9ded3d0cd6 | |
konrad | d5258b7315 | |
renovate | eccaeae9e9 | |
konrad | fd3e7e655d | |
kolaente | 5271166120 | |
Dominik Pschenitschni | 61a89117d2 | |
Dominik Pschenitschni | 066553838a | |
Dominik Pschenitschni | 443e1a063d | |
Dominik Pschenitschni | 9a84fb6d7f | |
Dominik Pschenitschni | d8d3e4c8a6 | |
Dominik Pschenitschni | b4f88bd4a6 | |
Dominik Pschenitschni | abc26496cf | |
Dominik Pschenitschni | b8cc828bc0 | |
Dominik Pschenitschni | 874dc1e5fc | |
Dominik Pschenitschni | e74e6fcc99 | |
Dominik Pschenitschni | 52d4d0bdb9 | |
Dominik Pschenitschni | 6bf6357cbd | |
Dominik Pschenitschni | cf0eaf9ba1 | |
Dominik Pschenitschni | 8dea4082bb | |
Dominik Pschenitschni | 51dc123d89 | |
Dominik Pschenitschni | acb3ddc73f | |
Dominik Pschenitschni | 407f5f2ef8 | |
Dominik Pschenitschni | 73eab6c5b5 | |
Dominik Pschenitschni | aefda38bdd | |
Dominik Pschenitschni | a70a2e3ba6 | |
Dominik Pschenitschni | db611ab2d3 | |
kolaente | e1f49f2ff1 | |
kolaente | b8e7b87f96 | |
kolaente | 6c619072b4 | |
kolaente | 26e522cf8c | |
Dominik Pschenitschni | 7f4114b703 | |
Dominik Pschenitschni | c7dd20ef57 | |
Dominik Pschenitschni | c1da04eda1 | |
Dominik Pschenitschni | 2c732eb0d5 | |
Dominik Pschenitschni | 2acb70c562 | |
Dominik Pschenitschni | eaf777864a | |
Dominik Pschenitschni | 0b194bb0cf | |
Dominik Pschenitschni | e968c88cfd | |
Dominik Pschenitschni | df02dd5291 | |
Dominik Pschenitschni | acdbf2f8f5 | |
Dominik Pschenitschni | 9f146c8c7f | |
Dominik Pschenitschni | 3b244dfdbe | |
Dominik Pschenitschni | 2f820e517f | |
kolaente | 56b88218b3 | |
kolaente | 957d8f05a5 | |
kolaente | 31f2065d20 | |
kolaente | f5fd14124f | |
Dominik Pschenitschni | d91bc5090a | |
Dominik Pschenitschni | f21a4e1e9f | |
kolaente | 970a04d973 | |
kolaente | fd9d0ad155 | |
kolaente | 4be0977014 | |
kolaente | 6975a2b286 | |
kolaente | 64fdae81ec | |
kolaente | 56a25734d7 | |
kolaente | ed5d3be7cb | |
kolaente | 98d0398ca8 | |
kolaente | d3925b8d80 | |
kolaente | b7b4530a11 | |
kolaente | 766b4c669f | |
kolaente | 5f7159ebc4 | |
kolaente | 0a9588e097 | |
kolaente | 091beecc19 | |
kolaente | 6cb331ee0f | |
kolaente | 8c62a9e198 | |
kolaente | 29dcc02217 | |
kolaente | 3eacc0754f | |
kolaente | ebd824bddf | |
kolaente | 2c012e1a08 | |
kolaente | 10c6db3849 | |
kolaente | 80c151ca6c | |
kolaente | 7a7a1c985e | |
kolaente | c8eac914d1 | |
kolaente | d2c40926de | |
kolaente | c3cae78213 | |
kolaente | c289a6ae18 | |
kolaente | ef4689335b | |
kolaente | 3b48adad67 | |
kolaente | 736e5a8bf5 | |
kolaente | ed241d21be | |
kolaente | 49a24977f9 | |
kolaente | 2b0df8c237 | |
renovate | ef3f19d046 | |
Dominik Pschenitschni | 7ce880239e | |
Dominik Pschenitschni | aa2278a564 | |
renovate | 96e44bf225 | |
renovate | 4ad99bdad1 | |
renovate | 5e7fe3280c | |
renovate | 7ec31363c3 | |
drone | c40c1fb10a | |
renovate | 59be904d4a | |
drone | 1d9d093b31 | |
renovate | ef6bc3cbab | |
kolaente | e13e477682 | |
kolaente | 8a5b1ab3e3 | |
renovate | 70e81ee682 | |
kolaente | a0795db040 | |
renovate | 35649d0e87 | |
drone | 67145fe00b | |
renovate | 22d93a1a3c | |
kolaente | 51471b9551 | |
kolaente | 22a18f8437 | |
kolaente | f17bbeddec | |
kolaente | eae555475d | |
kolaente | 12faafbe7c | |
kolaente | 5ddce387fe | |
renovate | 05d000fc50 | |
renovate | 333df9b247 | |
renovate | 8d368c552d | |
renovate | 57cc7b8f37 | |
renovate | 527873dad4 | |
renovate | d67dca4a81 | |
kolaente | 6083301d1f | |
renovate | 3f04571e43 | |
kolaente | d7ac2ad697 | |
kolaente | 820823b5c3 | |
kolaente | d7fb1a1e14 | |
kolaente | 7e218e03b2 | |
kolaente | d7048d589e | |
kolaente | 80230069c6 | |
kolaente | a695719128 | |
kolaente | f61723dac2 | |
kolaente | ae27502022 | |
kolaente | 8fdd3e785d | |
drone | 5d038dc79f | |
renovate | b2ef66e5df | |
renovate | af819f9539 | |
drone | 9cf0a9a89e | |
renovate | 91b70c2de4 | |
kolaente | 643a5b6d7d | |
kolaente | e6f7ddc9ce | |
kolaente | 73575302de | |
kolaente | 4ed665fbd9 | |
renovate | f30e948abd | |
drone | ea758f0c58 | |
renovate | 617a48157d | |
renovate | 419c0b2d96 | |
renovate | 449b11c1ff | |
drone | f818c207c2 | |
renovate | 4ee8f600a3 | |
konrad | 29f68747bb | |
renovate | ce201e0880 | |
renovate | bd26b81318 | |
Dominik Pschenitschni | b80f82c411 | |
Dominik Pschenitschni | 5afafb7c82 | |
Dominik Pschenitschni | 9de20b4c54 | |
renovate | c9b18232c9 | |
renovate | 8e460f9856 | |
renovate | 2db263f2d2 | |
renovate | 79872c96de | |
renovate | 2c881b3126 | |
renovate | 8093ce9441 | |
Dominik Pschenitschni | c4d7f6fdfa | |
Dominik Pschenitschni | c20de51a3c | |
renovate | ed56176f2d | |
renovate | ab7d889650 | |
renovate | b334712dfe | |
renovate | 454b680117 | |
renovate | 830ecc2c03 | |
renovate | 0e448a123e | |
renovate | 1c01fcbb84 | |
renovate | e8fb4ce1fa | |
renovate | 37cbbdbec8 | |
renovate | e5aabfc753 | |
renovate | 66f193871a | |
renovate | d7907d8075 | |
renovate | 38aa32c42d | |
renovate | 39d868278e | |
renovate | 1c8919ee2a | |
renovate | e26932aa95 |
278
.drone.yml
278
.drone.yml
|
@ -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,13 @@ steps:
|
|||
|
||||
- name: build
|
||||
image: node:18-alpine
|
||||
pull: true
|
||||
group: build-static
|
||||
pull: always
|
||||
environment:
|
||||
PNPM_CACHE_FOLDER: .cache/pnpm
|
||||
commands:
|
||||
- corepack enable && pnpm config set store-dir .cache/.pnp
|
||||
- pnpm install --fetch-timeout 100000
|
||||
- apk add git
|
||||
- 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"
|
||||
- pnpm run build
|
||||
|
@ -213,7 +215,7 @@ steps:
|
|||
|
||||
- name: static
|
||||
image: kolaente/zip
|
||||
pull: true
|
||||
pull: always
|
||||
commands:
|
||||
- cd dist
|
||||
- zip -r ../vikunja-frontend-unstable.zip *
|
||||
|
@ -222,7 +224,7 @@ steps:
|
|||
|
||||
- name: release
|
||||
image: plugins/s3
|
||||
pull: true
|
||||
pull: always
|
||||
settings:
|
||||
bucket: vikunja-releases
|
||||
access_key:
|
||||
|
@ -238,6 +240,7 @@ steps:
|
|||
|
||||
---
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: release-version
|
||||
|
||||
depends_on:
|
||||
|
@ -255,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
|
||||
|
@ -273,13 +276,13 @@ 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/pnpm
|
||||
- pnpm install --fetch-timeout 100000
|
||||
- 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"
|
||||
- pnpm run build
|
||||
|
@ -289,7 +292,7 @@ steps:
|
|||
|
||||
- name: static
|
||||
image: kolaente/zip
|
||||
pull: true
|
||||
pull: always
|
||||
commands:
|
||||
- cd dist
|
||||
- zip -r ../vikunja-frontend-${DRONE_TAG##v}.zip *
|
||||
|
@ -298,7 +301,7 @@ steps:
|
|||
|
||||
- name: release
|
||||
image: plugins/s3
|
||||
pull: true
|
||||
pull: always
|
||||
settings:
|
||||
bucket: vikunja-releases
|
||||
access_key:
|
||||
|
@ -314,6 +317,7 @@ steps:
|
|||
|
||||
---
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: trigger-desktop-update
|
||||
|
||||
trigger:
|
||||
|
@ -338,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
|
||||
|
@ -457,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:
|
||||
|
@ -466,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/**"
|
||||
|
@ -574,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
|
||||
|
@ -659,6 +513,6 @@ steps:
|
|||
from_secret: crowdin_key
|
||||
---
|
||||
kind: signature
|
||||
hmac: c885a0e50db729842402494aa645dd3ac662828b691108550f6bf302158295ba
|
||||
hmac: 9f26b5af73e3464e9ee1b5fbcb96854ca8a7e5f8d6ee2d85fd8376aad951b446
|
||||
|
||||
...
|
||||
|
|
|
@ -5,7 +5,7 @@ module.exports = {
|
|||
'root': true,
|
||||
'env': {
|
||||
'browser': true,
|
||||
'es2021': true,
|
||||
'es2022': true,
|
||||
'node': true,
|
||||
'vue/setup-compiler-macros': true,
|
||||
},
|
||||
|
@ -37,6 +37,10 @@ module.exports = {
|
|||
'@typescript-eslint/no-unused-vars': ['error', { vars: 'all', args: 'after-used', ignoreRestSiblings: true }],
|
||||
|
||||
'vue/multi-word-component-names': 0,
|
||||
// disabled until we have support for reactivityTransform
|
||||
// See https://github.com/vuejs/eslint-plugin-vue/issues/1948
|
||||
// see also setting in `vite.config`
|
||||
'vue/no-setup-props-destructure': 0,
|
||||
},
|
||||
'parser': 'vue-eslint-parser',
|
||||
'parserOptions': {
|
||||
|
|
686
CHANGELOG.md
686
CHANGELOG.md
|
@ -9,6 +9,692 @@ 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
|
||||
|
||||
* *(filters)* Changing filter checkbox values not being emitted to parent components
|
||||
* *(filters)* Make sure all checkboxes are aligned properly
|
||||
* *(filters)* Page freezing when entering a date as a result of an endless loop
|
||||
* *(gantt)* Only unmount chart if there aren't any loaded tasks yet
|
||||
* *(gantt)* UseDayjsLanguageSync and move to separate file
|
||||
* *(i18n)* Spelling typo
|
||||
* *(i18n)* Rename "right" to permission so that it's clearer what it is used for
|
||||
* *(labels)* Unset loading state after loading all labels
|
||||
* *(lint)* Unnecessary catch clause
|
||||
* *(list)* Automatically close task edit pane when switching between lists
|
||||
* *(quick add magic)* Time parsing for certain conditions (#2367)
|
||||
* *(sharing)* Correctly check if the user has admin rights when sharing
|
||||
* *(subscription)* Don't remove every namespace but the one subscribing to
|
||||
* *(subscription)* Make sure list subscription state is propagated everywhere for the current list
|
||||
* *(task)* Make sure users can be assigned via quick add magic via their real name as well
|
||||
* *(task)* Cancel loading state when creating a new task does not work
|
||||
* *(task)* Cancel loading state when creating a new task does not work
|
||||
* *(task)* New tasks with quick add magic not showing up in task list
|
||||
* *(task)* Setting a priority was not properly saved
|
||||
* *(task)* Setting progress was not properly saved
|
||||
* *(task)* Setting a label would not show up on the kanban board after setting it
|
||||
* *(task)* Stop loading when no list was specified while creating a task
|
||||
* *(task)* Only show create list or import cta when there are no tasks
|
||||
* *(task)* Marking checklist items as done
|
||||
* *(task)* Focusing on assignee search field when activating it
|
||||
* *(task)* Scroll the task field into view after activating it
|
||||
* *(tasks)* Don't allow adding the same assignee multiple times
|
||||
* *(teams)* Show an error message when no user is selected to add to a team
|
||||
* *(tests)* Fake current time in gantt tests to make them more reliable
|
||||
* *(tests)* Adjust gantt rows identifier* Authenticate per request (#2258) ([6e4a3ff](6e4a3ff1996f55d99896a0e8267c1915de09dd39))
|
||||
* Add lodash.clonedeep types ([80eaf38](80eaf38090413b74524ddc5a7dfcc9a845a6ba26))
|
||||
* Use correct model for generics ([3ba423e](3ba423ed238a5f8f445246793829c7645dfe42aa))
|
||||
* Merge duplicate types ([106abfc](106abfc842ca0c916ef7574b0fe5c89940869ac2))
|
||||
* CreateNewTask typing ([f9b5130](f9b51306c396ceb0d8fa0c4af3fea24d2b28b64b))
|
||||
* Improve some types ([4a50e6a](4a50e6aae28d22c3d441f1fead4edce7d0e30ff1))
|
||||
* Use definite assignment assertion operator ([96f5f00](96f5f00c073f71c71d85c351f86ad16a67db6992))
|
||||
* Mark abstractModel and abstractService abstract ([d36577c](d36577c04e1eea00fb21a5fb774e7f6b1f667d54))
|
||||
* Use IAbstract to extend model interface ([8be1f81](8be1f81848303d590adb890743dd688fbf5cdf1c))
|
||||
* Use new assignData method for default data ([8416b1f](8416b1f44811ff477d81db20370ff68e899c7252))
|
||||
* Don't push a select event when nothing was selected ([9616bad](9616badc33173483e0b5cc0c99655e0c9a4907f9))
|
||||
* Don't try to set the bucket of a task when it was moved to a new list ([c06b781](c06b781837c66174be41f40c967fbfcbcc35495e))
|
||||
* Mutation error in TaskDetailView ([b4cba6f](b4cba6f7d96334b46e5e2d6be5ac87432b01f0c0))
|
||||
* DefaultListId ([878b5bf](878b5bf236f7d1ddc9825d8dca8415313b08fd94))
|
||||
* Use typed useStore ([54de368](54de368642519fc900ce89e4ee38989555054a05))
|
||||
* Don't encode attachment upload file blob as json ([d819b9b](d819b9b0ba08db24a77751061ae285fc11205c2c))
|
||||
* Dragging a list on mobile Safari ([6bf5f6e](6bf5f6efd46c47293fb54b9e9a25d91d8c6bec0d))
|
||||
* Introduce a ListView type to properly type all available list views ([23598dd](23598dd2ee649449f2176ae86acbc16ecbf01e6f))
|
||||
* Use proper computed for available views list ([e67fc7f](e67fc7fb7e1678b1b691fee77d3237b222ad50c6))
|
||||
* Only warn once if triggeredNotifications are not supported (#2344) ([f083f18](f083f181e2c8aa0af3ac1381303f61792d5975f5))
|
||||
* Bucket title edit success message appearing twice ([4921788](49217889b50da73d0f4851c4ee21f0dec11c7958))
|
||||
* Don't parse dates in urls ([92f24e5](92f24e59a794a25098f5fb50f2101d516829cd36))
|
||||
* Vue-i18n global scope (#2366) ([602ab83](602ab8379e3fb11eb8b547d036921311f193fb12))
|
||||
* Redirect to login when the jwt token expires ([91976e2](91976e23f989f39fb25d3341aa3f4b632ea66f35))
|
||||
* Only try to save user settings when a user is authenticated ([2df2bd3](2df2bd38e2b9f86be7e7c5aab744f27cbf2644c3))
|
||||
* Remove margin from the color bubble component itself ([4fce71f](4fce71f729878d47c3ec79d0c10fae8fbaabbd91))
|
||||
* Test pnpm cache ([e5d04c9](e5d04c98dabc6b597ecc32dd01ab31c4dd9882d1))
|
||||
* Remove console.log ([43e2d03](43e2d036d77731fcce18cbea1d82196b10016609))
|
||||
* Explicitly install cypress ([62e227c](62e227c767a43578f4487e3dc244f4756e073f5d))
|
||||
* Only pass date to flatpickr if it's a valid date ([ede5cdd](ede5cdd8cf5575bba96d3e7b6824a7ad7b414ea7))
|
||||
* Loading state when creating a new task from list view ([aa64e98](aa64e9835c6b9ef2bb10ab8d2a1b4a695cb4321b))
|
||||
* Make add task button 100% height ([3c9c5ef](3c9c5eff1258b6e04e3d0e9299110fa9b5c9757d))
|
||||
* Lint ([2bf9771](2bf9771e2894acb7ad3e563b7b31442d91c49e1a))
|
||||
* Color list titles so that they are visible on cards with a background ([62ed7c5](62ed7c5964f1252f09fe432c42aaf327da5a8c4f))
|
||||
* Missed porting these getters and commits ([95ad245](95ad245b59b0c6398b0bca217572ca36f6ea5a54))
|
||||
* Use https for api url (#2425) ([9f39365](9f3936544d5906f0031412139b53c286023c2405))
|
||||
* Don't use corepack prepare at all ([a199fc7](a199fc7a8e7f621ee96b2079e9558987f1350493))
|
||||
* Add types for node ([6a82807](6a828078a398ab920f0e29d0801b918ae092ef30))
|
||||
* VueI18n global scope fallback warnings (#2437) ([e9cf562](e9cf562969e42cc3ce3ffba3ed093db7a2089395))
|
||||
* Fix missed conversion to ref (#2445) ([94d6f38](94d6f38e89174f879be4e5b1897b52603b40a745))
|
||||
* Don't emit a possible null task ([5f5ed41](5f5ed410df1a2fe73e821d7dee7ebd4c0b918069))
|
||||
* Docker build ([5b60693](5b606936c3f7b0dc1232ad269f3666f8170c6e11))
|
||||
* Update top header list title when saving a filter ([fd3c15d](fd3c15d0642a8d91260ba24eaae52e0ba62c2871))
|
||||
* Type of config stores maxFileSize (#2455) ([78a6d38](78a6d38641c5e4e68f117e37ee36a4ca3b40a24b))
|
||||
* Don't add class method to interface ([367ad1e](367ad1e5a5972ac6ff353275b31f309ebcf5cb4c))
|
||||
* Attachment deletion (#2472) ([f1852f1](f1852f1f33401576ae5033f54613c96cd80e0f95))
|
||||
* Add lodash.debounce types (#2487) ([00e0a23](00e0a23d48c19c440aea7857c8b162a0dfa34361))
|
||||
* Initial modal scroll lock (#2489) ([eae7cc5](eae7cc5a6b506cbbbe694b831cba7c5d1febaf05))
|
||||
* Unset cover image when the task does not have one ([054d70c](054d70cbe5344e39d0e5f277a7db2f26573e1efa))
|
||||
* Lint ([43258ab](43258ab74e0733e91be3ade1f0b13dcf9342cc18))
|
||||
* Lint ([84a1abf](84a1abf3477abbbee136979bd0bde08ae6c54ceb))
|
||||
* Don't try to render auth routes when the user is not authenticated ([3af20b6](3af20b6220d8fcded9c8c2f0bdef21dc26d748f6))
|
||||
* Lint ([f405b21](f405b2105bf4d1cfd4f6acf03210b37ac91eff5e))
|
||||
* Make sure subscriptions are properly inherited between lists and namespaces ([a895bde](a895bde6612e7a2b22a84b6ca7c583bafc9ebc9e))
|
||||
* Make sure subscription strings work consistently across languages ([172d353](172d353df7a86baa9c2759907c7f855679138cc0))
|
||||
* Make sure subscriptions are properly inherited between namespaces and lists ([0a29197](0a29197715f22602faf353fb8fe850150aa710d1))
|
||||
* Lint ([c6d6da3](c6d6da31712906f094a88dbfdb5e9b6db66c29e3))
|
||||
* Move hourToDaytime to separate file in order to pass tests ([5afafb7](5afafb7c82837a3af58c7bdc18174a785691b885))
|
||||
* Postcss-preset-env configuration (#2554) ([b80f82c](b80f82c4118bb372263130df80d15a2a79d2191e))
|
||||
* Password reset ([7357530](73575302debbe095ce031e4871fb3797a801db18))
|
||||
* Email confirmation ([e6f7ddc](e6f7ddc9ce90ddcb3b58b2c001320b6b2c3ac169))
|
||||
* Lint ([643a5b6](643a5b6d7d00bfab4b338582c85217dffa7d9b22))
|
||||
* Make sure services without a modelFactory override still return data ([8fdd3e7](8fdd3e785d3c55281b557827860d0532b94ac758))
|
||||
* Make sure share modals don't have a create button ([ae27502](ae27502022469882656459b0a9e7e8a4b6972c58))
|
||||
* Redirect with query parameters ([f61723d](f61723dac251c9d85102beae73c6a03df10bd4bf))
|
||||
* Task detail view top spacing on mobile ([a695719](a6957191284a8da38e56b4ed3fe0a57b69d6e2b9))
|
||||
* Make sure the filter button is always shown on the kanban board ([8023006](80230069c6f09ced484cd356b816df6b1dd296d6))
|
||||
* Wait until everything is loaded before replacing the current view with the last or login view ([6083301](6083301d1f410ede5fe62127e484169d74ff6dc0))
|
||||
* Show frontend version in about dialog ([5ddce38](5ddce387fe589c574adf0cce438732faf4ad9fd1))
|
||||
* Building version into releases ([a0795db](a0795db0408b5fece13d8a74e9e243375883ca6f))
|
||||
* Lint ([e13e477](e13e477682ef9fd647925f459d8d4527d3c55b9b))
|
||||
* New task input styling ([c3cae78](c3cae78213b791c9e6fd8143ee59e3ca256c374a))
|
||||
* Handle bar styling so they can actually be used ([10c6db3](10c6db3849e734d0508c8d435164a0f771175740))
|
||||
* Make sure the date format is actually valid ([2c012e1](2c012e1a080bd9519384d65ee0653483aa52d1c3))
|
||||
* Make tests work again with new selectors ([091beec](091beecc19cf5ff49fc252c4eeb98aa8a65ddb67))
|
||||
* Use inherit for font family ([b7b4530](b7b4530a111d93e81fc6398dc3f7267cc6e255fb))
|
||||
* Remove precision setting ([970a04d](970a04d9733f4cbdc35e5b772ce4a34fa71e6c4c))
|
||||
* Fix imports ([d91bc50](d91bc5090a6cec38e655c944df7cf57ac16e4133))
|
||||
* Use base store ([f5fd141](f5fd14124fa139f3e76f7a4915b2efc85de6c789))
|
||||
* Correctly import all components ([31f2065](31f2065d2005b27ff8a0abbc4efaa7138cfe27c1))
|
||||
* Update eslint env to 2022 ([0b194bb](0b194bb0cf326104c249c953194997a1f9a80dbf))
|
||||
* Don't try to dynamically load dayjs locales ([b8e7b87](b8e7b87f96bdccf19066ce31d40cf40379014bbe))
|
||||
* Disable dayjsLanguageSync function ([e1f49f2](e1f49f2ff15286ee8903c29dbe708cda90e5d70d))
|
||||
* Scope ListGantt styles ([73eab6c](73eab6c5b5bfe0d72393ab378cce77ad5cbb59b6))
|
||||
* Initial transformation of ganttBars ([407f5f2](407f5f2ef8c4759ea46f5fb74717bafb16f606c5))
|
||||
* ParseBooleanProp ([8dea408](8dea4082bb0766297f74acef0352f8a6a6168d3c))
|
||||
* Do not change language to the current one ([abc2649](abc26496cf0e20d0124af327d47e086b39e2bd23))
|
||||
* Remove IE fallback ([b4f88bd](b4f88bd4a6ba50be1f972794c3e87b7a09f7c2ca))
|
||||
* Improve return type ([0665538](066553838ad289d6c6c0a8b1c6ed0b84139ace54))
|
||||
* Improve notifications (#2583) ([9ded3d0](9ded3d0cd69dd974ffea2531e3ca92438e420f29))
|
||||
* Lint ([9894337](98943377b8344f1f5a8e38c23eff79d7678f51bc))
|
||||
* Label multiselect styling on focus ([da2a7a2](da2a7a224e3c8015939e189692813bc215dbd72c))
|
||||
|
||||
|
||||
### Dependencies
|
||||
|
||||
* *(deps)* Update sentry-javascript monorepo to v7.11.0 (#2274)
|
||||
* *(deps)* Update sentry-javascript monorepo to v7.11.1 (#2275)
|
||||
* *(deps)* Update dependency vitest to v0.22.1 (#2276)
|
||||
* *(deps)* Update dependency sass to v1.54.8 (#2281)
|
||||
* *(deps)* Update dependency caniuse-lite to v1.0.30001387 (#2285)
|
||||
* *(deps)* Update dependency rollup to v2.79.0 (#2278)
|
||||
* *(deps)* Update dependency marked to v4.1.0 (#2284)
|
||||
* *(deps)* Update dependency netlify-cli to v11 (#2287)
|
||||
* *(deps)* Update dependency vite to v3.0.9 (#2279)
|
||||
* *(deps)* Update dependency date-fns to v2.29.2 (#2277)
|
||||
* *(deps)* Update dependency esbuild to v0.15.6 (#2290)
|
||||
* *(deps)* Update dependency vite-plugin-pwa to v0.12.4 (#2291)
|
||||
* *(deps)* Update dependency rollup-plugin-visualizer to v5.8.0 (#2282)
|
||||
* *(deps)* Update dependency easymde to v2.17.0 (#2283)
|
||||
* *(deps)* Update dependency vue-tsc to v0.40.5 (#2292)
|
||||
* *(deps)* Update dependency vue to v3.2.38 (#2293)
|
||||
* *(deps)* Update dependency vue-router to v4.1.5 (#2294)
|
||||
* *(deps)* Update vueuse to v9.1.1 (#2295)
|
||||
* *(deps)* Update dependency @cypress/vue to v4.2.0 (#2296)
|
||||
* *(deps)* Update dependency @faker-js/faker to v7.5.0 (#2297)
|
||||
* *(deps)* Update dependency eslint to v8.23.0 (#2299)
|
||||
* *(deps)* Update dependency cypress to v10.7.0 (#2298)
|
||||
* *(deps)* Update dependency eslint-plugin-vue to v9.4.0 (#2300)
|
||||
* *(deps)* Update sentry-javascript monorepo to v7.12.0 (#2307)
|
||||
* *(deps)* Update dependency dompurify to v2.4.0 (#2306)
|
||||
* *(deps)* Update typescript-eslint monorepo to v5.36.1 (#2304)
|
||||
* *(deps)* Update dependency vite-svg-loader to v3.5.1 (#2302)
|
||||
* *(deps)* Update dependency typescript to v4.8.2 (#2301)
|
||||
* *(deps)* Update font awesome to v6.2.0 (#2303)
|
||||
* *(deps)* Update dependency @kyvg/vue3-notification to v2.4.1 (#2305)
|
||||
* *(deps)* Update sentry-javascript monorepo to v7.12.1 (#2308)
|
||||
* *(deps)* Update dependency vite-plugin-pwa to v0.12.6 (#2309)
|
||||
* *(deps)* Update dependency vue-tsc to v0.40.6 (#2310)
|
||||
* *(deps)* Update dependency rollup-plugin-visualizer to v5.8.1 (#2311)
|
||||
* *(deps)* Update dependency vitest to v0.23.0 (#2312)
|
||||
* *(deps)* Update dependency esbuild to v0.15.7 (#2313)
|
||||
* *(deps)* Update dependency caniuse-lite to v1.0.30001390 (#2314)
|
||||
* *(deps)* Update dependency vue-tsc to v0.40.7 (#2315)
|
||||
* *(deps)* Update dependency vitest to v0.23.1 (#2316)
|
||||
* *(deps)* Update dependency @vitejs/plugin-legacy to v2.1.0 (#2317)
|
||||
* *(deps)* Update dependency @vitejs/plugin-vue to v3.1.0 (#2318)
|
||||
* *(deps)* Update dependency vite to v3.1.0 (#2319)
|
||||
* *(deps)* Update vueuse to v9.2.0 (#2320)
|
||||
* *(deps)* Update typescript-eslint monorepo to v5.36.2 (#2321)
|
||||
* *(deps)* Update dependency vue-tsc to v0.40.9 (#2322)
|
||||
* *(deps)* Pin dependency @types/lodash.clonedeep to 4.5.7 (#2323)
|
||||
* *(deps)* Update dependency @vue/eslint-config-typescript to v11.0.1 (#2324)
|
||||
* *(deps)* Update dependency vite-plugin-pwa to v0.12.7 (#2325)
|
||||
* *(deps)* Update dependency vue-tsc to v0.40.10 (#2326)
|
||||
* *(deps)* Update dependency postcss-preset-env to v7.8.1 (#2328)
|
||||
* *(deps)* Update dependency vite-svg-loader to v3.6.0 (#2327)
|
||||
* *(deps)* Update dependency vue-tsc to v0.40.11 (#2333)
|
||||
* *(deps)* Update dependency sass to v1.54.9 (#2336)
|
||||
* *(deps)* Update dependency vue-tsc to v0.40.13
|
||||
* *(deps)* Update dependency vue to v3.2.39
|
||||
* *(deps)* Update dependency typescript to v4.8.3 (#2341)
|
||||
* *(deps)* Update dependency vitest to v0.23.2
|
||||
* *(deps)* Update dependency autoprefixer to v10.4.9
|
||||
* *(deps)* Update dependency caniuse-lite to v1.0.30001397
|
||||
* *(deps)* Update dependency netlify-cli to v11.7.1
|
||||
* *(deps)* Update dependency eslint to v8.23.1
|
||||
* *(deps)* Update typescript-eslint monorepo to v5.37.0
|
||||
* *(deps)* Update dependency blurhash to v2 (#2351)
|
||||
* *(deps)* Update dependency date-fns to v2.29.3 (#2354)
|
||||
* *(deps)* Update dependency autoprefixer to v10.4.10 (#2355)
|
||||
* *(deps)* Update dependency cypress to v10.8.0 (#2359)
|
||||
* *(deps)* Update dependency autoprefixer to v10.4.11 (#2363)
|
||||
* *(deps)* Update dependency postcss-preset-env to v7.8.2
|
||||
* *(deps)* Update dependency vite to v3.1.1 (#2365)
|
||||
* *(deps)* Pin dependency @types/dompurify to 2.3.4
|
||||
* *(deps)* Update sentry-javascript monorepo to v7.13.0
|
||||
* *(deps)* Update dependency eslint-plugin-vue to v9.5.0 (#2371)
|
||||
* *(deps)* Update dependency eslint-plugin-vue to v9.5.1 (#2373)
|
||||
* *(deps)* Update dependency vite to v3.1.2
|
||||
* *(deps)* Update dependency @types/sortablejs to v1.15.0
|
||||
* *(deps)* Update dependency vitest to v0.23.4
|
||||
* *(deps)* Update dependency esbuild to v0.15.8
|
||||
* *(deps)* Update dependency vite-plugin-pwa to v0.12.8 (#2375)
|
||||
* *(deps)* Update caniuse-and-related to v4.21.4 (#2379)
|
||||
* *(deps)* Update dependency netlify-cli to v11.8.0 (#2380)
|
||||
* *(deps)* Update dependency @vitejs/plugin-legacy to v2.2.0 (#2381)
|
||||
* *(deps)* Update dependency vite to v3.1.3 (#2382)
|
||||
* *(deps)* Update typescript-eslint monorepo to v5.38.0 (#2383)
|
||||
* *(deps)* Update dependency vite-plugin-pwa to v0.13.0 (#2385)
|
||||
* *(deps)* Update dependency easymde to v2.18.0 (#2386)
|
||||
* *(deps)* Update dependency autoprefixer to v10.4.12
|
||||
* *(deps)* Update dependency pinia to v2.0.22 (#2400)
|
||||
* *(deps)* Update dependency @vue/eslint-config-typescript to v11.0.2
|
||||
* *(deps)* Update dependency vite-plugin-pwa to v0.13.1
|
||||
* *(deps)* Update dependency rollup to v2.79.1
|
||||
* *(deps)* Update dependency codemirror to v5.65.9
|
||||
* *(deps)* Update pnpm to v7.12.1
|
||||
* *(deps)* Update dependency sass to v1.55.0
|
||||
* *(deps)* Update dependency esbuild to v0.15.9
|
||||
* *(deps)* Update pnpm to v7.12.2 (#2408)
|
||||
* *(deps)* Update dependency caniuse-lite to v1.0.30001412 (#2421)
|
||||
* *(deps)* Update dependency netlify-cli to v11.8.3 (#2422)
|
||||
* *(deps)* Update dependency eslint to v8.24.0 (#2410)
|
||||
* *(deps)* Update vueuse to v9.3.0 (#2423)
|
||||
* *(deps)* Update dependency rollup-plugin-visualizer to v5.8.2 (#2420)
|
||||
* *(deps)* Update typescript-eslint monorepo to v5.38.1 (#2426)
|
||||
* *(deps)* Update dependency blurhash to v2.0.1
|
||||
* *(deps)* Update dependency cypress to v10.9.0 (#2429)
|
||||
* *(deps)* Update dependency @types/node to v16.11.62 (#2430)
|
||||
* *(deps)* Update dependency typescript to v4.8.4
|
||||
* *(deps)* Update dependency vue to v3.2.40
|
||||
* *(deps)* Update dependency blurhash to v2.0.2
|
||||
* *(deps)* Update sentry-javascript monorepo to v7.14.0 (#2440)
|
||||
* *(deps)* Update dependency vite to v3.1.4 (#2439)
|
||||
* *(deps)* Update dependency @vue/test-utils to v2.1.0
|
||||
* *(deps)* Update dependency esbuild to v0.15.10
|
||||
* *(deps)* Update dependency @cypress/vite-dev-server to v3.2.0 (#2448)
|
||||
* *(deps)* Update dependency postcss to v8.4.17 (#2449)
|
||||
* *(deps)* Update dependency marked to v4.1.1
|
||||
* *(deps)* Update dependency @vitejs/plugin-vue to v3.1.2 (#2461)
|
||||
* *(deps)* Update dependency @types/node to v16.11.63 (#2464)
|
||||
* *(deps)* Update dependency caniuse-lite to v1.0.30001414 (#2465)
|
||||
* *(deps)* Update pnpm to v7.13.0 (#2467)
|
||||
* *(deps)* Update dependency netlify-cli to v12 (#2466)
|
||||
* *(deps)* Update dependency vue-advanced-cropper to v2.8.5 (#2469)
|
||||
* *(deps)* Update dependency blurhash to v2.0.3 (#2468)
|
||||
* *(deps)* Update sentry-javascript monorepo to v7.14.1 (#2471)
|
||||
* *(deps)* Update typescript-eslint monorepo to v5.39.0
|
||||
* *(deps)* Update dependency @types/node to v16.11.64 (#2479)
|
||||
* *(deps)* Update dependency eslint-plugin-vue to v9.6.0 (#2480)
|
||||
* *(deps)* Update pnpm to v7.13.1
|
||||
* *(deps)* Update dependency vue-advanced-cropper to v2.8.6 (#2483)
|
||||
* *(deps)* Pin dependency @rushstack/eslint-patch to 1.2.0 (#2486)
|
||||
* *(deps)* Pin dependency @types/lodash.debounce to 4.0.7 (#2488)
|
||||
* *(deps)* Update dependency happy-dom to v7 (#2492)
|
||||
* *(deps)* Update dependency vite to v3.1.5
|
||||
* *(deps)* Update dependency happy-dom to v7.0.2
|
||||
* *(deps)* Update sentry-javascript monorepo to v7.14.2
|
||||
* *(deps)* Update pnpm to v7.13.2
|
||||
* *(deps)* Update dependency vue-flatpickr-component to v9.0.8 (#2494)
|
||||
* *(deps)* Update dependency vite to v3.1.6
|
||||
* *(deps)* Update dependency happy-dom to v7.0.4 (#2499)
|
||||
* *(deps)* Update dependency @cypress/vite-dev-server to v3.3.0 (#2501)
|
||||
* *(deps)* Update dependency happy-dom to v7.0.6 (#2500)
|
||||
* *(deps)* Update dependency happy-dom to v7.3.0 (#2502)
|
||||
* *(deps)* Update dependency vitest to v0.24.0 (#2503)
|
||||
* *(deps)* Update dependency vue-tsc to v1 (#2504)
|
||||
* *(deps)* Update dependency happy-dom to v7.4.0 (#2505)
|
||||
* *(deps)* Update dependency eslint to v8.25.0
|
||||
* *(deps)* Update dependency vue-tsc to v1.0.1 (#2507)
|
||||
* *(deps)* Update dependency pinia to v2.0.23 (#2509)
|
||||
* *(deps)* Update dependency express to v4.18.2
|
||||
* *(deps)* Update pnpm to v7.13.3 (#2511)
|
||||
* *(deps)* Update dependency vue-tsc to v1.0.2 (#2510)
|
||||
* *(deps)* Update dependency vue-tsc to v1.0.3 (#2512)
|
||||
* *(deps)* Update dependency netlify-cli to v12.0.7 (#2514)
|
||||
* *(deps)* Update dependency caniuse-lite to v1.0.30001418 (#2513)
|
||||
* *(deps)* Update dependency vite to v3.1.7 (#2515)
|
||||
* *(deps)* Update sentry-javascript monorepo to v7.15.0 (#2516)
|
||||
* *(deps)* Update dependency vitest to v0.24.1 (#2517)
|
||||
* *(deps)* Update pnpm to v7.13.4 (#2518)
|
||||
* *(deps)* Update typescript-eslint monorepo to v5.40.0 (#2519)
|
||||
* *(deps)* Update dependency @types/node to v16.11.65 (#2520)
|
||||
* *(deps)* Update dependency minimist to v1.2.7 (#2521)
|
||||
* *(deps)* Update dependency rollup to v3 (#2524)
|
||||
* *(deps)* Update dependency @cypress/vite-dev-server to v3.3.1 (#2523)
|
||||
* *(deps)* Update dependency cypress to v10.10.0 (#2525)
|
||||
* *(deps)* Update dependency vue-tsc to v1.0.4 (#2526)
|
||||
* *(deps)* Update dependency vue-tsc to v1.0.5 (#2527)
|
||||
* *(deps)* Update dependency rollup to v3.1.0 (#2528)
|
||||
* *(deps)* Update dependency @faker-js/faker to v7.6.0 (#2530)
|
||||
* *(deps)* Update dependency vue-tsc to v1.0.6 (#2529)
|
||||
* *(deps)* Update dependency postcss to v8.4.18 (#2532)
|
||||
* *(deps)* Update dependency vue-tsc to v1.0.7 (#2533)
|
||||
* *(deps)* Update dependency vite to v3.1.8 (#2534)
|
||||
* *(deps)* Update dependency vue to v3.2.41 (#2538)
|
||||
* *(deps)* Update dependency vitest to v0.24.3 (#2536)
|
||||
* *(deps)* Update dependency @cypress/vue to v4.2.1 (#2535)
|
||||
* *(deps)* Update dependency esbuild to v0.15.11 (#2539)
|
||||
* *(deps)* Update dependency rollup to v3.2.0 (#2541)
|
||||
* *(deps)* Update dependency vue-tsc to v1.0.8 (#2540)
|
||||
* *(deps)* Update dependency rollup to v3.2.1 (#2545)
|
||||
* *(deps)* Update dependency @types/node to v16.11.66 (#2544)
|
||||
* *(deps)* Update dependency ufo to v0.8.6 (#2542)
|
||||
* *(deps)* Update dependency rollup-plugin-visualizer to v5.8.3 (#2543)
|
||||
* *(deps)* Update pnpm to v7.13.5
|
||||
* *(deps)* Update dependency rollup to v3.2.2 (#2549)
|
||||
* *(deps)* Update dependency netlify-cli to v12.0.9 (#2551)
|
||||
* *(deps)* Update vueuse to v9.3.1 (#2552)
|
||||
* *(deps)* Update dependency caniuse-lite to v1.0.30001420 (#2550)
|
||||
* *(deps)* Update dependency happy-dom to v7.5.12 (#2553)
|
||||
* *(deps)* Pin dependency @types/postcss-preset-env to 7.7.0 (#2555)
|
||||
* *(deps)* Update dependency rollup to v3.2.3 (#2556)
|
||||
* *(deps)* Update typescript-eslint monorepo to v5.40.1 (#2557)
|
||||
* *(deps)* Update dependency @types/node to v16.11.68 (#2558)
|
||||
* *(deps)* Update sentry-javascript monorepo to v7.16.0 (#2560)
|
||||
* *(deps)* Update dependency esbuild to v0.15.12 (#2561)
|
||||
* *(deps)* Update pnpm to v7.13.6 (#2562)
|
||||
* *(deps)* Update dependency vue-flatpickr-component to v10 (#2563)
|
||||
* *(deps)* Update dependency eslint to v8.26.0 (#2564)
|
||||
* *(deps)* Update pnpm to v7.14.0 (#2565)
|
||||
* *(deps)* Update dependency vue-tsc to v1.0.9 (#2566)
|
||||
* *(deps)* Update dependency @types/node to v16.18.0 (#2567)
|
||||
* *(deps)* Update dependency happy-dom to v7.6.0 (#2571)
|
||||
* *(deps)* Update dependency @vue/test-utils to v2.2.0 (#2570)
|
||||
* *(deps)* Update dependency caniuse-lite to v1.0.30001423 (#2568)
|
||||
* *(deps)* Update dependency netlify-cli to v12.0.11 (#2569)
|
||||
* *(deps)* Update dependency vue-router to v4.1.6 (#2572)
|
||||
* *(deps)* Update typescript-eslint monorepo to v5.41.0 (#2573)
|
||||
* *(deps)* Update dependency @types/node to v18 (#2574)
|
||||
* *(deps)* Update vueuse to v9.4.0 (#2575)
|
||||
* *(deps)* Update dependency cypress to v10.11.0 (#2576)
|
||||
* *(deps)* Update dependency @types/node to v18.11.6
|
||||
* *(deps)* Update dependency vite to v3.2.0 (#2580)
|
||||
* *(deps)* Update dependency @types/node to v18.11.7 (#2581)
|
||||
* *(deps)* Update dependency @vitejs/plugin-legacy to v2.3.0 (#2578)
|
||||
* *(deps)* Update dependency @vitejs/plugin-vue to v3.2.0 (#2579)
|
||||
* *(deps)* Update sentry-javascript monorepo to v7.17.0
|
||||
* *(deps)* Update sentry-javascript monorepo to v7.17.1 (#2585)
|
||||
* *(deps)* Update dependency autoprefixer to v10.4.13 (#2586)
|
||||
|
||||
### Features
|
||||
|
||||
* *(gantt)* Trying to load gantt-chart
|
||||
* *(gantt)* Add task collection to useGanttFilter
|
||||
* *(gantt)* Use time constants
|
||||
* *(gantt)* Reset gantt filter
|
||||
* *(gantt)* Disable useDayjsLanguageSync
|
||||
* *(link shares)* Hide the logo if a query parameter was passed
|
||||
* *(link shares)* Allows switching the initial view by passing a query parameter
|
||||
* *(link shares)* Cleanup link share table
|
||||
* *(link shares)* Allows switching the initial view by passing a query parameter (#2335)
|
||||
* *(list)* Add info dialoge to show list description (#2368)
|
||||
* *(openid)* Show error message from query after being redirected from third party
|
||||
* *(task)* Cover image for tasks (#2460)
|
||||
* *(tests)* Add tests for task attachments* Settings background script setup (#2104) ([ff65580](ff655808b3cb562bd1c843ff70bf3641718ae61d))
|
||||
* List settings edit script setup (#1988) ([f6437c8](f6437c81da73b7e3406c28b9bd7b201e376f15c3))
|
||||
* Convert abstractService to ts ([74ad6e6](74ad6e65e88d6aa5702686dd0b6f55e2dc6b7b77))
|
||||
* Add properties to models ([797de0c](797de0c5432face3887f4d77bcb7dd7ee2e7e0c1))
|
||||
* Constants ([8fb0065](8fb00653e47c6f41a0e461c944b401d58b4a2351))
|
||||
* Function attribute typing ([332acf0](332acf012c423d3201ec1811093226447cd065e8))
|
||||
* Improve types ([c9e85cb](c9e85cb52b562cf9dcfac3ed54d8289e2b499992))
|
||||
* Improve store and model typing ([3766b5e](3766b5e51ba9c40a6affa91ce5cc11519e2da5c3))
|
||||
* Use lib ESNext setting for typescript ([79e7e4a](79e7e4a8aefe9f4d00bcbad76c4206c409384b61))
|
||||
* Extend mode interface from class instead from interface ([a6b96f8](a6b96f857d949874ba75f657b887a7c997aa7c57))
|
||||
* Improve store typing ([2444784](244478400ad8b8243ae2b29d741c03fa2b83601b))
|
||||
* Add modelTypes ([7d4ba62](7d4ba6249e300b6711369476f5d6a84728668b0f))
|
||||
* Convert services and models to ts (#1798) ([dbea1f7](dbea1f7a51f3cf5173b5f381944c4ef19ef97ec8))
|
||||
* Add sponsor logo to readme (relm) ([e959043](e95904351fbd30776306225f3be55978d70ae42e))
|
||||
* Show user display name when searching for assignees on a list ([65fd2f1](65fd2f14a067ea9d79b352af00f3c316be883fdf))
|
||||
* Add keyboard shortcut to toggle task description edit (#2332) ([7f6f896](7f6f8963e7db236f3beb9e6a36fab4ba479b969b))
|
||||
* Programmatically generate list of available views ([26d02d5](26d02d5593283c3ad2fb961348ba2f412cc9eaa8))
|
||||
* Add fallback for useCopyToClipboard (#2343) ([7b398f7](7b398f73f604d6564a41c3ce5031883c677f02c7))
|
||||
* Improve models ([1a11b43](1a11b43ca8d51bf998019fbc741e845b07d70157))
|
||||
* Use v-model more consequent (#2356) ([db8b881](db8b8812af731fb6acbdd1aec173e37b84066eea))
|
||||
* Make share link name italic ([224cea3](224cea33ced403f45c7d833ab576be44c89d199a))
|
||||
* Move the url link to the bottom of the items ([6576b61](6576b6148ce1b02dbe6a335778592c4b72e275de))
|
||||
* Color the task color button when the task has a color set ([51c806c](51c806c12b90aa124384497856590f5010b9ff49))
|
||||
* Color the color button icon instead of the button itself ([bdf992c](bdf992c9bfe9de176a22f7b5a6fdae1bc5e5010f))
|
||||
* Move the update available dialoge always to the bottom ([a18c6ab](a18c6ab8d860a496905f58278315222992bacd07))
|
||||
* Show the task color bubble everywhere ([2683fec](2683fec0a67f6afd16579bb44a6ceadc0edd565f))
|
||||
* Color the task color button when the task has a color set (#2331) ([f70b1d2](f70b1d2902f91a88eaf33f1a9799489c20a6a143))
|
||||
* Namespace settings archive script setup ([ad6b335](ad6b335d41e07e8ce2e74e4282d572ba4c04ea30))
|
||||
* ListNamespaces script setup (#2389) ([ff5d1fc](ff5d1fc8c1961134ef3baec09be52b02c0b6898e))
|
||||
* NewTeam script setup (#2388) ([e91b5fd](e91b5fde0216e15f739da22efbcaae3829e31ba1))
|
||||
* Port label store to pinia | pinia 1/9 (#2391) ([d67e5e3](d67e5e386d7d1901694fe0004f580807754bcae1))
|
||||
* Use pnpm ([d76b526](d76b526916d4aca279670d2690f7bb8e63e432a7))
|
||||
* Move list store to pina (#2392) ([a38075f](a38075f376aa5cc2d8a06943cf8932366a0d4011))
|
||||
* Task relatedTasks script setup ([943d5f7](943d5f79757b73f447c51641812e7766edeffe9e))
|
||||
* Allow marking a related task done directly from the list ([ce0f58c](ce0f58c7833bbb37974709112cdedad88ae07cc8))
|
||||
* DeleteNamespace script setup (#2387) ([0814890](0814890cac92b813b5b93bb42c7a40e2dc13cb94))
|
||||
* Task relatedTasks script setup (#1939) ([d57e27b](d57e27b4a62aaa0f0a739f030515fff72a56f7fc))
|
||||
* Use pnpm (#1789) ([f7ca064](f7ca064127863de4a4c1e3ae29d84d6bd5311cb9))
|
||||
* Add hot reloading support ([1c58fcc](1c58fccd926586b2303ce41939a535b2044a78a9))
|
||||
* Move namespaces store to stores ([9474240](9474240cb9159a0e1b42f82cb492cc267782ce4f))
|
||||
* Port namespace store to pinia ([093ab76](093ab766d45247b3b1d12740dc6b24c6b48f21c4))
|
||||
* Feat-attachments-script-setup (#2358) ([4dfcd8e](4dfcd8e70f54d2ed977d4b8de5fb8bf9469819aa))
|
||||
* Convert namespaces store to pina (#2393) ([937fd36](937fd36f724f2b383fe51ae25a55ba90f58c8975))
|
||||
* Move attachments store to stores ([c2ba1b2](c2ba1b2828439d3bd1e846a4bb9a4c456562c460))
|
||||
* Port attachments store to pinia ([20e9420](20e94206388ab694248942996fdb67b7be87e76f))
|
||||
* Move config to stores ([9e8c429](9e8c429864923215be5b110fdcb7c4a586c60f3d))
|
||||
* Port config store to pinia ([a737fc5](a737fc5bc2affc87b209746ecf04c66e1f6077db))
|
||||
* Filter-popup script setup (#2418) ([ba2605a](ba2605af1bb6f9ba7d3bd1b99ed862d510c6bb31))
|
||||
* ListLabels script setup (#2416) ([89e428b](89e428b4d285f3465a40773fbda564c432fb371e))
|
||||
* Possible fix for pnpm ci errors ([e8f0b56](e8f0b5665161e77bcc961ec0dc57c5b127b93a1f))
|
||||
* NewLabel script setup (#2414) ([7f581cb](7f581cbe2780633fdfa03609824182fe93fe77e3))
|
||||
* Possible fix for pnpm ci errors (#2413) ([bc83309](bc833091f2b919177ce75815b562818c93ea2884))
|
||||
* Feat NewNamespace script setup (#2415) ([63f2e6b](63f2e6ba6f22502becf61aa89c729fa9d01cdc7b))
|
||||
* ListList script setup (#2441) ([bbf4ef4](bbf4ef4697fc6338ad603e2491fe4aed61057cd8))
|
||||
* Move auth to stores ([f30c964](f30c964c06987f87b615c3eec25197241175db96))
|
||||
* Port auth store to pinia ([7b53e68](7b53e684aa405a7874f189dcb404c031dfed1388))
|
||||
* Auth store type improvements ([176ad56](176ad565cc64e2212eedb1601c844e458d7e4bb6))
|
||||
* Improve api-config (#2444) ([8f25f5d](8f25f5d353064f383e97bbc524ce6e00ba559d0f))
|
||||
* Convert model methods to named functions ([8e3f54a](8e3f54ae42c21fdae62225892ad340877651df27))
|
||||
* Migrate auth store to pina (#2398) ([9856fab](9856fab38f62f82a42d5cb3b69b232eb319b8050))
|
||||
* Move tasks to stores ([1fdda07](1fdda07f650702b7e3943e0afc7532367ee20100))
|
||||
* Port tasks store to pinia ([34ffd1d](34ffd1d5729341bdede217387a4a4c490d7d60d8))
|
||||
* Move kanban to stores ([9f26ae1](9f26ae1ee6241b2ef529f01d3511380c9d7a4576))
|
||||
* Port kanban store to pinia ([c35810f](c35810f28fc5aacefabad7526b0ac4e982d53cc7))
|
||||
* Port tasks store to pina (#2409) ([8c394d8](8c394d8024a825b961e825543453d188c28fa370))
|
||||
* Automatically create subtask relations based on indention ([cc378b8](cc378b83fee2b326610cdda1997cc5236f947fbf))
|
||||
* Automatically create subtask relations based on indention (#2443) ([ec227a6](ec227a6872ababb612cb0b7e68ca0c20676117c1))
|
||||
* Migrate kanban store to pina (#2411) ([d1d7cd5](d1d7cd535ed992fc0a8be8afaf13250ac9b61132))
|
||||
* Move base store to stores ([df74f9d](df74f9d80cdd44315a29189ecb2f236482cb70f5))
|
||||
* Port base store to pinia ([7f281fc](7f281fc5e98c5eb83f926100c7f79ee374c5a784))
|
||||
* Rework loading state of stores ([1d7f857](1d7f857070651f676bbb5bd7e6d79c7fed56be5f))
|
||||
* TaskDetail as script setup (#1792) ([2dc36c0](2dc36c032bad93654fbd64a68682685870972feb))
|
||||
* Add github issue template ([9400637](940063784b3ec129e99fe18c4eb2b205ffb15163))
|
||||
* Login script setup (#2417) ([63fb8a1](63fb8a1962f9ecd8c9a079e2770b4658c5559d84))
|
||||
* Datepicker script setup (#2456) ([ff1968a](ff1968aa36254d788d0d80ba2d156ce66f4a9df8))
|
||||
* Multiselect script setup (#2458) ([0620b8f](0620b8f0b308e358526bed0d82322ffb9c0627cf))
|
||||
* ColorPicker script setup (#2457) ([b08dd58](b08dd58552edb763f007f355f5c0d36d6dccbd05))
|
||||
* Migrate kanban card to script setup ([a5925ba](a5925baff03ac2809b7c601b45b93363b6188083))
|
||||
* Migrate kanban card to script setup (#2459) ([3e21a8e](3e21a8ed6ee74d85628feedd8855c817af8de538))
|
||||
* Add nix flake for dev shell ([12215c0](12215c043d45d2f2294e65671587a923997e6f6f))
|
||||
* Fancycheckbox script setup (#2462) ([06c1a54](06c1a548867e37a74a8493bd44fef728e10c658b))
|
||||
* Editor script setup ([db627ed](db627ed28af8432e6971ad08864d11e56d3512c6))
|
||||
* Use floating-ui (#2482) ([f360ebf](f360ebfe9854aeae9cb426c67b1bb48aa74a9c08))
|
||||
* Update eslint config ([4655e1c](4655e1ce34223337c953ebbe52f94ef811034e6b))
|
||||
* Feature/update-eslint-config (#2484) ([6f2dedc](6f2dedcb488ec6a38182e85e702ec880263ecbd3))
|
||||
* Move composables in separate files (#2485) ([c206fc6](c206fc6f3462be2e0ebc0bd16d96b3c0099fdda1))
|
||||
* Add display of kanban card attachment image ([3d88fda](3d88fdaaddca15b98efa938f0b2813420d56ad84))
|
||||
* Promote an attachment to task cover image ([877e425](877e4250554b31db2d57f44a7443c5d04c783e59))
|
||||
* Add indicator if an attachment is task cover ([f01107f](f01107fd737e2205bf60498b3d2954a251c3d9d4))
|
||||
* Show done tasks as strikethrough when searching for new tasks to relate ([74a9b9a](74a9b9ab1b31740fe84a7dddd91a04995c1eb58d))
|
||||
* Allow users to leave a team they're in ([feeaca2](feeaca2c02fb233c35a81f786acd5cbdf5c5d21d))
|
||||
* Add TickTick migrator support ([1af4f78](1af4f7811a63826c4aa4740a55f606757e22c7ae))
|
||||
* Make salutation i18n static ([c20de51](c20de51a3c98792580c0a2f2751648582ac5ac0c))
|
||||
* Get username from store getter ([c4d7f6f](c4d7f6fdfa18c221597b28198d5fa432b1e934dc))
|
||||
* Use getter and helper in other components as well ([9de20b4](9de20b4c54d192a20f9135388de9fa13121ed322))
|
||||
* Make salutation i18n static (#2546) ([29f6874](29f68747bbd7da50d37ae3238b6b19782ec8022b))
|
||||
* Refactor password reset to use a single password field ([4ed665f](4ed665fbd9dc4db1ecb6afc1a75d1818c3518186))
|
||||
* Rename useTaskList ([7ce8802](7ce880239ec3ce16313d93bfefa657c499bbfb29))
|
||||
* Add basic implementation of ganttastic ([2b0df8c](2b0df8c2375ec5f9afe43207807e999bcc693d21))
|
||||
* Allow passing props down to the gantt component ([49a2497](49a24977f96cff1e90e706321505ae43bf7efadf))
|
||||
* Only load tasks which start in the currently selected range ([ed241d2](ed241d21bea91795a10cdc1af92561d435c9eedc))
|
||||
* Dynamically set default date ([736e5a8](736e5a8bf55ccf7cbed23fd3af48122c459bcdc6))
|
||||
* Dynamically set default date ([3b48ada](3b48adad675b0b20dc91a08f8ebbfe1dd1c3806b))
|
||||
* Create new tasks ([ef46893](ef4689335b3e738b7e1338657e9dcd69c82fbcb9))
|
||||
* Add open task detail when double clicking ([d2c4092](d2c40926ded479db92d0f3b77d2ece5842bcacbb))
|
||||
* Scroll ([c8eac91](c8eac914d10a09453afb70d35c6d16faac9cd00c))
|
||||
* Styling ([80c151c](80c151ca6c4a76a5f912505672eee471f77a3bba))
|
||||
* Update task in gantt bar after dragging to make sure it changes its color ([ebd824b](ebd824bddf8d37a66d2dbf7f330b39c8849db9b2))
|
||||
* Show done tasks strikethrough ([3eacc07](3eacc0754ff50fed2d5a50198480c5c8d697f6ce))
|
||||
* Handle changing props ([29dcc02](29dcc02217dfe9d52b3cdd6166ca82cc8be1022e))
|
||||
* Loading animation ([8c62a9e](8c62a9e198fb5b8221a13747e9510f5036ed3095))
|
||||
* Create task when pressing the button ([0a9588e](0a9588e09730e83ddc61630012e62c0530a9997d))
|
||||
* Increase the default date range ([5f7159e](5f7159ebc49e73bc4757c7cefa9a10ed14d65b46))
|
||||
* Only use one watcher ([64fdae8](64fdae81ec8a1b807a1b1788a6954c8d7850dc36))
|
||||
* Review changes ([f21a4e1](f21a4e1e9f558e999e1f6638847aeab4d73b9636))
|
||||
* Update ganttastic version ([2f820e5](2f820e517f6dea384440a9574da4f82c02c86143))
|
||||
* Improve types ([3b244df](3b244dfdbecf2f1feaa766b5c9e52c7e66dfe52a))
|
||||
* Working route sync ([acdbf2f](acdbf2f8f5b8e28e923d7598696dadec373c7a67))
|
||||
* Working gantt-chart ([eaf7778](eaf777864ac857275bc657bf39f1886460d307d2))
|
||||
* Abstract to useGanttFilter / and useRouteFilter ([2c732eb](2c732eb0d55c9161b8d47cbc850421136994bff4))
|
||||
* Simplify ListGantt styles ([c7dd20e](c7dd20ef57f037db0ac8bbdc583463ae98ffe9ac))
|
||||
* Move useGanttTaskList in separate file ([7f4114b](7f4114b7032c24d9305c7c731ad1fef2f9390dcd))
|
||||
* Remove gantt-chart wrapper ([aefda38](aefda38bdd8fa5f5b4f4d2c7486566f669dd6929))
|
||||
* Use PascalCase for component name ([acb3ddc](acb3ddc73fd7a8240d42774c80c68b5a725c3734))
|
||||
* Use ref for filters ([51dc123](51dc123d893517a30c2dbb26a68e877b493ec95e))
|
||||
* Use plural for filters consequently ([6bf6357](6bf6357cbd281fa5b99b7aae9845fee90c758ae7))
|
||||
* Move config preparation in separate function ([e74e6fc](e74e6fcc996cced93f040782ac278db6baea975e))
|
||||
* Align with vue-flatpickr-component 10 ([874dc1e](874dc1e5fc9f76ad3d45f555b9d04585cd9a2704))
|
||||
* Replace our home-grown gantt implementation with ganttastic (#2180) ([fd3e7e6](fd3e7e655dbbd59f9a94db0f18a3ef4876cec059))
|
||||
* Improve useTaskList (#2582) ([d5258b7](d5258b73153a477a82c750482a6fd504c5823b7a))
|
||||
* Unify savedFilter logic in service (#2491) ([9807858](9807858436e4b7d6de8dcb71b2a03a55ed8a7d52))
|
||||
* Quick-actions script setup (#2478) ([386fd79](386fd79b4983b9d472d46219fc60c1a1a2cc1012))
|
||||
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
* *(ci)* Sign drone config
|
||||
* *(ci)* Sign drone config
|
||||
* *(gantt)* Wip daterange
|
||||
* *(gantt)* Upgrade packages
|
||||
* *(gantt)* Upgrade packages
|
||||
* *(gantt)* Pnpm install after merge
|
||||
* *(i18n)* Use global scope
|
||||
* *(task)* Move cover image setter to store* Improve type imports ([af630d3](af630d3b8c1536c1a9a320172aaf19e000bb2517))
|
||||
* Remove date mixins ([b0ee316](b0ee316a262ca71b9cfecbaaeccab7f9465ec09d))
|
||||
* Remove global mixing ([4a247b2](4a247b2a7d6741bfec9fbdb387c9313d7b6381d1))
|
||||
* Remove unnecessary defineComponent ([6f93d63](6f93d6343c1c518fec3591b83b999efcbccf9607))
|
||||
* Better variable typing ([42e72d1](42e72d14a4a804aa38908cc2a9d6b4cb120c988a))
|
||||
* Align docker cypress image version with drone ([2445f0e](2445f0eec8b130d8d71e5fc399a399c0d1cf6836))
|
||||
* Minor fixes ([49f3b92](49f3b928cbc16031cf65fa3ed1cc908968e1083b))
|
||||
* Automerge renovate dev dependency updates ([d822709](d822709991ee4dc52ee8aa56a03248c6e4a3a709))
|
||||
* Rearrange non-dev dependencies ([b8d77a6](b8d77a617b0b205fbf8553d3ce060547c96f0f22))
|
||||
* Remove ([d91d1fe](d91d1fecf1b34734ef8af21c3c34bdaaa6d53e09))
|
||||
* Remove unused id ([5f678e2](5f678e2449529758cd6ade233c52a0c091889fd9))
|
||||
* Set more expressive variable names for available views dropdowns ([7e7fa80](7e7fa807fd1c6a34c5236cca4fb20141ca9d0454))
|
||||
* Improve types ([6d9c4a7](6d9c4a7aa083425e252b96729b57c16ab13fd295))
|
||||
* Don't cache node_modules ([b542221](b542221dac6a14cd84aab446ceab0888bc98bb38))
|
||||
* Don't use node alpine image ([6624db1](6624db1d49545524083d124698fa5b6e02bbfb0c))
|
||||
* Use node alpine image ([dfb3561](dfb3561310bec49043a630136a2d51cc80184cc1))
|
||||
* Optimise loading order (#2435) ([ca899d3](ca899d3b5172be6f39a60bdaffab58330225ecd9))
|
||||
* Make const out of export download file name (#2436) ([878c6ea](878c6ea9e17527b3f199f4acf10588e910b5727c))
|
||||
* Spread title ([3970d0f](3970d0fd315488427df0c4a37447eb52dca322b4))
|
||||
* Use better variable names ([8ce242b](8ce242bb6595ef12442a6ba0fb37eb66c65dd71b))
|
||||
* Break earlier if index === 0 ([d58f8b4](d58f8b4ba1d873abb0fc8dc4c2cec64a33b55ab8))
|
||||
* Use jsDoc to explain param ([5bd7c77](5bd7c77b68f08ab4771f3d80d5191def9d634204))
|
||||
* Small review adjustments ([af7f840](af7f8400e901c2f4d9c5c4cca7614af62892a75e))
|
||||
* Remove unneeded this from PasswordReset.vue (#2473) ([c232170](c2321703a767395b77523d4551ea508396b7cae8))
|
||||
* Remove IE edge fallback (#2477) ([3248dcd](3248dcd6636627548f2df869900a7943c0dde0ba))
|
||||
* Add line-wrap ([eb80bfa](eb80bfa00de891ee12643d664e8610d1f3bc851f))
|
||||
* Better wording for cover set button ([a773137](a7731370a0bcdd8a393036a617dd1953cd39f5df))
|
||||
* Update happy-dom less frequently ([458df80](458df8044306642e5da813ff8341bed07f67f26a))
|
||||
* Move helper function outside of composable ([aa2278a](aa2278a56411dc8045fa468b090755cf5d899d09))
|
||||
* Use flatpickr range instead of two datepickers ([c289a6a](c289a6ae18fd5936b789270cc72408374a790edc))
|
||||
* Use width property ([7a7a1c9](7a7a1c985e0feb8de62ddbdd54f36f2a09a9d765))
|
||||
* Remove old component and dependencies ([6cb331e](6cb331ee0f26dffcbf700426da17acb6159aea3e))
|
||||
* Use Loading component ([766b4c6](766b4c669ff52f6d6c888727e62142eaa90de54d))
|
||||
* Use @/models ([d3925b8](d3925b8d80e16e25e9b82d057fb47ed9f41f61a0))
|
||||
* Uppercase const ([98d0398](98d0398ca840d8d8077f850c8ca4e65784373b61))
|
||||
* Don't set required if there's a default value ([ed5d3be](ed5d3be7cba7992eb18a3ed1844c085cf88b3bdd))
|
||||
* Define types ([56a2573](56a25734d7557663e2ba43ba41f4922f0b10ed8b))
|
||||
* Don't use for..in ([6975a2b](6975a2b286628294b8909bce3d43334cc383d987))
|
||||
* Add types for template ref ([4be0977](4be097701449b74bbeb7218b539db65961539591))
|
||||
* Don't use ref when not nessecary ([fd9d0ad](fd9d0ad1553756414696315508bc2d8928f63d9d))
|
||||
* Update lockfile ([957d8f0](957d8f05a5e9548138f8dce192513928deb02669))
|
||||
* Better naming for input ([df02dd5](df02dd529181e9701ce586dba9025c83eeaf48d8))
|
||||
* Clean up ([2acb70c](2acb70c56257202fe7d136b36ceaaa2fe122491e))
|
||||
* Pnpm install after merge ([26e522c](26e522cf8c302f5d63b26134e5fa37bed5c808ef))
|
||||
* Use vue-ganttastic release ([6c61907](6c619072b4863328c24588bb08a9543806942be1))
|
||||
* Don't pass other params to ListGantt than route ([cf0eaf9](cf0eaf9ba1816b610ba1cbc9b4a6c661f00f61a5))
|
||||
* Refactor parseTimeLabel to own function ([443e1a0](443e1a063dfff3cbb82a9f625e05bf7e2b606cbe))
|
||||
* Add git-cliff to flake ([b817720](b817720907b0c4bb848e9624e3fdf71437ba0bde))
|
||||
|
||||
|
||||
### Other
|
||||
|
||||
* *(other)* [skip ci] Updated translations via Crowdin
|
||||
|
||||
|
||||
## [0.19.1] - 2022-08-17
|
||||
|
||||
### Bug Fixes
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.19.1-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.
|
||||
|
|
|
@ -45,7 +45,7 @@ describe('List History', () => {
|
|||
|
||||
cy.get('body')
|
||||
.should('contain', 'Last viewed')
|
||||
cy.get('.list-cards-wrapper-2-rows')
|
||||
cy.get('[data-cy="listCardGrid"]')
|
||||
.should('not.contain', lists[0].title)
|
||||
.should('contain', lists[1].title)
|
||||
.should('contain', lists[2].title)
|
||||
|
|
|
@ -11,7 +11,7 @@ describe('List View Gantt', () => {
|
|||
const tasks = TaskFactory.create(1)
|
||||
cy.visit('/lists/1/gantt')
|
||||
|
||||
cy.get('.gantt-chart .tasks')
|
||||
cy.get('.g-gantt-rows-container')
|
||||
.should('not.contain', tasks[0].title)
|
||||
})
|
||||
|
||||
|
@ -25,7 +25,7 @@ describe('List View Gantt', () => {
|
|||
|
||||
cy.visit('/lists/1/gantt')
|
||||
|
||||
cy.get('.gantt-chart .months')
|
||||
cy.get('.g-timeunits-container')
|
||||
.should('contain', format(now, 'MMMM'))
|
||||
.should('contain', format(nextMonth, 'MMMM'))
|
||||
})
|
||||
|
@ -38,14 +38,13 @@ describe('List View Gantt', () => {
|
|||
})
|
||||
cy.visit('/lists/1/gantt')
|
||||
|
||||
cy.get('.gantt-chart .tasks')
|
||||
cy.get('.g-gantt-rows-container')
|
||||
.should('not.be.empty')
|
||||
cy.get('.gantt-chart .tasks')
|
||||
.should('contain', tasks[0].title)
|
||||
})
|
||||
|
||||
it('Shows tasks with no dates after enabling them', () => {
|
||||
TaskFactory.create(1, {
|
||||
const tasks = TaskFactory.create(1, {
|
||||
start_date: null,
|
||||
end_date: null,
|
||||
})
|
||||
|
@ -55,13 +54,15 @@ describe('List View Gantt', () => {
|
|||
.contains('Show tasks which don\'t have dates set')
|
||||
.click()
|
||||
|
||||
cy.get('.gantt-chart .tasks')
|
||||
cy.get('.g-gantt-rows-container')
|
||||
.should('not.be.empty')
|
||||
cy.get('.gantt-chart .tasks .task.nodate')
|
||||
.should('exist')
|
||||
.should('contain', tasks[0].title)
|
||||
})
|
||||
|
||||
it('Drags a task around', () => {
|
||||
cy.intercept('**/api/v1/tasks/*')
|
||||
.as('taskUpdate')
|
||||
|
||||
const now = new Date()
|
||||
TaskFactory.create(1, {
|
||||
start_date: formatISO(now),
|
||||
|
@ -69,10 +70,56 @@ describe('List View Gantt', () => {
|
|||
})
|
||||
cy.visit('/lists/1/gantt')
|
||||
|
||||
cy.get('.gantt-chart .tasks .task')
|
||||
cy.get('.g-gantt-rows-container .g-gantt-row .g-gantt-row-bars-container div .g-gantt-bar')
|
||||
.first()
|
||||
.trigger('mousedown', {which: 1})
|
||||
.trigger('mousemove', {clientX: 500, clientY: 0})
|
||||
.trigger('mouseup', {force: true})
|
||||
cy.wait('@taskUpdate')
|
||||
})
|
||||
|
||||
it('Should change the query parameters when selecting a date range', () => {
|
||||
const now = Date.UTC(2022, 10, 9)
|
||||
cy.clock(now, ['Date'])
|
||||
|
||||
cy.visit('/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}`)
|
||||
})
|
||||
})
|
|
@ -193,4 +193,48 @@ describe('List View Kanban', () => {
|
|||
cy.get('.kanban .bucket')
|
||||
.should('not.contain', task.title)
|
||||
})
|
||||
|
||||
it('Shows a button to filter the kanban board', () => {
|
||||
const data = TaskFactory.create(10, {
|
||||
list_id: 1,
|
||||
bucket_id: 1,
|
||||
})
|
||||
cy.visit('/lists/1/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)
|
||||
})
|
||||
})
|
|
@ -78,7 +78,7 @@ describe('List View List', () => {
|
|||
|
||||
cy.get('.menu-list li .list-menu-link .color-bubble')
|
||||
.should('have.css', 'background-color', 'rgb(0, 219, 96)')
|
||||
cy.get('.tasks-container .tasks .color-bubble')
|
||||
cy.get('.tasks .color-bubble')
|
||||
.should('not.exist')
|
||||
})
|
||||
|
||||
|
@ -90,9 +90,9 @@ describe('List View List', () => {
|
|||
})
|
||||
cy.visit('/lists/1/list')
|
||||
|
||||
cy.get('.tasks-container .tasks')
|
||||
cy.get('.tasks')
|
||||
.should('contain', tasks[1].title)
|
||||
cy.get('.tasks-container .tasks')
|
||||
cy.get('.tasks')
|
||||
.should('not.contain', tasks[99].title)
|
||||
|
||||
cy.get('.card-content .pagination .pagination-link')
|
||||
|
@ -101,9 +101,9 @@ describe('List View List', () => {
|
|||
|
||||
cy.url()
|
||||
.should('contain', '?page=2')
|
||||
cy.get('.tasks-container .tasks')
|
||||
cy.get('.tasks')
|
||||
.should('contain', tasks[99].title)
|
||||
cy.get('.tasks-container .tasks')
|
||||
cy.get('.tasks')
|
||||
.should('not.contain', tasks[1].title)
|
||||
})
|
||||
})
|
|
@ -52,7 +52,7 @@ describe('Lists', () => {
|
|||
cy.get('.list-title h1')
|
||||
.should('contain', 'First List')
|
||||
|
||||
cy.get('.namespace-container .menu.namespaces-lists .menu-list li:first-child .dropdown .dropdown-trigger')
|
||||
cy.get('.namespace-container .menu.namespaces-lists .menu-list li:first-child .dropdown .menu-list-dropdown-trigger')
|
||||
.click()
|
||||
cy.get('.namespace-container .menu.namespaces-lists .menu-list li:first-child .dropdown .dropdown-content')
|
||||
.contains('Edit')
|
||||
|
@ -80,7 +80,7 @@ describe('Lists', () => {
|
|||
it('Should remove a list', () => {
|
||||
cy.visit(`/lists/${lists[0].id}`)
|
||||
|
||||
cy.get('.namespace-container .menu.namespaces-lists .menu-list li:first-child .dropdown .dropdown-trigger')
|
||||
cy.get('.namespace-container .menu.namespaces-lists .menu-list li:first-child .dropdown .menu-list-dropdown-trigger')
|
||||
.click()
|
||||
cy.get('.namespace-container .menu.namespaces-lists .menu-list li:first-child .dropdown .dropdown-content')
|
||||
.contains('Delete')
|
||||
|
|
|
@ -128,4 +128,24 @@ describe('Home Page Task Overview', () => {
|
|||
.last()
|
||||
.should('contain.text', newTaskTitle)
|
||||
})
|
||||
|
||||
it('Should show the cta buttons for new list when there are no tasks', () => {
|
||||
TaskFactory.truncate()
|
||||
|
||||
cy.visit('/')
|
||||
|
||||
cy.get('.home.app-content .content')
|
||||
.should('contain.text', 'You can create a new list for your new tasks:')
|
||||
.should('contain.text', 'Or import your lists and tasks from other services into Vikunja:')
|
||||
})
|
||||
|
||||
it('Should not show the cta buttons for new list when there are tasks', () => {
|
||||
seedTasks()
|
||||
|
||||
cy.visit('/')
|
||||
|
||||
cy.get('.home.app-content .content')
|
||||
.should('not.contain.text', 'You can create a new list for your new tasks:')
|
||||
.should('not.contain.text', 'Or import your lists and tasks from other services into Vikunja:')
|
||||
})
|
||||
})
|
||||
|
|
|
@ -546,5 +546,35 @@ describe('Task', () => {
|
|||
cy.get('.bucket .task .footer .icon svg.fa-paperclip')
|
||||
.should('exist')
|
||||
})
|
||||
|
||||
it('Can check items off a checklist', () => {
|
||||
const tasks = TaskFactory.create(1, {
|
||||
id: 1,
|
||||
description: `
|
||||
This is a checklist:
|
||||
|
||||
* [ ] one item
|
||||
* [ ] another item
|
||||
* [ ] third item
|
||||
* [ ] fourth item
|
||||
* [x] and this one is already done
|
||||
`,
|
||||
})
|
||||
cy.visit(`/tasks/${tasks[0].id}`)
|
||||
|
||||
cy.get('.task-view .checklist-summary')
|
||||
.should('contain.text', '1 of 5 tasks')
|
||||
cy.get('.editor .content ul > li input[type=checkbox]')
|
||||
.eq(2)
|
||||
.click()
|
||||
|
||||
cy.get('.editor .content ul > li input[type=checkbox]')
|
||||
.eq(2)
|
||||
.should('be.checked')
|
||||
cy.get('.editor .content input[type=checkbox]')
|
||||
.should('have.length', 5)
|
||||
cy.get('.task-view .checklist-summary')
|
||||
.should('contain.text', '2 of 5 tasks')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -55,4 +55,9 @@ context('Login', () => {
|
|||
|
||||
testAndAssertFailed(fixture)
|
||||
})
|
||||
|
||||
it('Should redirect to /login when no user is logged in', () => {
|
||||
cy.visit('/')
|
||||
cy.url().should('include', '/login')
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -5,6 +5,6 @@
|
|||
let pkgs = nixpkgs.legacyPackages.x86_64-linux;
|
||||
in {
|
||||
defaultPackage.x86_64-linux =
|
||||
pkgs.mkShell { buildInputs = [ pkgs.nodePackages.pnpm pkgs.cypress ]; };
|
||||
pkgs.mkShell { buildInputs = [ pkgs.nodePackages.pnpm pkgs.cypress pkgs.git-cliff ]; };
|
||||
};
|
||||
}
|
||||
|
|
110
package.json
110
package.json
|
@ -18,97 +18,97 @@
|
|||
"browserslist:update": "npx browserslist@latest --update-db"
|
||||
},
|
||||
"dependencies": {
|
||||
"@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/fontawesome-svg-core": "6.2.1",
|
||||
"@fortawesome/free-regular-svg-icons": "6.2.1",
|
||||
"@fortawesome/free-solid-svg-icons": "6.2.1",
|
||||
"@fortawesome/vue-fontawesome": "3.0.2",
|
||||
"@github/hotkey": "2.0.1",
|
||||
"@kyvg/vue3-notification": "2.4.1",
|
||||
"@sentry/tracing": "7.15.0",
|
||||
"@sentry/vue": "7.15.0",
|
||||
"@infectoone/vue-ganttastic": "2.1.3",
|
||||
"@intlify/unplugin-vue-i18n": "0.8.0",
|
||||
"@kyvg/vue3-notification": "2.7.0",
|
||||
"@sentry/tracing": "7.23.0",
|
||||
"@sentry/vue": "7.23.0",
|
||||
"@types/is-touch-device": "1.0.0",
|
||||
"@types/lodash.clonedeep": "4.5.7",
|
||||
"@types/sortablejs": "1.15.0",
|
||||
"@vueuse/core": "9.3.0",
|
||||
"@vueuse/router": "9.3.0",
|
||||
"@vueuse/core": "9.6.0",
|
||||
"axios": "0.27.2",
|
||||
"blurhash": "2.0.3",
|
||||
"blurhash": "2.0.4",
|
||||
"bulma-css-variables": "0.9.33",
|
||||
"camel-case": "4.1.2",
|
||||
"codemirror": "5.65.9",
|
||||
"codemirror": "5.65.10",
|
||||
"date-fns": "2.29.3",
|
||||
"dompurify": "2.4.0",
|
||||
"dayjs": "1.11.6",
|
||||
"dompurify": "2.4.1",
|
||||
"easymde": "2.18.0",
|
||||
"fast-deep-equal": "3.1.3",
|
||||
"flatpickr": "4.6.13",
|
||||
"flexsearch": "0.7.21",
|
||||
"floating-vue": "2.0.0-beta.20",
|
||||
"highlight.js": "11.6.0",
|
||||
"highlight.js": "11.7.0",
|
||||
"is-touch-device": "1.0.1",
|
||||
"lodash.clonedeep": "4.5.0",
|
||||
"lodash.debounce": "4.0.8",
|
||||
"marked": "4.1.1",
|
||||
"marked": "4.2.3",
|
||||
"minimist": "1.2.7",
|
||||
"pinia": "2.0.23",
|
||||
"pinia": "2.0.27",
|
||||
"register-service-worker": "1.7.2",
|
||||
"snake-case": "3.0.4",
|
||||
"sortablejs": "1.15.0",
|
||||
"ufo": "0.8.5",
|
||||
"vue": "3.2.40",
|
||||
"ufo": "1.0.1",
|
||||
"vue": "3.2.45",
|
||||
"vue-advanced-cropper": "2.8.6",
|
||||
"vue-drag-resize": "2.0.3",
|
||||
"vue-flatpickr-component": "9.0.8",
|
||||
"vue-flatpickr-component": "11.0.1",
|
||||
"vue-i18n": "9.2.2",
|
||||
"vue-router": "4.1.5",
|
||||
"vue-router": "4.1.6",
|
||||
"workbox-precaching": "6.5.4",
|
||||
"zhyswan-vuedraggable": "4.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@4tw/cypress-drag-drop": "2.2.1",
|
||||
"@cypress/vite-dev-server": "3.3.1",
|
||||
"@cypress/vue": "4.2.0",
|
||||
"@faker-js/faker": "7.5.0",
|
||||
"@4tw/cypress-drag-drop": "2.2.2",
|
||||
"@cypress/vite-dev-server": "5.0.0",
|
||||
"@cypress/vue": "5.0.3",
|
||||
"@faker-js/faker": "7.6.0",
|
||||
"@rushstack/eslint-patch": "1.2.0",
|
||||
"@types/dompurify": "2.3.4",
|
||||
"@types/codemirror": "5.60.5",
|
||||
"@types/dompurify": "2.4.0",
|
||||
"@types/flexsearch": "0.7.3",
|
||||
"@types/lodash.debounce": "4.0.7",
|
||||
"@types/marked": "4.0.7",
|
||||
"@types/node": "16.11.65",
|
||||
"@typescript-eslint/eslint-plugin": "5.40.0",
|
||||
"@typescript-eslint/parser": "5.40.0",
|
||||
"@vitejs/plugin-legacy": "2.2.0",
|
||||
"@vitejs/plugin-vue": "3.1.2",
|
||||
"@types/node": "18.11.10",
|
||||
"@types/postcss-preset-env": "7.7.0",
|
||||
"@typescript-eslint/eslint-plugin": "5.45.0",
|
||||
"@typescript-eslint/parser": "5.45.0",
|
||||
"@vitejs/plugin-legacy": "2.3.1",
|
||||
"@vitejs/plugin-vue": "3.2.0",
|
||||
"@vue/eslint-config-typescript": "11.0.2",
|
||||
"@vue/test-utils": "2.1.0",
|
||||
"@vue/test-utils": "2.2.6",
|
||||
"@vue/tsconfig": "0.1.3",
|
||||
"autoprefixer": "10.4.12",
|
||||
"autoprefixer": "10.4.13",
|
||||
"browserslist": "4.21.4",
|
||||
"caniuse-lite": "1.0.30001418",
|
||||
"cypress": "10.10.0",
|
||||
"esbuild": "0.15.10",
|
||||
"eslint": "8.25.0",
|
||||
"eslint-plugin-vue": "9.6.0",
|
||||
"caniuse-lite": "1.0.30001436",
|
||||
"csstype": "3.1.1",
|
||||
"cypress": "11.2.0",
|
||||
"esbuild": "0.15.18",
|
||||
"eslint": "8.29.0",
|
||||
"eslint-plugin-vue": "9.8.0",
|
||||
"express": "4.18.2",
|
||||
"happy-dom": "7.4.0",
|
||||
"netlify-cli": "12.0.7",
|
||||
"postcss": "8.4.17",
|
||||
"postcss-preset-env": "7.8.2",
|
||||
"rollup": "3.0.0",
|
||||
"rollup-plugin-visualizer": "5.8.2",
|
||||
"sass": "1.55.0",
|
||||
"typescript": "4.8.4",
|
||||
"vite": "3.1.7",
|
||||
"vite-plugin-pwa": "0.13.1",
|
||||
"happy-dom": "7.7.2",
|
||||
"netlify-cli": "12.2.8",
|
||||
"postcss": "8.4.19",
|
||||
"postcss-preset-env": "7.8.3",
|
||||
"rollup": "3.5.1",
|
||||
"rollup-plugin-visualizer": "5.8.3",
|
||||
"sass": "1.56.1",
|
||||
"typescript": "4.9.3",
|
||||
"vite": "3.2.5",
|
||||
"vite-plugin-pwa": "0.13.3",
|
||||
"vite-svg-loader": "3.6.0",
|
||||
"vitest": "0.24.1",
|
||||
"vue-tsc": "1.0.5",
|
||||
"vitest": "0.25.3",
|
||||
"vue-tsc": "1.0.11",
|
||||
"wait-on": "6.0.1",
|
||||
"workbox-cli": "6.5.4"
|
||||
},
|
||||
"postcss": {
|
||||
"plugins": {
|
||||
"autoprefixer": {}
|
||||
}
|
||||
},
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"packageManager": "pnpm@7.13.4"
|
||||
"packageManager": "pnpm@7.18.0"
|
||||
}
|
||||
|
|
2536
pnpm-lock.yaml
2536
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
|
@ -1,5 +1,4 @@
|
|||
const {exec} = require('child_process')
|
||||
const axios = require('axios')
|
||||
const { exec } = require('child_process')
|
||||
|
||||
const BOT_USER_ID = 513
|
||||
const giteaToken = process.env.GITEA_TOKEN
|
||||
|
@ -35,7 +34,7 @@ const promiseExec = cmd => {
|
|||
stdout = await promiseExec(`./node_modules/.bin/netlify deploy --alias ${alias}`)
|
||||
console.log(stdout)
|
||||
|
||||
const {data} = await axios.get(prIssueCommentsUrl)
|
||||
const data = await fetch(prIssueCommentsUrl).then(response => response.json())
|
||||
const hasComment = data.some(c => c.user.id === BOT_USER_ID)
|
||||
|
||||
if (hasComment) {
|
||||
|
@ -43,8 +42,7 @@ const promiseExec = cmd => {
|
|||
return
|
||||
}
|
||||
|
||||
await axios.post(prIssueCommentsUrl, {
|
||||
body: `
|
||||
const message = `
|
||||
Hi ${process.env.DRONE_COMMIT_AUTHOR}!
|
||||
|
||||
Thank you for creating a PR!
|
||||
|
@ -57,14 +55,25 @@ You will need to manually connect this to an api running somehwere. The easiest
|
|||
Have a nice day!
|
||||
|
||||
> Beep boop, I'm a bot.
|
||||
`,
|
||||
}, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'accept': 'application/json',
|
||||
'Authorization': `token ${giteaToken}`,
|
||||
},
|
||||
})
|
||||
`
|
||||
|
||||
console.log(`Preview comment sent successfully to PR #${prNumber}!`)
|
||||
try {
|
||||
const response = await fetch(prIssueCommentsUrl, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
body: message,
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'accept': 'application/json',
|
||||
'Authorization': `token ${giteaToken}`,
|
||||
},
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error, status = ${response.status}`)
|
||||
}
|
||||
console.log(`Preview comment sent successfully to PR #${prNumber}!`)
|
||||
} catch (e) {
|
||||
console.log(`Could not send preview comment to PR #${prNumber}! ${e.message}`)
|
||||
}
|
||||
})()
|
|
@ -1 +1 @@
|
|||
bb46342a0a08105b340ba7976cff9d80ef89901120ec0639669caa70bb7d2dbc43e78b1f635a7654ab2456e8358c98a4 ./scripts/deploy-preview-netlify.js
|
||||
05c69e5323a4d4bac041ade830735becd52c230277396d1f72be8fde83683a75dc095f6678804083b2ca66f27cc7995f ./scripts/deploy-preview-netlify.js
|
||||
|
|
18
src/App.vue
18
src/App.vue
|
@ -15,9 +15,8 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {computed, watch, type Ref} from 'vue'
|
||||
import {useRouter} from 'vue-router'
|
||||
import {useRouteQuery} from '@vueuse/router'
|
||||
import {computed, watch} from 'vue'
|
||||
import {useRoute, useRouter} from 'vue-router'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
import isTouchDevice from 'is-touch-device'
|
||||
import {success} from '@/message'
|
||||
|
@ -41,6 +40,7 @@ import {useAuthStore} from './stores/auth'
|
|||
const baseStore = useBaseStore()
|
||||
const authStore = useAuthStore()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
useBodyClass('is-touch', isTouchDevice())
|
||||
const keyboardShortcutsActive = computed(() => baseStore.keyboardShortcutsActive)
|
||||
|
@ -51,9 +51,9 @@ const authLinkShare = computed(() => authStore.authLinkShare)
|
|||
const {t} = useI18n({useScope: 'global'})
|
||||
|
||||
// setup account deletion verification
|
||||
const accountDeletionConfirm = useRouteQuery('accountDeletionConfirm') as Ref<null | string>
|
||||
const accountDeletionConfirm = computed(() => route.query?.accountDeletionConfirm as (string | undefined))
|
||||
watch(accountDeletionConfirm, async (accountDeletionConfirm) => {
|
||||
if (accountDeletionConfirm === null) {
|
||||
if (accountDeletionConfirm === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -64,9 +64,9 @@ watch(accountDeletionConfirm, async (accountDeletionConfirm) => {
|
|||
}, { immediate: true })
|
||||
|
||||
// setup password reset redirect
|
||||
const userPasswordReset = useRouteQuery('userPasswordReset') as Ref<null | string>
|
||||
const userPasswordReset = computed(() => route.query?.userPasswordReset as (string | undefined))
|
||||
watch(userPasswordReset, (userPasswordReset) => {
|
||||
if (userPasswordReset === null) {
|
||||
if (userPasswordReset === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -75,9 +75,9 @@ watch(userPasswordReset, (userPasswordReset) => {
|
|||
}, { immediate: true })
|
||||
|
||||
// setup email verification redirect
|
||||
const userEmailConfirm = useRouteQuery('userEmailConfirm') as Ref<null | string>
|
||||
const userEmailConfirm = computed(() => route.query?.userEmailConfirm as (string | undefined))
|
||||
watch(userEmailConfirm, (userEmailConfirm) => {
|
||||
if (userEmailConfirm === null) {
|
||||
if (userEmailConfirm === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
@ -1,18 +1,53 @@
|
|||
<!-- a disabled link of any kind is not a link -->
|
||||
<!-- we have a router link -->
|
||||
<!-- just a normal link -->
|
||||
<!-- a button it shall be -->
|
||||
<!-- note that we only pass the click listener here -->
|
||||
<template>
|
||||
<component
|
||||
:is="componentNodeName"
|
||||
<div
|
||||
v-if="disabled === true && (to !== undefined || href !== undefined)"
|
||||
class="base-button"
|
||||
:class="{ 'base-button--type-button': isButton }"
|
||||
v-bind="elementBindings"
|
||||
:disabled="disabled || undefined"
|
||||
:aria-disabled="disabled || undefined"
|
||||
ref="button"
|
||||
>
|
||||
<slot/>
|
||||
</component>
|
||||
</div>
|
||||
<router-link
|
||||
v-else-if="to !== undefined"
|
||||
:to="to"
|
||||
class="base-button"
|
||||
ref="button"
|
||||
>
|
||||
<slot/>
|
||||
</router-link>
|
||||
<a v-else-if="href !== undefined"
|
||||
class="base-button"
|
||||
:href="href"
|
||||
rel="noreferrer noopener nofollow"
|
||||
target="_blank"
|
||||
ref="button"
|
||||
>
|
||||
<slot/>
|
||||
</a>
|
||||
<button
|
||||
v-else
|
||||
:type="type"
|
||||
class="base-button base-button--type-button"
|
||||
:disabled="disabled || undefined"
|
||||
ref="button"
|
||||
@click="(event: MouseEvent) => emit('click', event)"
|
||||
>
|
||||
<slot/>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default { inheritAttrs: false }
|
||||
const BASE_BUTTON_TYPES_MAP = {
|
||||
BUTTON: 'button',
|
||||
SUBMIT: 'submit',
|
||||
} as const
|
||||
|
||||
export type BaseButtonTypes = typeof BASE_BUTTON_TYPES_MAP[keyof typeof BASE_BUTTON_TYPES_MAP] | undefined
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup>
|
||||
|
@ -20,77 +55,36 @@ export default { inheritAttrs: false }
|
|||
// by doing so we make it easy abstract the functionality from style and enable easier and semantic
|
||||
// correct button and link usage. Also see: https://css-tricks.com/a-complete-guide-to-links-and-buttons/#accessibility-considerations
|
||||
|
||||
// the component tries to heuristically determine what it should be checking the props (see the
|
||||
// componentNodeName and elementBindings ref for this).
|
||||
// the component tries to heuristically determine what it should be checking the props
|
||||
|
||||
// NOTE: Do NOT use buttons with @click to push routes. => Use router-links instead!
|
||||
|
||||
import { ref, watchEffect, computed, useAttrs, type PropType } from 'vue'
|
||||
import {unrefElement} from '@vueuse/core'
|
||||
import {ref, type HTMLAttributes} from 'vue'
|
||||
import type {RouteLocationNamedRaw} from 'vue-router'
|
||||
|
||||
const BASE_BUTTON_TYPES_MAP = Object.freeze({
|
||||
button: 'button',
|
||||
submit: 'submit',
|
||||
})
|
||||
|
||||
type BaseButtonTypes = keyof typeof BASE_BUTTON_TYPES_MAP
|
||||
|
||||
const props = defineProps({
|
||||
type: {
|
||||
type: String as PropType<BaseButtonTypes>,
|
||||
default: 'button',
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
const componentNodeName = ref<Node['nodeName']>('button')
|
||||
|
||||
interface ElementBindings {
|
||||
type?: string;
|
||||
rel?: string;
|
||||
target?: string;
|
||||
export interface BaseButtonProps extends HTMLAttributes {
|
||||
type?: BaseButtonTypes
|
||||
disabled?: boolean
|
||||
to?: RouteLocationNamedRaw
|
||||
href?: string
|
||||
}
|
||||
|
||||
const elementBindings = ref({})
|
||||
export interface BaseButtonEmits {
|
||||
(e: 'click', payload: MouseEvent): void
|
||||
}
|
||||
|
||||
const attrs = useAttrs()
|
||||
watchEffect(() => {
|
||||
// by default this component is a button element with the attribute of the type "button" (default prop value)
|
||||
let nodeName = 'button'
|
||||
let bindings: ElementBindings = {type: props.type}
|
||||
const {
|
||||
type = BASE_BUTTON_TYPES_MAP.BUTTON,
|
||||
disabled = false,
|
||||
} = defineProps<BaseButtonProps>()
|
||||
|
||||
// if we find a "to" prop we set it as router-link
|
||||
if ('to' in attrs) {
|
||||
nodeName = 'router-link'
|
||||
bindings = {}
|
||||
}
|
||||
const emit = defineEmits<BaseButtonEmits>()
|
||||
|
||||
// if there is a href we assume the user wants an external link via a link element
|
||||
// we also set a predefined value for the attribute rel, but make it possible to overwrite this by the user.
|
||||
if ('href' in attrs) {
|
||||
nodeName = 'a'
|
||||
bindings = {
|
||||
rel: 'noreferrer noopener nofollow',
|
||||
target: '_blank',
|
||||
}
|
||||
}
|
||||
|
||||
componentNodeName.value = nodeName
|
||||
elementBindings.value = {
|
||||
...bindings,
|
||||
...attrs,
|
||||
}
|
||||
})
|
||||
|
||||
const isButton = computed(() => componentNodeName.value === 'button')
|
||||
|
||||
const button = ref()
|
||||
const button = ref<HTMLElement | null>(null)
|
||||
|
||||
function focus() {
|
||||
button.value.focus()
|
||||
unrefElement(button)?.focus()
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -54,8 +54,8 @@
|
|||
</p>
|
||||
|
||||
<modal
|
||||
@close="() => showHowItWorks = false"
|
||||
:enabled="showHowItWorks"
|
||||
@close="() => showHowItWorks = false"
|
||||
transition-name="fade"
|
||||
:overflow="true"
|
||||
variant="hint-modal"
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -44,8 +44,8 @@
|
|||
variant="secondary"
|
||||
:shadow="false"
|
||||
>
|
||||
<img :src="userAvatar" alt="" class="avatar" width="40" height="40"/>
|
||||
<span class="username">{{ userInfo.name !== '' ? userInfo.name : userInfo.username }}</span>
|
||||
<img :src="authStore.avatarUrl" alt="" class="avatar" width="40" height="40"/>
|
||||
<span class="username">{{ authStore.userDisplayName }}</span>
|
||||
<span class="icon is-small">
|
||||
<icon icon="chevron-down"/>
|
||||
</span>
|
||||
|
@ -80,7 +80,7 @@
|
|||
{{ $t('about.title') }}
|
||||
</dropdown-item>
|
||||
<dropdown-item
|
||||
@click="logout()"
|
||||
@click="authStore.logout()"
|
||||
>
|
||||
{{ $t('user.auth.logout') }}
|
||||
</dropdown-item>
|
||||
|
@ -117,8 +117,6 @@ const canWriteCurrentList = computed(() => baseStore.currentList.maxRight > Righ
|
|||
const menuActive = computed(() => baseStore.menuActive)
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const userInfo = computed(() => authStore.info)
|
||||
const userAvatar = computed(() => authStore.avatarUrl)
|
||||
|
||||
const configStore = useConfigStore()
|
||||
const imprintUrl = computed(() => configStore.legal.imprintUrl)
|
||||
|
@ -136,10 +134,6 @@ onMounted(async () => {
|
|||
listTitle.value.style.setProperty('--nav-username-width', `${usernameWidth}px`)
|
||||
})
|
||||
|
||||
function logout() {
|
||||
authStore.logout()
|
||||
}
|
||||
|
||||
function openQuickActions() {
|
||||
baseStore.setQuickActionsActive(true)
|
||||
}
|
||||
|
|
|
@ -39,7 +39,7 @@
|
|||
</router-view>
|
||||
|
||||
<modal
|
||||
v-if="currentModal"
|
||||
:enabled="Boolean(currentModal)"
|
||||
@close="closeModal()"
|
||||
variant="scrolling"
|
||||
class="task-detail-view-modal"
|
||||
|
@ -154,41 +154,36 @@ 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: 1.5rem 0.5rem 1rem;
|
||||
|
||||
@media screen {
|
||||
padding: $navbar-height + 1.5rem 1.5rem 1rem 1.5rem;
|
||||
@media screen and (max-width: $tablet) {
|
||||
margin-left: 0;
|
||||
min-height: calc(100vh - 4rem);
|
||||
}
|
||||
|
||||
@media screen and (min-width: $tablet) {
|
||||
padding: $navbar-height + 1.5rem 1.5rem 1rem 1.5rem;
|
||||
}
|
||||
|
||||
&.is-menu-enabled {
|
||||
@media screen and (min-width: $tablet) {
|
||||
margin-left: $navbar-width;
|
||||
}
|
||||
}
|
||||
|
||||
// Used to make sure the spinner is always in the middle while loading
|
||||
> .loader-container {
|
||||
min-height: calc(100vh - #{$navbar-height + 1.5rem + 1rem});
|
||||
}
|
||||
// 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 {
|
||||
&.is-menu-enabled {
|
||||
margin-left: $navbar-width;
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
min-width: 100%;
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--white);
|
||||
}
|
||||
// FIXME: This should be somehow defined inside Card.vue
|
||||
.card {
|
||||
background: var(--white);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -235,6 +230,4 @@ labelStore.loadAllLabels()
|
|||
.content-auth.z-unset {
|
||||
z-index: unset;
|
||||
}
|
||||
|
||||
@include modal-transition();
|
||||
</style>
|
|
@ -7,7 +7,7 @@
|
|||
<ul class="menu-list">
|
||||
<li>
|
||||
<router-link :to="{ name: 'home'}" v-shortcut="'g o'">
|
||||
<span class="icon">
|
||||
<span class="menu-item-icon icon">
|
||||
<icon icon="calendar"/>
|
||||
</span>
|
||||
{{ $t('navigation.overview') }}
|
||||
|
@ -15,7 +15,7 @@
|
|||
</li>
|
||||
<li>
|
||||
<router-link :to="{ name: 'tasks.range'}" v-shortcut="'g u'">
|
||||
<span class="icon">
|
||||
<span class="menu-item-icon icon">
|
||||
<icon :icon="['far', 'calendar-alt']"/>
|
||||
</span>
|
||||
{{ $t('navigation.upcoming') }}
|
||||
|
@ -23,7 +23,7 @@
|
|||
</li>
|
||||
<li>
|
||||
<router-link :to="{ name: 'namespaces.index'}" v-shortcut="'g n'">
|
||||
<span class="icon">
|
||||
<span class="menu-item-icon icon">
|
||||
<icon icon="layer-group"/>
|
||||
</span>
|
||||
{{ $t('namespace.title') }}
|
||||
|
@ -31,7 +31,7 @@
|
|||
</li>
|
||||
<li>
|
||||
<router-link :to="{ name: 'labels.index'}" v-shortcut="'g a'">
|
||||
<span class="icon">
|
||||
<span class="menu-item-icon icon">
|
||||
<icon icon="tags"/>
|
||||
</span>
|
||||
{{ $t('label.title') }}
|
||||
|
@ -39,7 +39,7 @@
|
|||
</li>
|
||||
<li>
|
||||
<router-link :to="{ name: 'teams.index'}" v-shortcut="'g m'">
|
||||
<span class="icon">
|
||||
<span class="menu-item-icon icon">
|
||||
<icon icon="users"/>
|
||||
</span>
|
||||
{{ $t('team.title') }}
|
||||
|
@ -63,7 +63,7 @@
|
|||
/>
|
||||
<span class="name">{{ namespaceTitles[nk] }}</span>
|
||||
<div
|
||||
class="icon is-small toggle-lists-icon pl-2"
|
||||
class="icon menu-item-icon is-small toggle-lists-icon pl-2"
|
||||
:class="{'active': typeof listsVisible[n.id] !== 'undefined' ? listsVisible[n.id] : true}"
|
||||
>
|
||||
<icon icon="chevron-down"/>
|
||||
|
@ -72,7 +72,7 @@
|
|||
({{ namespaceListsCount[nk] }})
|
||||
</span>
|
||||
</BaseButton>
|
||||
<namespace-settings-dropdown :namespace="n" v-if="n.id > 0"/>
|
||||
<namespace-settings-dropdown class="menu-list-dropdown" :namespace="n" v-if="n.id > 0"/>
|
||||
</div>
|
||||
<!--
|
||||
NOTE: a v-model / computed setter is not possible, since the updateActiveLists function
|
||||
|
@ -111,11 +111,11 @@
|
|||
class="list-menu-link"
|
||||
:class="{'router-link-exact-active': currentList.id === l.id}"
|
||||
>
|
||||
<span class="icon handle">
|
||||
<span class="icon menu-item-icon handle">
|
||||
<icon icon="grip-lines"/>
|
||||
</span>
|
||||
<ColorBubble
|
||||
v-if="l.hexColor !== ''"
|
||||
v-if="l.hexColor !== ''"
|
||||
:color="l.hexColor"
|
||||
class="mr-1"
|
||||
/>
|
||||
|
@ -128,7 +128,13 @@
|
|||
>
|
||||
<icon :icon="l.isFavorite ? 'star' : ['far', 'star']"/>
|
||||
</BaseButton>
|
||||
<list-settings-dropdown :list="l" v-if="l.id > 0"/>
|
||||
<list-settings-dropdown class="menu-list-dropdown" :list="l" v-if="l.id > 0">
|
||||
<template #trigger="{toggleOpen}">
|
||||
<BaseButton class="menu-list-dropdown-trigger" @click="toggleOpen">
|
||||
<icon icon="ellipsis-h" class="icon"/>
|
||||
</BaseButton>
|
||||
</template>
|
||||
</list-settings-dropdown>
|
||||
<span class="list-setting-spacer" v-else></span>
|
||||
</li>
|
||||
</template>
|
||||
|
@ -280,6 +286,18 @@ $vikunja-nav-background: var(--site-background);
|
|||
$vikunja-nav-color: var(--grey-700);
|
||||
$vikunja-nav-selected-width: 0.4rem;
|
||||
|
||||
.logo {
|
||||
display: block;
|
||||
|
||||
padding-left: 1rem;
|
||||
margin-right: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
@media screen and (min-width: $tablet) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.namespace-container {
|
||||
background: $vikunja-nav-background;
|
||||
color: $vikunja-nav-color;
|
||||
|
@ -303,248 +321,226 @@ $vikunja-nav-selected-width: 0.4rem;
|
|||
transform: translateX(0);
|
||||
transition: transform $transition-duration ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
.menu {
|
||||
.menu-label {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
font-weight: bold;
|
||||
font-family: $vikunja-font;
|
||||
color: $vikunja-nav-color;
|
||||
font-weight: 500;
|
||||
min-height: 2.5rem;
|
||||
padding-top: 0;
|
||||
padding-left: $navbar-padding;
|
||||
// these are general menu styles
|
||||
// should be in own components
|
||||
.menu {
|
||||
.menu-label,
|
||||
.menu-list .list-menu-link,
|
||||
.menu-list a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.menu-label,
|
||||
.menu-list .list-menu-link,
|
||||
.menu-list a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
|
||||
.list-menu-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.color-bubble {
|
||||
height: 12px;
|
||||
flex: 0 0 12px;
|
||||
}
|
||||
|
||||
}
|
||||
.favorite {
|
||||
margin-left: .25rem;
|
||||
transition: opacity $transition, color $transition;
|
||||
opacity: 0;
|
||||
|
||||
&:hover,
|
||||
&.is-favorite {
|
||||
color: var(--warning);
|
||||
}
|
||||
}
|
||||
|
||||
.favorite.is-favorite,
|
||||
.list-menu:hover .favorite {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.menu-label {
|
||||
.color-bubble {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-basis: auto;
|
||||
}
|
||||
|
||||
.is-archived {
|
||||
min-width: 85px;
|
||||
}
|
||||
}
|
||||
|
||||
.namespace-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
color: $vikunja-nav-color;
|
||||
padding: 0 .25rem;
|
||||
|
||||
.menu-label {
|
||||
margin-bottom: 0;
|
||||
flex: 1 1 auto;
|
||||
|
||||
.name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.count {
|
||||
color: var(--grey-500);
|
||||
margin-right: .5rem;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.dropdown-trigger) {
|
||||
padding: .5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toggle-lists-icon {
|
||||
svg {
|
||||
transition: all $transition;
|
||||
transform: rotate(90deg);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&.active svg {
|
||||
transform: rotate(0deg);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .toggle-lists-icon svg {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:not(.has-menu) .toggle-lists-icon {
|
||||
padding-right: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-label,
|
||||
.nsettings,
|
||||
.menu-list .list-menu-link,
|
||||
.menu-list a {
|
||||
color: $vikunja-nav-color;
|
||||
}
|
||||
|
||||
.menu-list {
|
||||
li {
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&:hover {
|
||||
background: var(--white);
|
||||
}
|
||||
|
||||
:deep(.dropdown-trigger) {
|
||||
opacity: 0;
|
||||
padding: .5rem;
|
||||
cursor: pointer;
|
||||
transition: $transition;
|
||||
}
|
||||
|
||||
&:hover :deep(.dropdown-trigger) {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.flip-list-move {
|
||||
transition: transform $transition-duration;
|
||||
}
|
||||
|
||||
.ghost {
|
||||
background: var(--grey-200);
|
||||
|
||||
* {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
a:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.list-menu-link, li > a {
|
||||
padding: 0.75rem .5rem 0.75rem ($navbar-padding * 1.5 - 1.75rem);
|
||||
transition: all 0.2s ease;
|
||||
|
||||
border-radius: 0;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
border-left: $vikunja-nav-selected-width solid transparent;
|
||||
|
||||
.icon {
|
||||
height: 1rem;
|
||||
vertical-align: middle;
|
||||
padding-right: 0.5rem;
|
||||
|
||||
&.handle {
|
||||
opacity: 0;
|
||||
transition: opacity $transition;
|
||||
margin-right: .25rem;
|
||||
cursor: grab;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .icon.handle {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&.router-link-exact-active {
|
||||
color: var(--primary);
|
||||
border-left: $vikunja-nav-selected-width solid var(--primary);
|
||||
|
||||
.icon {
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-left: $vikunja-nav-selected-width solid var(--primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: block;
|
||||
|
||||
padding-left: 1rem;
|
||||
margin-right: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
@media screen and (min-width: $tablet) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.namespaces-lists {
|
||||
padding-top: math.div($navbar-padding, 2);
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: var(--grey-400) !important;
|
||||
.color-bubble {
|
||||
height: 12px;
|
||||
flex: 0 0 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.top-menu {
|
||||
margin-top: math.div($navbar-padding, 2);
|
||||
.menu-list {
|
||||
li {
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.menu-list {
|
||||
li {
|
||||
font-weight: 500;
|
||||
font-family: $vikunja-font;
|
||||
&:hover {
|
||||
background: var(--white);
|
||||
}
|
||||
|
||||
.list-menu-link, li > a {
|
||||
padding-left: 2rem;
|
||||
display: inline-block;
|
||||
.menu-list-dropdown {
|
||||
opacity: 0;
|
||||
transition: $transition;
|
||||
}
|
||||
|
||||
.icon {
|
||||
padding-bottom: .25rem;
|
||||
}
|
||||
&:hover .menu-list-dropdown {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-item-icon {
|
||||
color: var(--grey-400);
|
||||
}
|
||||
|
||||
.menu-list-dropdown-trigger {
|
||||
display: flex;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.flip-list-move {
|
||||
transition: transform $transition-duration;
|
||||
}
|
||||
|
||||
.ghost {
|
||||
background: var(--grey-200);
|
||||
|
||||
* {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
a:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.list-menu-link,
|
||||
li > a {
|
||||
color: $vikunja-nav-color;
|
||||
padding: 0.75rem .5rem 0.75rem ($navbar-padding * 1.5 - 1.75rem);
|
||||
transition: all 0.2s ease;
|
||||
|
||||
border-radius: 0;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
border-left: $vikunja-nav-selected-width solid transparent;
|
||||
|
||||
&:hover {
|
||||
border-left: $vikunja-nav-selected-width solid var(--primary);
|
||||
}
|
||||
|
||||
&.router-link-exact-active {
|
||||
color: var(--primary);
|
||||
border-left: $vikunja-nav-selected-width solid var(--primary);
|
||||
}
|
||||
|
||||
.icon {
|
||||
height: 1rem;
|
||||
vertical-align: middle;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
&.router-link-exact-active .icon:not(.handle) {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.handle {
|
||||
opacity: 0;
|
||||
transition: opacity $transition;
|
||||
margin-right: .25rem;
|
||||
cursor: grab;
|
||||
}
|
||||
&:hover .handle {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.top-menu {
|
||||
margin-top: math.div($navbar-padding, 2);
|
||||
|
||||
.menu-list {
|
||||
li {
|
||||
font-weight: 500;
|
||||
font-family: $vikunja-font;
|
||||
}
|
||||
|
||||
.list-menu-link,
|
||||
li > a {
|
||||
padding-left: 2rem;
|
||||
display: inline-block;
|
||||
|
||||
.icon {
|
||||
padding-bottom: .25rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.namespaces-lists {
|
||||
padding-top: math.div($navbar-padding, 2);
|
||||
|
||||
.menu-label {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
font-weight: bold;
|
||||
font-family: $vikunja-font;
|
||||
color: $vikunja-nav-color;
|
||||
font-weight: 500;
|
||||
min-height: 2.5rem;
|
||||
padding-top: 0;
|
||||
padding-left: $navbar-padding;
|
||||
|
||||
overflow: hidden;
|
||||
margin-bottom: 0;
|
||||
flex: 1 1 auto;
|
||||
|
||||
.name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.count {
|
||||
color: var(--grey-500);
|
||||
margin-right: .5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.favorite {
|
||||
margin-left: .25rem;
|
||||
transition: opacity $transition, color $transition;
|
||||
opacity: 0;
|
||||
|
||||
&:hover,
|
||||
&.is-favorite {
|
||||
color: var(--warning);
|
||||
}
|
||||
}
|
||||
|
||||
.favorite.is-favorite,
|
||||
.list-menu:hover .favorite {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.list-menu-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.color-bubble {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-basis: auto;
|
||||
}
|
||||
|
||||
.is-archived {
|
||||
min-width: 85px;
|
||||
}
|
||||
}
|
||||
|
||||
.namespace-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
color: $vikunja-nav-color;
|
||||
padding: 0 .25rem;
|
||||
|
||||
.toggle-lists-icon {
|
||||
svg {
|
||||
transition: all $transition;
|
||||
transform: rotate(90deg);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&.active svg {
|
||||
transform: rotate(0deg);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .toggle-lists-icon svg {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:not(.has-menu) .toggle-lists-icon {
|
||||
padding-right: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -26,7 +26,7 @@ if (navigator && navigator.serviceWorker) {
|
|||
)
|
||||
}
|
||||
|
||||
function showRefreshUI(e) {
|
||||
function showRefreshUI(e: Event) {
|
||||
console.log('recieved refresh event', e)
|
||||
registration.value = e.detail
|
||||
updateAvailable.value = true
|
||||
|
|
|
@ -1,12 +1,3 @@
|
|||
import { defineAsyncComponent } from 'vue'
|
||||
import ErrorComponent from '@/components/misc/error.vue'
|
||||
import LoadingComponent from '@/components/misc/loading.vue'
|
||||
import {createAsyncComponent} from '@/helpers/createAsyncComponent'
|
||||
|
||||
const Editor = () => import('@/components/input/editor.vue')
|
||||
|
||||
export default defineAsyncComponent({
|
||||
loader: Editor,
|
||||
loadingComponent: LoadingComponent,
|
||||
errorComponent: ErrorComponent,
|
||||
timeout: 60000,
|
||||
})
|
||||
export default createAsyncComponent(() => import('@/components/input/editor.vue'))
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -9,64 +9,61 @@
|
|||
}
|
||||
]"
|
||||
>
|
||||
<icon
|
||||
v-if="showIconOnly"
|
||||
:icon="icon"
|
||||
:style="{'color': iconColor !== '' ? iconColor : false}"
|
||||
/>
|
||||
<span class="icon is-small" v-else-if="icon !== ''">
|
||||
<template v-if="icon">
|
||||
<icon
|
||||
v-if="showIconOnly"
|
||||
:icon="icon"
|
||||
:style="{'color': iconColor !== '' ? iconColor : false}"
|
||||
/>
|
||||
</span>
|
||||
<span class="icon is-small" v-else>
|
||||
<icon
|
||||
:icon="icon"
|
||||
:style="{'color': iconColor !== '' ? iconColor : false}"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
<slot />
|
||||
</BaseButton>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
const BUTTON_TYPES_MAP = {
|
||||
primary: 'is-primary',
|
||||
secondary: 'is-outlined',
|
||||
tertiary: 'is-text is-inverted underline-none',
|
||||
} as const
|
||||
|
||||
export type ButtonTypes = keyof typeof BUTTON_TYPES_MAP
|
||||
|
||||
export default { name: 'x-button' }
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, useSlots, type PropType} from 'vue'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import {computed, useSlots} from 'vue'
|
||||
import BaseButton, {type BaseButtonProps} from '@/components/base/BaseButton.vue'
|
||||
import type { IconProp } from '@fortawesome/fontawesome-svg-core'
|
||||
|
||||
const BUTTON_TYPES_MAP = Object.freeze({
|
||||
primary: 'is-primary',
|
||||
secondary: 'is-outlined',
|
||||
tertiary: 'is-text is-inverted underline-none',
|
||||
})
|
||||
// extending the props of the BaseButton
|
||||
export interface ButtonProps extends BaseButtonProps {
|
||||
variant?: ButtonTypes
|
||||
icon?: IconProp
|
||||
iconColor?: string
|
||||
loading?: boolean
|
||||
shadow?: boolean
|
||||
}
|
||||
|
||||
type ButtonTypes = keyof typeof BUTTON_TYPES_MAP
|
||||
const {
|
||||
variant = 'primary',
|
||||
icon = '',
|
||||
iconColor = '',
|
||||
loading = false,
|
||||
shadow = true,
|
||||
} = defineProps<ButtonProps>()
|
||||
|
||||
const props = defineProps({
|
||||
variant: {
|
||||
type: String as PropType<ButtonTypes>,
|
||||
default: 'primary',
|
||||
},
|
||||
icon: {
|
||||
type: [String, Array],
|
||||
default: '',
|
||||
},
|
||||
iconColor: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
shadow: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
const variantClass = computed(() => BUTTON_TYPES_MAP[props.variant])
|
||||
const variantClass = computed(() => BUTTON_TYPES_MAP[variant])
|
||||
|
||||
const slots = useSlots()
|
||||
const showIconOnly = computed(() => props.icon !== '' && typeof slots.default === 'undefined')
|
||||
const showIconOnly = computed(() => icon !== '' && typeof slots.default === 'undefined')
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
{{ date === null ? chooseDateLabel : formatDateShort(date) }}
|
||||
</BaseButton>
|
||||
|
||||
<transition name="fade">
|
||||
<CustomTransition name="fade">
|
||||
<div v-if="show" class="datepicker-popup" ref="datepickerPopup">
|
||||
|
||||
<BaseButton
|
||||
|
@ -84,7 +84,7 @@
|
|||
{{ $t('misc.confirm') }}
|
||||
</x-button>
|
||||
</div>
|
||||
</transition>
|
||||
</CustomTransition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -94,6 +94,7 @@ import flatPickr from 'vue-flatpickr-component'
|
|||
import 'flatpickr/dist/flatpickr.css'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import CustomTransition from '@/components/misc/CustomTransition.vue'
|
||||
|
||||
import {formatDate, formatDateShort} from '@/helpers/time/formatDate'
|
||||
import {calculateDayInterval} from '@/helpers/time/calculateDayInterval'
|
||||
|
@ -193,7 +194,7 @@ function toggleDatePopup() {
|
|||
}
|
||||
|
||||
const datepickerPopup = ref<HTMLElement | null>(null)
|
||||
function hideDatePopup(e) {
|
||||
function hideDatePopup(e: MouseEvent) {
|
||||
if (show.value) {
|
||||
closeWhenClickedOutside(e, datepickerPopup.value, close)
|
||||
}
|
||||
|
|
|
@ -115,6 +115,7 @@ const props = defineProps({
|
|||
default: true,
|
||||
},
|
||||
bottomActions: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
emptyText: {
|
||||
|
@ -285,9 +286,9 @@ function handleCheckboxClick(e: Event) {
|
|||
console.debug('no index found')
|
||||
return
|
||||
}
|
||||
console.debug(index, text.value.slice(index, 9))
|
||||
|
||||
const listPrefix = text.value.slice(index, 1)
|
||||
const listPrefix = text.value.substring(index, index + 1)
|
||||
|
||||
console.debug({index, listPrefix, checked, text: text.value})
|
||||
|
||||
text.value = replaceAt(text.value, index, `${listPrefix} ${checked ? '[x]' : '[ ]'} `)
|
||||
bubble()
|
||||
|
|
|
@ -9,6 +9,7 @@ export function createEasyMDEConfig({ placeholder, uploadImage, imageUploadFunct
|
|||
uploadImage,
|
||||
imageUploadFunction,
|
||||
minHeight: '150px',
|
||||
sideBySideFullscreen: false,
|
||||
toolbar: [
|
||||
{
|
||||
name: 'heading-1',
|
||||
|
|
|
@ -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">
|
||||
|
@ -35,10 +36,10 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<transition name="fade">
|
||||
<CustomTransition 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)"
|
||||
|
@ -77,8 +78,7 @@
|
|||
</span>
|
||||
</BaseButton>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
</CustomTransition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -89,6 +89,7 @@ import {useI18n} from 'vue-i18n'
|
|||
import {closeWhenClickedOutside} from '@/helpers/closeWhenClickedOutside'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import CustomTransition from '@/components/misc/CustomTransition.vue'
|
||||
|
||||
function elementInResults(elem: string | any, label: string, query: string): boolean {
|
||||
// Don't make create available if we have an exact match in our search results.
|
||||
|
@ -100,37 +101,52 @@ function elementInResults(elem: string | any, label: string, query: string): boo
|
|||
}
|
||||
|
||||
const props = defineProps({
|
||||
// When true, shows a loading spinner
|
||||
/**
|
||||
* When true, shows a loading spinner
|
||||
*/
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
// The placeholder of the search input
|
||||
/**
|
||||
* The placeholder of the search input
|
||||
*/
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
// The search results where the @search listener needs to put the results into
|
||||
/**
|
||||
* The search results where the @search listener needs to put the results into
|
||||
*/
|
||||
searchResults: {
|
||||
type: Array as PropType<{[id: string]: any}>,
|
||||
default: () => [],
|
||||
},
|
||||
// The name of the property of the searched object to show the user.
|
||||
// If empty the component will show all raw data of an entry.
|
||||
/**
|
||||
* The name of the property of the searched object to show the user.
|
||||
* If empty the component will show all raw data of an entry.
|
||||
*/
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
// The object with the value, updated every time an entry is selected.
|
||||
/**
|
||||
* The object with the value, updated every time an entry is selected.
|
||||
*/
|
||||
modelValue: {
|
||||
type: [Object] as PropType<{[key: string]: any}>,
|
||||
default: null,
|
||||
},
|
||||
// If true, will provide an "add this as a new value" entry which fires an @create event when clicking on it.
|
||||
/**
|
||||
* If true, will provide an "add this as a new value" entry which fires an @create event when clicking on it.
|
||||
*/
|
||||
creatable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
// The text shown next to the new value option.
|
||||
/**
|
||||
* The text shown next to the new value option.
|
||||
*/
|
||||
createPlaceholder: {
|
||||
type: String,
|
||||
default() {
|
||||
|
@ -138,7 +154,9 @@ const props = defineProps({
|
|||
return t('input.multiselect.createPlaceholder')
|
||||
},
|
||||
},
|
||||
// The text shown next to an option.
|
||||
/**
|
||||
* The text shown next to an option.
|
||||
*/
|
||||
selectPlaceholder: {
|
||||
type: String,
|
||||
default() {
|
||||
|
@ -146,22 +164,30 @@ const props = defineProps({
|
|||
return t('input.multiselect.selectPlaceholder')
|
||||
},
|
||||
},
|
||||
// If true, allows for selecting multiple items. v-model will be an array with all selected values in that case.
|
||||
/**
|
||||
* If true, allows for selecting multiple items. v-model will be an array with all selected values in that case.
|
||||
*/
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
// If true, displays the search results inline instead of using a dropdown.
|
||||
/**
|
||||
* If true, displays the search results inline instead of using a dropdown.
|
||||
*/
|
||||
inline: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
// If true, shows search results when no query is specified.
|
||||
/**
|
||||
* If true, shows search results when no query is specified.
|
||||
*/
|
||||
showEmpty: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
// The delay in ms after which the search event will be fired. Used to avoid hitting the network on every keystroke.
|
||||
/**
|
||||
* The delay in ms after which the search event will be fired. Used to avoid hitting the network on every keystroke.
|
||||
*/
|
||||
searchDelay: {
|
||||
type: Number,
|
||||
default: 200,
|
||||
|
@ -174,17 +200,25 @@ const props = defineProps({
|
|||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: null): void
|
||||
// @search: Triggered every time the search query input changes
|
||||
/**
|
||||
* Triggered every time the search query input changes
|
||||
*/
|
||||
(e: 'search', query: string): void
|
||||
// @select: Triggered every time an option from the search results is selected. Also triggers a change in v-model.
|
||||
(e: 'select', value: null): void
|
||||
// @create: If nothing or no exact match was found and `creatable` is true, this event is triggered with the current value of the search query.
|
||||
/**
|
||||
* Triggered every time an option from the search results is selected. Also triggers a change in v-model.
|
||||
*/
|
||||
(e: 'select', value: {[key: string]: any}): void
|
||||
/**
|
||||
* If nothing or no exact match was found and `creatable` is true, this event is triggered with the current value of the search query.
|
||||
*/
|
||||
(e: 'create', query: string): void
|
||||
// @remove: If `multiple` is enabled, this will be fired every time an item is removed from the array of selected items.
|
||||
/**
|
||||
* If `multiple` is enabled, this will be fired every time an item is removed from the array of selected items.
|
||||
*/
|
||||
(e: 'remove', value: null): void
|
||||
}>()
|
||||
|
||||
const query = ref('')
|
||||
const query = ref<string | {[key: string]: any}>('')
|
||||
const searchTimeout = ref<ReturnType<typeof setTimeout> | null>(null)
|
||||
const localLoading = ref(false)
|
||||
const showSearchResults = ref(false)
|
||||
|
@ -401,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) !important;
|
||||
border-color: var(--grey-200) !important;
|
||||
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>
|
|
@ -4,22 +4,28 @@
|
|||
class="vue-simplemde-textarea"
|
||||
:name="name"
|
||||
:value="modelValue"
|
||||
@input="handleInput($event.target.value)"
|
||||
@input="handleInput(($event.target as HTMLTextAreaElement).value)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, watch, onMounted, onDeactivated, onBeforeUnmount, nextTick, shallowReactive} from 'vue'
|
||||
import type { ShallowReactive } from 'vue'
|
||||
|
||||
import EasyMDE from 'easymde'
|
||||
import {ref, watch, onMounted, onDeactivated, onBeforeUnmount, nextTick, shallowReactive, type ShallowReactive, type PropType} from 'vue'
|
||||
import EasyMDE, {toggleFullScreen} from 'easymde'
|
||||
import {marked} from 'marked'
|
||||
import type CodeMirror from 'codemirror'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: String,
|
||||
name: String,
|
||||
previewClass: String,
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
},
|
||||
previewClass: {
|
||||
type: String,
|
||||
},
|
||||
autoinit: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
|
@ -37,7 +43,7 @@ const props = defineProps({
|
|||
default: () => ({}),
|
||||
},
|
||||
previewRender: {
|
||||
type: Function,
|
||||
type: Function as PropType<EasyMDE.Options['previewRender']>,
|
||||
},
|
||||
})
|
||||
|
||||
|
@ -51,9 +57,9 @@ onMounted(() => {
|
|||
})
|
||||
|
||||
onDeactivated(() => {
|
||||
if (!easymde) return
|
||||
const isFullScreen = easymde.codemirror.getOption('fullScreen')
|
||||
if (isFullScreen) easymde.toggleFullScreen()
|
||||
if (easymde === undefined) return
|
||||
if (easymde.isFullscreenActive()) toggleFullScreen(easymde)
|
||||
easymde.toTextArea
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
|
@ -67,8 +73,8 @@ onBeforeUnmount(() => {
|
|||
const easymdeRef = ref<HTMLElement | null>(null)
|
||||
|
||||
function initialize() {
|
||||
const configs = Object.assign({
|
||||
element: easymdeRef.value?.firstElementChild,
|
||||
const configs: EasyMDE.Options = Object.assign({
|
||||
element: easymdeRef.value?.firstElementChild as HTMLElement,
|
||||
initialValue: props.modelValue,
|
||||
previewRender: props.previewRender,
|
||||
renderingConfig: {},
|
||||
|
@ -81,7 +87,7 @@ function initialize() {
|
|||
|
||||
// Determine whether to enable code highlighting
|
||||
if (props.highlight) {
|
||||
configs.renderingConfig.codeSyntaxHighlighting = true
|
||||
configs.renderingConfig!.codeSyntaxHighlighting = true
|
||||
}
|
||||
|
||||
// Set whether to render the input html
|
||||
|
@ -92,15 +98,16 @@ function initialize() {
|
|||
|
||||
// Add a custom previewClass
|
||||
const className = props.previewClass || ''
|
||||
addPreviewClass(className)
|
||||
addPreviewClass(easymde, className)
|
||||
|
||||
// Binding event
|
||||
bindingEvents()
|
||||
easymde.codemirror.on('change', handleCodemirrorInput)
|
||||
easymde.codemirror.on('blur', handleCodemirrorBlur)
|
||||
|
||||
nextTick(() => emit('initialized', easymde))
|
||||
}
|
||||
|
||||
function addPreviewClass(className: string) {
|
||||
function addPreviewClass(easymde: EasyMDE, className: string) {
|
||||
const wrapper = easymde.codemirror.getWrapperElement()
|
||||
const preview = document.createElement('div')
|
||||
wrapper.nextSibling.className += ` ${className}`
|
||||
|
@ -108,28 +115,24 @@ function addPreviewClass(className: string) {
|
|||
wrapper.appendChild(preview)
|
||||
}
|
||||
|
||||
function bindingEvents() {
|
||||
easymde.codemirror.on('change', handleCodemirrorInput)
|
||||
easymde.codemirror.on('blur', handleCodemirrorBlur)
|
||||
function handleInput(val: string) {
|
||||
isValueUpdateFromInner.value = true
|
||||
emit('update:modelValue', val)
|
||||
}
|
||||
|
||||
function handleCodemirrorInput(instance, changeObj) {
|
||||
if (changeObj.origin === 'setValue') {
|
||||
function handleCodemirrorInput(instance: CodeMirror.Editor, changeObj: CodeMirror.EditorChange) {
|
||||
if (changeObj.origin === 'setValue' || easymde === undefined) {
|
||||
return
|
||||
}
|
||||
const val = easymde.value()
|
||||
handleInput(val)
|
||||
handleInput(easymde.value())
|
||||
}
|
||||
|
||||
function handleCodemirrorBlur() {
|
||||
const val = easymde.value()
|
||||
if (easymde === undefined) {
|
||||
return
|
||||
}
|
||||
isValueUpdateFromInner.value = true
|
||||
emit('blur', val)
|
||||
}
|
||||
|
||||
function handleInput(val) {
|
||||
isValueUpdateFromInner.value = true
|
||||
emit('update:modelValue', val)
|
||||
emit('blur', easymde.value())
|
||||
}
|
||||
|
||||
watch(
|
||||
|
@ -138,7 +141,7 @@ watch(
|
|||
if (isValueUpdateFromInner.value) {
|
||||
isValueUpdateFromInner.value = false
|
||||
} else {
|
||||
easymde.value(val)
|
||||
easymde?.value(val)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
|
|
@ -5,42 +5,50 @@
|
|||
>
|
||||
<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>
|
||||
<transition name="fade">
|
||||
<CustomTransition name="fade">
|
||||
<Message variant="warning" v-if="currentList.isArchived" class="mb-4">
|
||||
{{ $t('list.archived') }}
|
||||
</Message>
|
||||
</transition>
|
||||
</CustomTransition>
|
||||
|
||||
<slot v-if="loadedListId"/>
|
||||
</div>
|
||||
|
@ -50,7 +58,9 @@
|
|||
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 CustomTransition from '@/components/misc/CustomTransition.vue'
|
||||
|
||||
import ListModel from '@/models/list'
|
||||
import ListService from '@/services/list'
|
||||
|
@ -61,7 +71,6 @@ import {useTitle} from '@/composables/useTitle'
|
|||
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
import {useListStore} from '@/stores/lists'
|
||||
import {useKanbanStore} from '@/stores/kanban'
|
||||
|
||||
const props = defineProps({
|
||||
listId: {
|
||||
|
@ -77,7 +86,6 @@ const props = defineProps({
|
|||
const route = useRoute()
|
||||
|
||||
const baseStore = useBaseStore()
|
||||
const kanbanStore = useKanbanStore()
|
||||
const listStore = useListStore()
|
||||
const listService = ref(new ListService())
|
||||
const loadedListId = ref(0)
|
||||
|
@ -90,6 +98,7 @@ const currentList = computed(() => {
|
|||
maxRight: null,
|
||||
} : baseStore.currentList
|
||||
})
|
||||
useTitle(() => currentList.value.id ? getListTitle(currentList.value) : '')
|
||||
|
||||
// watchEffect would be called every time the prop would get a value assigned, even if that value was the same as before.
|
||||
// This resulted in loading and setting the list multiple times, even when navigating away from it.
|
||||
|
@ -98,62 +107,47 @@ const currentList = computed(() => {
|
|||
// of it, most likely due to the rights not being properly populated.
|
||||
watch(
|
||||
() => props.listId,
|
||||
listId => loadList(listId),
|
||||
// loadList
|
||||
async (listIdToLoad: number) => {
|
||||
const listData = {id: listIdToLoad}
|
||||
saveListToHistory(listData)
|
||||
|
||||
// Don't load the list if we either already loaded it or aren't dealing with a list at all currently and
|
||||
// the currently loaded list has the right set.
|
||||
if (
|
||||
(
|
||||
listIdToLoad === loadedListId.value ||
|
||||
typeof listIdToLoad === 'undefined' ||
|
||||
listIdToLoad === currentList.value.id
|
||||
)
|
||||
&& typeof currentList.value !== 'undefined' && currentList.value.maxRight !== null
|
||||
) {
|
||||
loadedListId.value = props.listId
|
||||
return
|
||||
}
|
||||
|
||||
console.debug(`Loading list, props.viewName = ${props.viewName}, $route.params =`, route.params, `, loadedListId = ${loadedListId.value}, currentList = `, currentList.value)
|
||||
|
||||
// Set the current list to the one we're about to load so that the title is already shown at the top
|
||||
loadedListId.value = 0
|
||||
const listFromStore = listStore.getListById(listData.id)
|
||||
if (listFromStore !== null) {
|
||||
baseStore.setBackground(null)
|
||||
baseStore.setBlurHash(null)
|
||||
baseStore.handleSetCurrentList({list: listFromStore})
|
||||
}
|
||||
|
||||
// We create an extra list object instead of creating it in list.value because that would trigger a ui update which would result in bad ux.
|
||||
const list = new ListModel(listData)
|
||||
try {
|
||||
const loadedList = await listService.value.get(list)
|
||||
baseStore.handleSetCurrentList({list: loadedList})
|
||||
} finally {
|
||||
loadedListId.value = props.listId
|
||||
}
|
||||
},
|
||||
{immediate: true},
|
||||
)
|
||||
|
||||
useTitle(() => currentList.value.id ? getListTitle(currentList.value) : '')
|
||||
|
||||
async function loadList(listIdToLoad: number) {
|
||||
const listData = {id: listIdToLoad}
|
||||
saveListToHistory(listData)
|
||||
|
||||
// This invalidates the loaded list at the kanban board which lets it reload its content when
|
||||
// switched to it. This ensures updates done to tasks in the gantt or list views are consistently
|
||||
// shown in all views while preventing reloads when closing a task popup.
|
||||
// We don't do this for the table view because that does not change tasks.
|
||||
// FIXME: remove this
|
||||
if (
|
||||
props.viewName === 'list.list' ||
|
||||
props.viewName === 'list.gantt'
|
||||
) {
|
||||
kanbanStore.setListId(0)
|
||||
}
|
||||
|
||||
// Don't load the list if we either already loaded it or aren't dealing with a list at all currently and
|
||||
// the currently loaded list has the right set.
|
||||
if (
|
||||
(
|
||||
listIdToLoad === loadedListId.value ||
|
||||
typeof listIdToLoad === 'undefined' ||
|
||||
listIdToLoad === currentList.value.id
|
||||
)
|
||||
&& typeof currentList.value !== 'undefined' && currentList.value.maxRight !== null
|
||||
) {
|
||||
loadedListId.value = props.listId
|
||||
return
|
||||
}
|
||||
|
||||
console.debug(`Loading list, props.viewName = ${props.viewName}, $route.params =`, route.params, `, loadedListId = ${loadedListId.value}, currentList = `, currentList.value)
|
||||
|
||||
// Set the current list to the one we're about to load so that the title is already shown at the top
|
||||
loadedListId.value = 0
|
||||
const listFromStore = listStore.getListById(listData.id)
|
||||
if (listFromStore !== null) {
|
||||
baseStore.setBackground(null)
|
||||
baseStore.setBlurHash(null)
|
||||
baseStore.handleSetCurrentList({list: listFromStore})
|
||||
}
|
||||
|
||||
// We create an extra list object instead of creating it in list.value because that would trigger a ui update which would result in bad ux.
|
||||
const list = new ListModel(listData)
|
||||
try {
|
||||
const loadedList = await listService.value.get(list)
|
||||
await baseStore.handleSetCurrentList({list: loadedList})
|
||||
} finally {
|
||||
loadedListId.value = props.listId
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
@ -174,35 +168,32 @@ async function loadList(listIdToLoad: number) {
|
|||
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;
|
||||
}
|
|
@ -1,5 +1,13 @@
|
|||
<template>
|
||||
<dropdown>
|
||||
<template #trigger="triggerProps">
|
||||
<slot name="trigger" v-bind="triggerProps">
|
||||
<BaseButton class="dropdown-trigger" @click="triggerProps.toggleOpen">
|
||||
<icon icon="ellipsis-h" class="icon"/>
|
||||
</BaseButton>
|
||||
</slot>
|
||||
</template>
|
||||
|
||||
<template v-if="isSavedFilter(list)">
|
||||
<dropdown-item
|
||||
:to="{ name: 'filter.settings.edit', params: { listId: list.id } }"
|
||||
|
@ -78,12 +86,14 @@
|
|||
<script setup lang="ts">
|
||||
import {ref, computed, watchEffect, type PropType} from 'vue'
|
||||
|
||||
import {isSavedFilter} from '@/helpers/savedFilter'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import Dropdown from '@/components/misc/dropdown.vue'
|
||||
import DropdownItem from '@/components/misc/dropdown-item.vue'
|
||||
import Subscription from '@/components/misc/subscription.vue'
|
||||
import type {IList} from '@/modelTypes/IList'
|
||||
import type {ISubscription} from '@/modelTypes/ISubscription'
|
||||
|
||||
import {isSavedFilter} from '@/services/savedFilter'
|
||||
import {useConfigStore} from '@/stores/config'
|
||||
import {useListStore} from '@/stores/lists'
|
||||
import {useNamespaceStore} from '@/stores/namespaces'
|
||||
|
@ -114,4 +124,4 @@ function setSubscriptionInStore(sub: ISubscription) {
|
|||
listStore.setList(updatedList)
|
||||
namespaceStore.setListInNamespaceById(updatedList)
|
||||
}
|
||||
</script>
|
||||
</script>
|
|
@ -0,0 +1,176 @@
|
|||
<template>
|
||||
<div
|
||||
class="list-card"
|
||||
:class="{
|
||||
'has-light-text': background !== null,
|
||||
'has-background': blurHashUrl !== '' || background !== null
|
||||
}"
|
||||
:style="{
|
||||
'border-left': list.hexColor ? `0.25rem solid ${list.hexColor}` : undefined,
|
||||
'background-image': blurHashUrl !== '' ? `url(${blurHashUrl})` : undefined,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="list-background background-fade-in"
|
||||
:class="{'is-visible': background}"
|
||||
:style="{'background-image': background !== null ? `url(${background})` : undefined}"
|
||||
/>
|
||||
<span v-if="list.isArchived" class="is-archived" >{{ $t('namespace.archived') }}</span>
|
||||
|
||||
<div class="list-title" aria-hidden="true">{{ list.title }}</div>
|
||||
<BaseButton
|
||||
class="list-button"
|
||||
:aria-label="list.title"
|
||||
:title="list.description"
|
||||
:to="{
|
||||
name: 'list.index',
|
||||
params: { listId: list.id}
|
||||
}"
|
||||
/>
|
||||
<BaseButton
|
||||
v-if="!list.isArchived"
|
||||
class="favorite"
|
||||
:class="{'is-favorite': list.isFavorite}"
|
||||
@click.prevent.stop="listStore.toggleListFavorite(list)"
|
||||
>
|
||||
<icon :icon="list.isFavorite ? 'star' : ['far', 'star']" />
|
||||
</BaseButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {toRef, type PropType} from 'vue'
|
||||
|
||||
import type {IList} from '@/modelTypes/IList'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
|
||||
import {useListBackground} from './useListBackground'
|
||||
import {useListStore} from '@/stores/lists'
|
||||
|
||||
const props = defineProps({
|
||||
list: {
|
||||
type: Object as PropType<IList>,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const {background, blurHashUrl} = useListBackground(toRef(props, 'list'))
|
||||
|
||||
const listStore = useListStore()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.list-card {
|
||||
--list-card-padding: 1rem;
|
||||
background: var(--white);
|
||||
padding: var(--list-card-padding);
|
||||
border-radius: $radius;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: box-shadow $transition;
|
||||
position: relative;
|
||||
overflow: hidden; // hide background
|
||||
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
&:active,
|
||||
&:focus {
|
||||
box-shadow: var(--shadow-xs) !important;
|
||||
}
|
||||
|
||||
> * {
|
||||
// so the elements are on top of the background
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
.has-background,
|
||||
.list-background {
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
.list-background,
|
||||
.list-button {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.is-archived {
|
||||
font-size: .75rem;
|
||||
float: left;
|
||||
}
|
||||
|
||||
.list-title {
|
||||
align-self: flex-end;
|
||||
font-family: $vikunja-font;
|
||||
font-weight: 400;
|
||||
font-size: 1.5rem;
|
||||
line-height: var(--title-line-height);
|
||||
color: var(--text);
|
||||
width: 100%;
|
||||
margin-bottom: 0;
|
||||
max-height: calc(100% - (var(--list-card-padding) + 1rem)); // padding & height of the "is archived" badge
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
word-break: break-word;
|
||||
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.has-light-text .list-title {
|
||||
color: var(--grey-100);
|
||||
}
|
||||
|
||||
.has-background .list-title {
|
||||
text-shadow:
|
||||
0 0 10px var(--black),
|
||||
1px 1px 5px var(--grey-700),
|
||||
-1px -1px 5px var(--grey-700);
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
.favorite {
|
||||
position: absolute;
|
||||
top: var(--list-card-padding);
|
||||
right: var(--list-card-padding);
|
||||
transition: opacity $transition, color $transition;
|
||||
opacity: 0;
|
||||
|
||||
&:hover {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
&.is-favorite {
|
||||
display: inline-block;
|
||||
opacity: 1;
|
||||
color: var(--warning);
|
||||
}
|
||||
}
|
||||
|
||||
.list-card:hover .favorite {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.background-fade-in {
|
||||
opacity: 0;
|
||||
transition: opacity $transition;
|
||||
transition-delay: $transition-duration * 2; // To fake an appearing background
|
||||
|
||||
&.is-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,77 @@
|
|||
<template>
|
||||
<ul class="list-grid">
|
||||
<li
|
||||
v-for="(item, index) in filteredLists"
|
||||
:key="`list_${item.id}_${index}`"
|
||||
class="list-grid-item"
|
||||
>
|
||||
<ListCard :list="item" />
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {computed, type PropType} from 'vue'
|
||||
import type {IList} from '@/modelTypes/IList'
|
||||
|
||||
import ListCard from './ListCard.vue'
|
||||
|
||||
const props = defineProps({
|
||||
lists: {
|
||||
type: Array as PropType<IList[]>,
|
||||
default: () => [],
|
||||
},
|
||||
showArchived: {
|
||||
default: false,
|
||||
type: Boolean,
|
||||
},
|
||||
itemLimit: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const filteredLists = computed(() => {
|
||||
return props.showArchived
|
||||
? props.lists
|
||||
: props.lists.filter(l => !l.isArchived)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
$list-height: 150px;
|
||||
$list-spacing: 1rem;
|
||||
|
||||
.list-grid {
|
||||
margin: 0; // reset li
|
||||
list-style-type: none;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--list-columns), 1fr);
|
||||
grid-auto-rows: $list-height;
|
||||
gap: $list-spacing;
|
||||
|
||||
@media screen and (min-width: $mobile) {
|
||||
--list-rows: 4;
|
||||
--list-columns: 1;
|
||||
}
|
||||
|
||||
@media screen and (min-width: $mobile) and (max-width: $tablet) {
|
||||
--list-columns: 2;
|
||||
}
|
||||
|
||||
@media screen and (min-width: $tablet) and (max-width: $widescreen) {
|
||||
--list-columns: 3;
|
||||
--list-rows: 3;
|
||||
}
|
||||
|
||||
@media screen and (min-width: $widescreen) {
|
||||
--list-columns: 5;
|
||||
--list-rows: 2;
|
||||
}
|
||||
}
|
||||
|
||||
.list-grid-item {
|
||||
display: grid;
|
||||
margin-top: 0; // remove padding coming form .content li + li
|
||||
}
|
||||
</style>
|
|
@ -14,11 +14,11 @@
|
|||
{{ $t('filters.title') }}
|
||||
</x-button>
|
||||
<modal
|
||||
@close="() => modalOpen = false"
|
||||
:enabled="modalOpen"
|
||||
transition-name="fade"
|
||||
:overflow="true"
|
||||
variant="hint-modal"
|
||||
@close="() => modalOpen = false"
|
||||
>
|
||||
<filters
|
||||
:has-title="true"
|
||||
|
@ -34,7 +34,7 @@ import {computed, ref, watch} from 'vue'
|
|||
|
||||
import Filters from '@/components/list/partials/filters.vue'
|
||||
|
||||
import {getDefaultParams} from '@/composables/taskList'
|
||||
import {getDefaultParams} from '@/composables/useTaskList'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
|
|
|
@ -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'
|
||||
import {getDefaultParams} from '@/composables/taskList'
|
||||
import {camelCase} from 'camel-case'
|
||||
// FIXME: do not use this here for now. instead create new version from DEFAULT_PARAMS
|
||||
import {getDefaultParams} from '@/composables/useTaskList'
|
||||
|
||||
// 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>
|
||||
|
|
|
@ -1,222 +0,0 @@
|
|||
<template>
|
||||
<router-link
|
||||
:class="{
|
||||
'has-light-text': !colorIsDark(list.hexColor) || background !== null,
|
||||
'has-background': blurHashUrl !== '' || background !== null,
|
||||
}"
|
||||
:style="{
|
||||
'background-color': list.hexColor,
|
||||
'background-image': blurHashUrl !== null ? `url(${blurHashUrl})` : false,
|
||||
}"
|
||||
:to="{ name: 'list.index', params: { listId: list.id} }"
|
||||
class="list-card"
|
||||
v-if="list !== null && (showArchived ? true : !list.isArchived)"
|
||||
>
|
||||
<div
|
||||
class="list-background background-fade-in"
|
||||
:class="{'is-visible': background}"
|
||||
:style="{'background-image': background !== null ? `url(${background})` : undefined}"
|
||||
/>
|
||||
<div class="list-content">
|
||||
<span class="is-archived" v-if="list.isArchived">
|
||||
{{ $t('namespace.archived') }}
|
||||
</span>
|
||||
<BaseButton
|
||||
v-else
|
||||
:class="{'is-favorite': list.isFavorite}"
|
||||
@click.stop="listStore.toggleListFavorite(list)"
|
||||
class="favorite"
|
||||
>
|
||||
<icon :icon="list.isFavorite ? 'star' : ['far', 'star']"/>
|
||||
</BaseButton>
|
||||
|
||||
<div class="title">{{ list.title }}</div>
|
||||
</div>
|
||||
</router-link>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {type PropType, ref, watch} from 'vue'
|
||||
|
||||
import ListService from '@/services/list'
|
||||
import {getBlobFromBlurHash} from '@/helpers/getBlobFromBlurHash'
|
||||
|
||||
import {colorIsDark} from '@/helpers/color/colorIsDark'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import type {IList} from '@/modelTypes/IList'
|
||||
import {useListStore} from '@/stores/lists'
|
||||
|
||||
const background = ref<string | null>(null)
|
||||
const backgroundLoading = ref(false)
|
||||
const blurHashUrl = ref('')
|
||||
|
||||
const props = defineProps({
|
||||
list: {
|
||||
type: Object as PropType<IList>,
|
||||
required: true,
|
||||
},
|
||||
showArchived: {
|
||||
default: false,
|
||||
type: Boolean,
|
||||
},
|
||||
})
|
||||
|
||||
watch(props.list, loadBackground, {immediate: true})
|
||||
|
||||
async function loadBackground() {
|
||||
if (props.list === null || !props.list.backgroundInformation || backgroundLoading.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const blurHash = await getBlobFromBlurHash(props.list.backgroundBlurHash)
|
||||
if (blurHash) {
|
||||
blurHashUrl.value = window.URL.createObjectURL(blurHash)
|
||||
}
|
||||
|
||||
backgroundLoading.value = true
|
||||
|
||||
const listService = new ListService()
|
||||
try {
|
||||
background.value = await listService.background(props.list)
|
||||
} finally {
|
||||
backgroundLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const listStore = useListStore()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.list-card {
|
||||
cursor: pointer;
|
||||
width: calc((100% - #{($lists-per-row - 1) * 1rem}) / #{$lists-per-row});
|
||||
height: $list-height;
|
||||
background: var(--white);
|
||||
margin: 0 $list-spacing $list-spacing 0;
|
||||
border-radius: $radius;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: box-shadow $transition;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&.has-light-text .title {
|
||||
color: var(--grey-100) !important;
|
||||
}
|
||||
|
||||
&.has-background,
|
||||
.list-background {
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
&.has-background .title {
|
||||
text-shadow: 0 0 10px var(--black), 1px 1px 5px var(--grey-700), -1px -1px 5px var(--grey-700);
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
.list-background {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
&:active,
|
||||
&:focus,
|
||||
&:focus:not(:active) {
|
||||
box-shadow: var(--shadow-xs) !important;
|
||||
}
|
||||
|
||||
@media screen and (min-width: $widescreen) {
|
||||
&:nth-child(#{$lists-per-row}n) {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: $widescreen) and (min-width: $tablet) {
|
||||
$lists-per-row: 3;
|
||||
& {
|
||||
width: calc((100% - #{($lists-per-row - 1) * 1rem}) / #{$lists-per-row});
|
||||
}
|
||||
|
||||
&:nth-child(#{$lists-per-row}n) {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
$lists-per-row: 2;
|
||||
& {
|
||||
width: calc((100% - #{($lists-per-row - 1) * 1rem}) / #{$lists-per-row});
|
||||
}
|
||||
|
||||
&:nth-child(#{$lists-per-row}n) {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: $mobile) {
|
||||
$lists-per-row: 1;
|
||||
& {
|
||||
width: 100%;
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.list-content {
|
||||
display: flex;
|
||||
align-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
padding: 1rem;
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
|
||||
.is-archived {
|
||||
font-size: .75rem;
|
||||
}
|
||||
|
||||
.favorite {
|
||||
margin-left: auto;
|
||||
transition: opacity $transition, color $transition;
|
||||
opacity: 0;
|
||||
display: block;
|
||||
|
||||
&:hover,
|
||||
&.is-favorite {
|
||||
color: var(--warning);
|
||||
}
|
||||
}
|
||||
|
||||
.favorite.is-favorite,
|
||||
&:hover .favorite {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.title {
|
||||
align-self: flex-end;
|
||||
font-family: $vikunja-font;
|
||||
font-weight: 400;
|
||||
font-size: 1.5rem;
|
||||
color: var(--text);
|
||||
width: 100%;
|
||||
margin-bottom: 0;
|
||||
max-height: calc(100% - 2rem); // 1rem padding, 1rem height of the "is archived" badge
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,55 @@
|
|||
import {ref, watch, type Ref} from 'vue'
|
||||
import ListService from '@/services/list'
|
||||
import type {IList} from '@/modelTypes/IList'
|
||||
import {getBlobFromBlurHash} from '@/helpers/getBlobFromBlurHash'
|
||||
|
||||
export function useListBackground(list: Ref<IList>) {
|
||||
const background = ref<string | null>(null)
|
||||
const backgroundLoading = ref(false)
|
||||
const blurHashUrl = ref('')
|
||||
|
||||
watch(
|
||||
() => [list.value.id, list.value.backgroundBlurHash] as [IList['id'], IList['backgroundBlurHash']],
|
||||
async ([listId, blurHash], oldValue) => {
|
||||
if (
|
||||
list.value === null ||
|
||||
!list.value.backgroundInformation ||
|
||||
backgroundLoading.value
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const [oldListId, oldBlurHash] = oldValue || []
|
||||
if (
|
||||
oldValue !== undefined &&
|
||||
listId === oldListId && blurHash === oldBlurHash
|
||||
) {
|
||||
// list hasn't changed
|
||||
return
|
||||
}
|
||||
|
||||
backgroundLoading.value = true
|
||||
|
||||
try {
|
||||
const blurHashPromise = getBlobFromBlurHash(blurHash).then((blurHash) => {
|
||||
blurHashUrl.value = blurHash ? window.URL.createObjectURL(blurHash) : ''
|
||||
})
|
||||
|
||||
const listService = new ListService()
|
||||
const backgroundPromise = listService.background(list.value).then((result) => {
|
||||
background.value = result
|
||||
})
|
||||
await Promise.all([blurHashPromise, backgroundPromise])
|
||||
} finally {
|
||||
backgroundLoading.value = false
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
return {
|
||||
background,
|
||||
blurHashUrl,
|
||||
backgroundLoading,
|
||||
}
|
||||
}
|
|
@ -0,0 +1,67 @@
|
|||
<template>
|
||||
<transition :name="name">
|
||||
<slot />
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
name: 'flash-background' | 'fade' | 'width' | 'modal'
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
$flash-background-duration: 750ms;
|
||||
|
||||
.flash-background-enter-from,
|
||||
.flash-background-enter-active {
|
||||
animation: flash-background $flash-background-duration ease 1;
|
||||
}
|
||||
|
||||
@keyframes flash-background {
|
||||
0% {
|
||||
background: var(--primary-light);
|
||||
}
|
||||
100% {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
@keyframes flash-background {
|
||||
0% {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity $transition-duration;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.width-enter-active,
|
||||
.width-leave-active {
|
||||
transition: width $transition-duration;
|
||||
}
|
||||
|
||||
.width-enter-from,
|
||||
.width-leave-to {
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.modal-enter,
|
||||
.modal-leave-active {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.modal-enter .modal-container,
|
||||
.modal-leave-active .modal-container {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
</style>
|
|
@ -70,6 +70,8 @@ import {
|
|||
} from '@fortawesome/free-regular-svg-icons'
|
||||
import {FontAwesomeIcon} from '@fortawesome/vue-fontawesome'
|
||||
|
||||
import type { FontAwesomeIcon as FontAwesomeIconFixedTypes } from '@/types/vue-fontawesome'
|
||||
|
||||
library.add(faAlignLeft)
|
||||
library.add(faAngleRight)
|
||||
library.add(faArchive)
|
||||
|
@ -136,4 +138,5 @@ library.add(faTrashAlt)
|
|||
library.add(faUser)
|
||||
library.add(faUsers)
|
||||
|
||||
export default FontAwesomeIcon
|
||||
// overwriting the wrong types
|
||||
export default FontAwesomeIcon as unknown as FontAwesomeIconFixedTypes
|
|
@ -35,6 +35,9 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type {PropType} from 'vue'
|
||||
import type {IconProp} from '@fortawesome/fontawesome-svg-core'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
|
||||
defineProps({
|
||||
|
@ -51,7 +54,7 @@ defineProps({
|
|||
default: false,
|
||||
},
|
||||
closeIcon: {
|
||||
type: String,
|
||||
type: String as PropType<IconProp>,
|
||||
default: 'times',
|
||||
},
|
||||
shadow: {
|
||||
|
|
|
@ -6,10 +6,10 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { Color } from 'csstype'
|
||||
import type { DataType } from 'csstype'
|
||||
|
||||
defineProps< {
|
||||
color: Color,
|
||||
color: DataType.Color,
|
||||
}>()
|
||||
</script>
|
||||
|
||||
|
|
|
@ -10,7 +10,7 @@
|
|||
:loading="loading"
|
||||
>
|
||||
<div class="p-4">
|
||||
<slot />
|
||||
<slot/>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
|
@ -30,10 +30,12 @@
|
|||
{{ $t('misc.cancel') }}
|
||||
</x-button>
|
||||
<x-button
|
||||
v-if="hasPrimaryAction"
|
||||
variant="primary"
|
||||
@click.prevent.stop="primary()"
|
||||
:icon="primaryIcon"
|
||||
:disabled="primaryDisabled || loading"
|
||||
class="ml-2"
|
||||
>
|
||||
{{ primaryLabel || $t('misc.create') }}
|
||||
</x-button>
|
||||
|
@ -44,6 +46,9 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type {PropType} from 'vue'
|
||||
import type {IconProp} from '@fortawesome/fontawesome-svg-core'
|
||||
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
|
@ -53,13 +58,17 @@ defineProps({
|
|||
type: String,
|
||||
},
|
||||
primaryIcon: {
|
||||
type: String,
|
||||
type: String as PropType<IconProp>,
|
||||
default: 'plus',
|
||||
},
|
||||
primaryDisabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
hasPrimaryAction: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
tertiary: {
|
||||
type: String,
|
||||
default: '',
|
||||
|
|
|
@ -1,46 +1,24 @@
|
|||
<template>
|
||||
<component
|
||||
:is="componentNodeName"
|
||||
v-bind="elementBindings"
|
||||
:to="to"
|
||||
class="dropdown-item">
|
||||
<BaseButton class="dropdown-item">
|
||||
<span class="icon" v-if="icon">
|
||||
<icon :icon="icon"/>
|
||||
<Icon :icon="icon"/>
|
||||
</span>
|
||||
<span>
|
||||
<slot></slot>
|
||||
<slot />
|
||||
</span>
|
||||
</component>
|
||||
</BaseButton>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {ref, useAttrs, watchEffect} from 'vue'
|
||||
import BaseButton, { type BaseButtonProps } from '@/components/base//BaseButton.vue'
|
||||
import Icon from '@/components/misc/Icon'
|
||||
import type { IconProp } from '@fortawesome/fontawesome-svg-core'
|
||||
|
||||
const props = defineProps<{
|
||||
to?: object,
|
||||
icon?: string | string[],
|
||||
}>()
|
||||
export interface DropDownItemProps extends BaseButtonProps {
|
||||
icon?: IconProp,
|
||||
}
|
||||
|
||||
const componentNodeName = ref<Node['nodeName']>('a')
|
||||
const elementBindings = ref({})
|
||||
|
||||
const attrs = useAttrs()
|
||||
watchEffect(() => {
|
||||
let nodeName = 'a'
|
||||
|
||||
if (props.to) {
|
||||
nodeName = 'router-link'
|
||||
}
|
||||
|
||||
if ('href' in attrs) {
|
||||
nodeName = 'BaseButton'
|
||||
}
|
||||
|
||||
componentNodeName.value = nodeName
|
||||
elementBindings.value = {
|
||||
...attrs,
|
||||
}
|
||||
})
|
||||
defineProps<DropDownItemProps>()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
@ -51,10 +29,6 @@ watchEffect(() => {
|
|||
line-height: 1.5;
|
||||
padding: $item-padding;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
a.dropdown-item,
|
||||
button.dropdown-item {
|
||||
text-align: inherit;
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
|
@ -62,34 +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>
|
||||
|
|
|
@ -6,25 +6,27 @@
|
|||
</BaseButton>
|
||||
</slot>
|
||||
|
||||
<transition name="fade">
|
||||
<CustomTransition name="fade">
|
||||
<div class="dropdown-menu" v-if="open">
|
||||
<div class="dropdown-content">
|
||||
<slot :close="close"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</CustomTransition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref} from 'vue'
|
||||
import {ref, type PropType} from 'vue'
|
||||
import {onClickOutside} from '@vueuse/core'
|
||||
import type {IconProp} from '@fortawesome/fontawesome-svg-core'
|
||||
|
||||
import CustomTransition from '@/components/misc/CustomTransition.vue'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
|
||||
defineProps({
|
||||
triggerIcon: {
|
||||
type: String,
|
||||
type: String as PropType<IconProp>,
|
||||
default: 'ellipsis-h',
|
||||
},
|
||||
})
|
||||
|
|
|
@ -7,6 +7,12 @@
|
|||
</message>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
inheritAttrs: false,
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import Message from '@/components/misc/message.vue'
|
||||
import ButtonLink from '@/components/misc/ButtonLink.vue'
|
||||
|
|
|
@ -0,0 +1,225 @@
|
|||
<template>
|
||||
<input
|
||||
type="text"
|
||||
data-input
|
||||
:disabled="disabled"
|
||||
v-bind="attrs"
|
||||
ref="root"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import flatpickr from 'flatpickr'
|
||||
import 'flatpickr/dist/flatpickr.css'
|
||||
|
||||
// FIXME: Not sure how to alias these correctly
|
||||
// import Options = Flatpickr.Options doesn't work
|
||||
type Hook = flatpickr.Options.Hook
|
||||
type HookKey = flatpickr.Options.HookKey
|
||||
type Options = flatpickr.Options.Options
|
||||
type DateOption = flatpickr.Options.DateOption
|
||||
|
||||
function camelToKebab(string: string) {
|
||||
return string.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase()
|
||||
}
|
||||
|
||||
function arrayify<T = unknown>(obj: T) {
|
||||
return obj instanceof Array
|
||||
? obj
|
||||
: [obj]
|
||||
}
|
||||
|
||||
function nullify<T = unknown>(value: T) {
|
||||
return (value && (value as unknown[]).length)
|
||||
? value
|
||||
: null
|
||||
}
|
||||
|
||||
// Events to emit, copied from flatpickr source
|
||||
const includedEvents = [
|
||||
'onChange',
|
||||
'onClose',
|
||||
'onDestroy',
|
||||
'onMonthChange',
|
||||
'onOpen',
|
||||
'onYearChange',
|
||||
] as HookKey[]
|
||||
|
||||
// Let's not emit these events by default
|
||||
const excludedEvents = [
|
||||
'onValueUpdate',
|
||||
'onDayCreate',
|
||||
'onParseConfig',
|
||||
'onReady',
|
||||
'onPreCalendarPosition',
|
||||
'onKeyDown',
|
||||
] as HookKey[]
|
||||
|
||||
// Keep a copy of all events for later use
|
||||
const allEvents = includedEvents.concat(excludedEvents)
|
||||
|
||||
export default {inheritAttrs: false}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, onBeforeUnmount, onMounted, ref, toRefs, useAttrs, watch, watchEffect, type PropType} from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: [String, Number, Date, Array] as PropType<DateOption | DateOption[] | null>,
|
||||
default: null,
|
||||
},
|
||||
// https://flatpickr.js.org/options/
|
||||
config: {
|
||||
type: Object as PropType<Options>,
|
||||
default: () => ({
|
||||
defaultDate: null,
|
||||
wrap: false,
|
||||
}),
|
||||
},
|
||||
events: {
|
||||
type: Array as PropType<HookKey[]>,
|
||||
default: () => includedEvents,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'blur',
|
||||
'update:modelValue',
|
||||
...allEvents.map(camelToKebab),
|
||||
])
|
||||
|
||||
const {modelValue, config, disabled} = toRefs(props)
|
||||
|
||||
// bind listener like onBlur
|
||||
const attrs = useAttrs()
|
||||
|
||||
const root = ref<HTMLInputElement | null>(null)
|
||||
const fp = ref<flatpickr.Instance | null>(null)
|
||||
const safeConfig = ref<Options>({ ...props.config })
|
||||
|
||||
function prepareConfig() {
|
||||
// Don't mutate original object on parent component
|
||||
const newConfig: Options = { ...props.config }
|
||||
|
||||
props.events.forEach((hook) => {
|
||||
// Respect global callbacks registered via setDefault() method
|
||||
const globalCallbacks = flatpickr.defaultConfig[hook] || []
|
||||
|
||||
// Inject our own method along with user callback
|
||||
const localCallback: Hook = (...args) => emit(camelToKebab(hook), ...args)
|
||||
|
||||
// Overwrite with merged array
|
||||
newConfig[hook] = arrayify(newConfig[hook] || []).concat(
|
||||
globalCallbacks,
|
||||
localCallback,
|
||||
)
|
||||
})
|
||||
|
||||
// Watch for value changed by date-picker itself and notify parent component
|
||||
const onChange: Hook = (dates) => emit('update:modelValue', dates)
|
||||
newConfig['onChange'] = arrayify(newConfig['onChange'] || []).concat(onChange)
|
||||
|
||||
// Flatpickr does not emit input event in some cases
|
||||
// const onClose: Hook = (_selectedDates, dateStr) => emit('update:modelValue', dateStr)
|
||||
// newConfig['onClose'] = arrayify(newConfig['onClose'] || []).concat(onClose)
|
||||
|
||||
// Set initial date without emitting any event
|
||||
newConfig.defaultDate = props.modelValue || newConfig.defaultDate
|
||||
|
||||
safeConfig.value = newConfig
|
||||
|
||||
return safeConfig.value
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (
|
||||
fp.value || // Return early if flatpickr is already loaded
|
||||
!root.value // our input needs to be mounted
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
prepareConfig()
|
||||
|
||||
/**
|
||||
* Get the HTML node where flatpickr to be attached
|
||||
* Bind on parent element if wrap is true
|
||||
*/
|
||||
const element = props.config.wrap
|
||||
? root.value.parentNode
|
||||
: root.value
|
||||
|
||||
// Init flatpickr
|
||||
fp.value = flatpickr(element, safeConfig.value)
|
||||
})
|
||||
onBeforeUnmount(() => fp.value?.destroy())
|
||||
|
||||
watch(config, () => {
|
||||
if (!fp.value) return
|
||||
// Workaround: Don't pass hooks to configs again otherwise
|
||||
// previously registered hooks will stop working
|
||||
// Notice: we are looping through all events
|
||||
// This also means that new callbacks can not be passed once component has been initialized
|
||||
allEvents.forEach((hook) => {
|
||||
delete safeConfig.value?.[hook]
|
||||
})
|
||||
fp.value.set(safeConfig.value)
|
||||
|
||||
// Passing these properties in `set()` method will cause flatpickr to trigger some callbacks
|
||||
const configCallbacks = ['locale', 'showMonths'] as (keyof Options)[]
|
||||
|
||||
// Workaround: Allow to change locale dynamically
|
||||
configCallbacks.forEach(name => {
|
||||
if (typeof safeConfig.value?.[name] !== 'undefined' && fp.value) {
|
||||
fp.value.set(name, safeConfig.value[name])
|
||||
}
|
||||
})
|
||||
}, {deep:true})
|
||||
|
||||
const fpInput = computed(() => {
|
||||
if (!fp.value) return
|
||||
return fp.value.altInput || fp.value.input
|
||||
})
|
||||
|
||||
/**
|
||||
* init blur event
|
||||
* (is required by many validation libraries)
|
||||
*/
|
||||
function onBlur(event: Event) {
|
||||
emit('blur', nullify((event.target as HTMLInputElement).value))
|
||||
}
|
||||
|
||||
watchEffect(() => fpInput.value?.addEventListener('blur', onBlur))
|
||||
onBeforeUnmount(() => fpInput.value?.removeEventListener('blur', onBlur))
|
||||
|
||||
/**
|
||||
* Watch for the disabled property and sets the value to the real input.
|
||||
*/
|
||||
watchEffect(() => {
|
||||
if (disabled.value) {
|
||||
fpInput.value?.setAttribute('disabled', '')
|
||||
} else {
|
||||
fpInput.value?.removeAttribute('disabled')
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Watch for changes from parent component and update DOM
|
||||
*/
|
||||
watch(
|
||||
modelValue,
|
||||
newValue => {
|
||||
// Prevent updates if v-model value is same as input's current value
|
||||
if (!root.value || newValue === nullify(root.value.value)) return
|
||||
// Make sure we have a flatpickr instance and
|
||||
// notify flatpickr instance that there is a change in value
|
||||
fp.value?.setDate(newValue, true)
|
||||
},
|
||||
{deep: true},
|
||||
)
|
||||
</script>
|
|
@ -2,6 +2,12 @@
|
|||
<div class="loader-container is-loading"></div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
inheritAttrs: false,
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.loader-container {
|
||||
height: 100%;
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<Teleport to="body">
|
||||
<!-- FIXME: transition should not be included in the modal -->
|
||||
<transition :name="transitionName">
|
||||
<CustomTransition :name="transitionName" appear>
|
||||
<section
|
||||
v-if="enabled"
|
||||
class="modal-mask"
|
||||
|
@ -59,7 +59,7 @@
|
|||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</transition>
|
||||
</CustomTransition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
|
@ -70,6 +70,7 @@ export default {
|
|||
</script>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import CustomTransition from '@/components/misc/CustomTransition.vue'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import {ref, useAttrs, watchEffect} from 'vue'
|
||||
import {useScrollLock} from '@vueuse/core'
|
||||
|
@ -99,6 +100,9 @@ watchEffect(() => {
|
|||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
$modal-margin: 4rem;
|
||||
$modal-width: 1024px;
|
||||
|
||||
.modal-mask {
|
||||
position: fixed;
|
||||
z-index: 4000;
|
||||
|
@ -147,16 +151,16 @@ watchEffect(() => {
|
|||
// scrolling-content
|
||||
// used e.g. for <TaskDetailViewModal>
|
||||
.scrolling .modal-content {
|
||||
max-width: 1024px;
|
||||
max-width: $modal-width;
|
||||
width: 100%;
|
||||
margin: 4rem auto;
|
||||
margin: $modal-margin auto;
|
||||
|
||||
max-height: none; // reset bulma
|
||||
overflow: visible; // reset bulma
|
||||
|
||||
@media screen and (min-width: $tablet) {
|
||||
max-height: none; // reset bulma
|
||||
margin: 4rem auto; // reset bulma
|
||||
margin: $modal-margin auto; // reset bulma
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
@ -189,14 +193,23 @@ watchEffect(() => {
|
|||
}
|
||||
|
||||
.close {
|
||||
$close-button-min-space: 84px;
|
||||
$close-button-padding: 26px;
|
||||
position: fixed;
|
||||
top: 5px;
|
||||
right: 26px;
|
||||
color: var(--white);
|
||||
right: $close-button-padding;
|
||||
color: var(--grey-900);
|
||||
font-size: 2rem;
|
||||
|
||||
@media screen and (max-width: $desktop) {
|
||||
color: var(--grey-900);
|
||||
|
||||
@media screen and (min-width: $desktop) and (max-width: calc(#{$desktop } + #{$close-button-min-space})) {
|
||||
top: calc(5px + $modal-margin);
|
||||
right: 50%;
|
||||
// we align the close button to the modal until there is enough space outside for it
|
||||
transform: translateX(calc((#{$modal-width} / 2) - #{$close-button-padding}));
|
||||
}
|
||||
// we can only use light color when there is enough space for the close button next to the modal
|
||||
@media screen and (min-width: calc(#{$desktop } + #{$close-button-min-space})) {
|
||||
color: var(--white);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -16,11 +16,7 @@
|
|||
</div>
|
||||
<div
|
||||
class="buttons is-right"
|
||||
v-if="
|
||||
item.data &&
|
||||
item.data.actions &&
|
||||
item.data.actions.length > 0
|
||||
"
|
||||
v-if="item.data?.actions?.length > 0"
|
||||
>
|
||||
<x-button
|
||||
:key="'action_' + i"
|
||||
|
|
|
@ -1,20 +1,20 @@
|
|||
<template>
|
||||
<slot name="trigger" :isOpen="open" :toggle="toggle"></slot>
|
||||
<div class="popup" :class="{'is-open': open, 'has-overflow': props.hasOverflow && open}" ref="popup">
|
||||
<div
|
||||
class="popup"
|
||||
:class="{
|
||||
'is-open': open,
|
||||
'has-overflow': props.hasOverflow && open
|
||||
}"
|
||||
ref="popup"
|
||||
>
|
||||
<slot name="content" :isOpen="open"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {closeWhenClickedOutside} from '@/helpers/closeWhenClickedOutside'
|
||||
import {onBeforeUnmount, onMounted, ref} from 'vue'
|
||||
|
||||
const open = ref(false)
|
||||
const popup = ref(null)
|
||||
|
||||
const toggle = () => {
|
||||
open.value = !open.value
|
||||
}
|
||||
import {ref} from 'vue'
|
||||
import {onClickOutside} from '@vueuse/core'
|
||||
|
||||
const props = defineProps({
|
||||
hasOverflow: {
|
||||
|
@ -23,24 +23,22 @@ const props = defineProps({
|
|||
},
|
||||
})
|
||||
|
||||
function hidePopup(e) {
|
||||
const open = ref(false)
|
||||
const popup = ref<HTMLElement | null>(null)
|
||||
|
||||
function close() {
|
||||
open.value = false
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
open.value = !open.value
|
||||
}
|
||||
|
||||
onClickOutside(popup, () => {
|
||||
if (!open.value) {
|
||||
return
|
||||
}
|
||||
|
||||
// we actually want to use popup.$el, not its value.
|
||||
// eslint-disable-next-line vue/no-ref-as-operand
|
||||
closeWhenClickedOutside(e, popup.value, () => {
|
||||
open.value = false
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', hidePopup)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', hidePopup)
|
||||
close()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
@ -29,7 +29,7 @@
|
|||
</card>
|
||||
</no-auth-wrapper>
|
||||
</section>
|
||||
<transition name="fade">
|
||||
<CustomTransition name="fade">
|
||||
<section class="vikunja-loading" v-if="showLoading">
|
||||
<Logo class="logo"/>
|
||||
<p>
|
||||
|
@ -37,7 +37,7 @@
|
|||
{{ $t('ready.loading') }}
|
||||
</p>
|
||||
</section>
|
||||
</transition>
|
||||
</CustomTransition>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
|
@ -47,6 +47,7 @@ import {useRouter, useRoute} from 'vue-router'
|
|||
import Logo from '@/assets/logo.svg?component'
|
||||
import ApiConfig from '@/components/misc/api-config.vue'
|
||||
import Message from '@/components/misc/message.vue'
|
||||
import CustomTransition from '@/components/misc/CustomTransition.vue'
|
||||
import NoAuthWrapper from '@/components/misc/no-auth-wrapper.vue'
|
||||
|
||||
import {ERROR_NO_API_URL} from '@/helpers/checkAndSetApiUrl'
|
||||
|
@ -61,7 +62,7 @@ const route = useRoute()
|
|||
|
||||
const baseStore = useBaseStore()
|
||||
|
||||
const ready = ref(false)
|
||||
const ready = computed(() => baseStore.ready)
|
||||
const online = useOnline()
|
||||
|
||||
const error = ref('')
|
||||
|
@ -70,11 +71,11 @@ const showLoading = computed(() => !ready.value && error.value === '')
|
|||
async function load() {
|
||||
try {
|
||||
await baseStore.loadApp()
|
||||
const redirectTo = getAuthForRoute(route)
|
||||
baseStore.setReady(true)
|
||||
const redirectTo = await getAuthForRoute(route)
|
||||
if (typeof redirectTo !== 'undefined') {
|
||||
await router.push(redirectTo)
|
||||
}
|
||||
ready.value = true
|
||||
} catch (e: unknown) {
|
||||
error.value = String(e)
|
||||
}
|
||||
|
@ -128,14 +129,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>
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
v-else-if="type === 'dropdown'"
|
||||
v-tooltip="tooltipText"
|
||||
@click="changeSubscription"
|
||||
:class="{'is-disabled': disabled}"
|
||||
:disabled="disabled"
|
||||
:icon="iconName"
|
||||
>
|
||||
{{ buttonText }}
|
||||
|
@ -44,6 +44,7 @@ import SubscriptionModel from '@/models/subscription'
|
|||
import type {ISubscription} from '@/modelTypes/ISubscription'
|
||||
|
||||
import {success} from '@/message'
|
||||
import type { IconProp } from '@fortawesome/fontawesome-svg-core'
|
||||
|
||||
const props = defineProps({
|
||||
entity: String,
|
||||
|
@ -104,7 +105,7 @@ const tooltipText = computed(() => {
|
|||
})
|
||||
|
||||
const buttonText = computed(() => props.modelValue ? t('task.subscription.unsubscribe') : t('task.subscription.subscribe'))
|
||||
const iconName = computed(() => props.modelValue ? ['far', 'bell-slash'] : 'bell')
|
||||
const iconName = computed<IconProp>(() => props.modelValue ? ['far', 'bell-slash'] : 'bell')
|
||||
const disabled = computed(() => props.modelValue && subscriptionEntity.value !== props.entity)
|
||||
|
||||
function changeSubscription() {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -1,5 +1,13 @@
|
|||
<template>
|
||||
<dropdown>
|
||||
<template #trigger="triggerProps">
|
||||
<slot name="trigger" v-bind="triggerProps">
|
||||
<BaseButton class="dropdown-trigger" @click="triggerProps.toggleOpen">
|
||||
<icon icon="ellipsis-h" class="icon"/>
|
||||
</BaseButton>
|
||||
</slot>
|
||||
</template>
|
||||
|
||||
<template v-if="namespace.isArchived">
|
||||
<dropdown-item
|
||||
:to="{ name: 'namespace.settings.archive', params: { id: namespace.id } }"
|
||||
|
@ -56,6 +64,7 @@
|
|||
<script setup lang="ts">
|
||||
import {ref, onMounted, type PropType} from 'vue'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import Dropdown from '@/components/misc/dropdown.vue'
|
||||
import DropdownItem from '@/components/misc/dropdown-item.vue'
|
||||
import Subscription from '@/components/misc/subscription.vue'
|
||||
|
@ -79,11 +88,15 @@ onMounted(() => {
|
|||
|
||||
function setSubscriptionInStore(sub: ISubscription) {
|
||||
subscription.value = sub
|
||||
namespaceStore.setNamespaces([
|
||||
{
|
||||
...props.namespace,
|
||||
subscription: sub,
|
||||
},
|
||||
])
|
||||
namespaceStore.setNamespaceById({
|
||||
...props.namespace,
|
||||
subscription: sub,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.dropdown-trigger {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
</style>
|
|
@ -7,7 +7,7 @@
|
|||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<transition name="fade">
|
||||
<CustomTransition name="fade">
|
||||
<div class="notifications-list" v-if="showNotifications" ref="popup">
|
||||
<span class="head">{{ $t('notification.title') }}</span>
|
||||
<div
|
||||
|
@ -42,7 +42,7 @@
|
|||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</transition>
|
||||
</CustomTransition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -52,6 +52,7 @@ import {useRouter} from 'vue-router'
|
|||
|
||||
import NotificationService from '@/services/notification'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import CustomTransition from '@/components/misc/CustomTransition.vue'
|
||||
import User from '@/components/misc/user.vue'
|
||||
import { NOTIFICATION_NAMES as names, type INotification} from '@/modelTypes/INotification'
|
||||
import {closeWhenClickedOutside} from '@/helpers/closeWhenClickedOutside'
|
||||
|
@ -76,7 +77,7 @@ const notifications = computed(() => {
|
|||
})
|
||||
const userInfo = computed(() => authStore.info)
|
||||
|
||||
let interval: number
|
||||
let interval: ReturnType<typeof setInterval>
|
||||
|
||||
onMounted(() => {
|
||||
loadNotifications()
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -169,21 +169,19 @@
|
|||
</table>
|
||||
</div>
|
||||
|
||||
<transition name="modal">
|
||||
<modal
|
||||
@close="showDeleteModal = false"
|
||||
@submit="remove(listId)"
|
||||
v-if="showDeleteModal"
|
||||
>
|
||||
<template #header>
|
||||
<span>{{ $t('list.share.links.remove') }}</span>
|
||||
</template>
|
||||
<modal
|
||||
:enabled="showDeleteModal"
|
||||
@close="showDeleteModal = false"
|
||||
@submit="remove(listId)"
|
||||
>
|
||||
<template #header>
|
||||
<span>{{ $t('list.share.links.remove') }}</span>
|
||||
</template>
|
||||
|
||||
<template #text>
|
||||
<p>{{ $t('list.share.links.removeText') }}</p>
|
||||
</template>
|
||||
</modal>
|
||||
</transition>
|
||||
<template #text>
|
||||
<p>{{ $t('list.share.links.removeText') }}</p>
|
||||
</template>
|
||||
</modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -297,6 +295,4 @@ function getShareLink(hash: string, view: ListView = LIST_VIEWS.LIST) {
|
|||
.sharables-list:not(.card-content) {
|
||||
overflow-y: auto
|
||||
}
|
||||
|
||||
@include modal-transition();
|
||||
</style>
|
|
@ -113,22 +113,20 @@
|
|||
{{ $t('list.share.userTeam.notShared', {type: shareTypeNames}) }}
|
||||
</nothing>
|
||||
|
||||
<transition name="modal">
|
||||
<modal
|
||||
@close="showDeleteModal = false"
|
||||
@submit="deleteSharable()"
|
||||
v-if="showDeleteModal"
|
||||
>
|
||||
<template #header>
|
||||
<span>{{
|
||||
$t('list.share.userTeam.removeHeader', {type: shareTypeName, sharable: sharableName})
|
||||
}}</span>
|
||||
</template>
|
||||
<template #text>
|
||||
<p>{{ $t('list.share.userTeam.removeText', {type: shareTypeName, sharable: sharableName}) }}</p>
|
||||
</template>
|
||||
</modal>
|
||||
</transition>
|
||||
<modal
|
||||
:enabled="showDeleteModal"
|
||||
@close="showDeleteModal = false"
|
||||
@submit="deleteSharable()"
|
||||
>
|
||||
<template #header>
|
||||
<span>{{
|
||||
$t('list.share.userTeam.removeHeader', {type: shareTypeName, sharable: sharableName})
|
||||
}}</span>
|
||||
</template>
|
||||
<template #text>
|
||||
<p>{{ $t('list.share.userTeam.removeText', {type: shareTypeName, sharable: sharableName}) }}</p>
|
||||
</template>
|
||||
</modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -381,8 +379,4 @@ async function find(query: string) {
|
|||
return typeof sharables.value.find(s => s.id === m.id) === 'undefined'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@include modal-transition();
|
||||
</style>
|
||||
</script>
|
|
@ -0,0 +1,246 @@
|
|||
<template>
|
||||
<Loading
|
||||
v-if="props.isLoading && !ganttBars.length || dayjsLanguageLoading"
|
||||
class="gantt-container"
|
||||
/>
|
||||
<div class="gantt-container" v-else>
|
||||
<GGanttChart
|
||||
:date-format="DAYJS_ISO_DATE_FORMAT"
|
||||
:chart-start="isoToKebabDate(filters.dateFrom)"
|
||||
:chart-end="isoToKebabDate(filters.dateTo)"
|
||||
precision="day"
|
||||
bar-start="startDate"
|
||||
bar-end="endDate"
|
||||
:grid="true"
|
||||
@dragend-bar="updateGanttTask"
|
||||
@dblclick-bar="openTask"
|
||||
:width="ganttChartWidth + 'px'"
|
||||
>
|
||||
<template #timeunit="{value, date}">
|
||||
<div
|
||||
class="timeunit-wrapper"
|
||||
:class="{'today': dateIsToday(date)}"
|
||||
>
|
||||
<span>{{ value }}</span>
|
||||
<span class="weekday">
|
||||
{{ weekDayFromDate(date) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<GGanttRow
|
||||
v-for="(bar, k) in ganttBars"
|
||||
:key="k"
|
||||
label=""
|
||||
:bars="bar"
|
||||
/>
|
||||
</GGanttChart>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, ref, watch, toRefs} from 'vue'
|
||||
import {useRouter} from 'vue-router'
|
||||
import {useNow} from '@vueuse/core'
|
||||
|
||||
import {getHexColor} from '@/models/task'
|
||||
|
||||
import {colorIsDark} from '@/helpers/color/colorIsDark'
|
||||
import {isoToKebabDate} from '@/helpers/time/isoToKebabDate'
|
||||
import {parseKebabDate} from '@/helpers/time/parseKebabDate'
|
||||
|
||||
import type {ITask, ITaskPartialWithId} from '@/modelTypes/ITask'
|
||||
import type {DateISO} from '@/types/DateISO'
|
||||
import type {GanttFilters} from '@/views/list/helpers/useGanttFilters'
|
||||
|
||||
import {
|
||||
extendDayjs,
|
||||
GGanttChart,
|
||||
GGanttRow,
|
||||
type GanttBarObject,
|
||||
} from '@infectoone/vue-ganttastic'
|
||||
|
||||
import Loading from '@/components/misc/loading.vue'
|
||||
import {MILLISECONDS_A_DAY} from '@/constants/date'
|
||||
import {useWeekDayFromDate} from '@/helpers/time/formatDate'
|
||||
|
||||
export interface GanttChartProps {
|
||||
isLoading: boolean,
|
||||
filters: GanttFilters,
|
||||
tasks: Map<ITask['id'], ITask>,
|
||||
defaultTaskStartDate: DateISO
|
||||
defaultTaskEndDate: DateISO
|
||||
}
|
||||
|
||||
const DAYJS_ISO_DATE_FORMAT = 'YYYY-MM-DD'
|
||||
|
||||
const props = defineProps<GanttChartProps>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:task', task: ITaskPartialWithId): void
|
||||
}>()
|
||||
|
||||
const {tasks, filters} = toRefs(props)
|
||||
|
||||
// setup dayjs for vue-ganttastic
|
||||
const dayjsLanguageLoading = ref(false)
|
||||
// const dayjsLanguageLoading = useDayjsLanguageSync(dayjs)
|
||||
extendDayjs()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const dateFromDate = computed(() => new Date(new Date(filters.value.dateFrom).setHours(0,0,0,0)))
|
||||
const dateToDate = computed(() => new Date(new Date(filters.value.dateTo).setHours(23,59,0,0)))
|
||||
|
||||
const DAY_WIDTH_PIXELS = 30
|
||||
const ganttChartWidth = computed(() => {
|
||||
const dateDiff = Math.floor((dateToDate.value.valueOf() - dateFromDate.value.valueOf()) / MILLISECONDS_A_DAY)
|
||||
|
||||
return dateDiff * DAY_WIDTH_PIXELS
|
||||
})
|
||||
|
||||
const ganttBars = ref<GanttBarObject[][]>([])
|
||||
|
||||
/**
|
||||
* Update ganttBars when tasks change
|
||||
*/
|
||||
watch(
|
||||
tasks,
|
||||
() => {
|
||||
ganttBars.value = []
|
||||
tasks.value.forEach(t => ganttBars.value.push(transformTaskToGanttBar(t)))
|
||||
},
|
||||
{deep: true, immediate: true},
|
||||
)
|
||||
|
||||
function transformTaskToGanttBar(t: ITask) {
|
||||
const black = 'var(--grey-800)'
|
||||
return [{
|
||||
startDate: isoToKebabDate(t.startDate ? t.startDate.toISOString() : props.defaultTaskStartDate),
|
||||
endDate: isoToKebabDate(t.endDate ? t.endDate.toISOString() : props.defaultTaskEndDate),
|
||||
ganttBarConfig: {
|
||||
id: String(t.id),
|
||||
label: t.title,
|
||||
hasHandles: true,
|
||||
style: {
|
||||
color: t.startDate ? (colorIsDark(getHexColor(t.hexColor)) ? black : 'white') : black,
|
||||
backgroundColor: t.startDate ? getHexColor(t.hexColor) : 'var(--grey-100)',
|
||||
border: t.startDate ? '' : '2px dashed var(--grey-300)',
|
||||
'text-decoration': t.done ? 'line-through' : null,
|
||||
},
|
||||
},
|
||||
} as GanttBarObject]
|
||||
}
|
||||
|
||||
async function updateGanttTask(e: {
|
||||
bar: GanttBarObject;
|
||||
e: MouseEvent;
|
||||
datetime?: string | undefined;
|
||||
}) {
|
||||
emit('update:task', {
|
||||
id: Number(e.bar.ganttBarConfig.id),
|
||||
startDate: new Date(parseKebabDate(e.bar.startDate).setHours(0,0,0,0)),
|
||||
endDate: new Date(parseKebabDate(e.bar.endDate).setHours(23,59,0,0)),
|
||||
})
|
||||
}
|
||||
|
||||
function openTask(e: {
|
||||
bar: GanttBarObject;
|
||||
e: MouseEvent;
|
||||
datetime?: string | undefined;
|
||||
}) {
|
||||
router.push({
|
||||
name: 'task.detail',
|
||||
params: {id: e.bar.ganttBarConfig.id},
|
||||
state: {backdropView: router.currentRoute.value.fullPath},
|
||||
})
|
||||
}
|
||||
|
||||
const weekDayFromDate = useWeekDayFromDate()
|
||||
|
||||
const today = useNow()
|
||||
const dateIsToday = computed(() => (date: Date) => {
|
||||
return (
|
||||
date.getDate() === today.value.getDate() &&
|
||||
date.getMonth() === today.value.getMonth() &&
|
||||
date.getFullYear() === today.value.getFullYear()
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.gantt-container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
<style lang="scss">
|
||||
// Not scoped because we need to style the elements inside the gantt chart component
|
||||
.g-gantt-chart {
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.g-gantt-row-label {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.g-upper-timeunit, .g-timeunit {
|
||||
background: var(--white) !important;
|
||||
font-family: $vikunja-font;
|
||||
}
|
||||
|
||||
.g-upper-timeunit {
|
||||
font-weight: bold;
|
||||
border-right: 1px solid var(--grey-200);
|
||||
padding: .5rem 0;
|
||||
}
|
||||
|
||||
.g-timeunit .timeunit-wrapper {
|
||||
padding: 0.5rem 0;
|
||||
font-size: 1rem !important;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
|
||||
&.today {
|
||||
background: var(--primary);
|
||||
color: var(--white);
|
||||
border-radius: 5px 5px 0 0;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.weekday {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
.g-timeaxis {
|
||||
height: auto !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.g-gantt-row > .g-gantt-row-bars-container {
|
||||
border-bottom: none !important;
|
||||
border-top: none !important;
|
||||
}
|
||||
|
||||
.g-gantt-row:nth-child(odd) {
|
||||
background: hsla(var(--grey-100-hsl), .5);
|
||||
}
|
||||
|
||||
.g-gantt-bar {
|
||||
border-radius: $radius * 1.5;
|
||||
overflow: visible;
|
||||
font-size: .85rem;
|
||||
|
||||
&-handle-left,
|
||||
&-handle-right {
|
||||
width: 6px !important;
|
||||
height: 75% !important;
|
||||
opacity: .75 !important;
|
||||
border-radius: $radius !important;
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,80 @@
|
|||
<template>
|
||||
<form
|
||||
@submit.prevent="createTask"
|
||||
class="add-new-task"
|
||||
>
|
||||
<CustomTransition name="width">
|
||||
<input
|
||||
v-if="newTaskFieldActive"
|
||||
v-model="newTaskTitle"
|
||||
@blur="hideCreateNewTask"
|
||||
@keyup.esc="newTaskFieldActive = false"
|
||||
class="input"
|
||||
ref="newTaskTitleField"
|
||||
type="text"
|
||||
/>
|
||||
</CustomTransition>
|
||||
<x-button @click="showCreateTaskOrCreate" :shadow="false" icon="plus">
|
||||
{{ $t('task.new') }}
|
||||
</x-button>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {nextTick, ref} from 'vue'
|
||||
import type {ITask} from '@/modelTypes/ITask'
|
||||
|
||||
import CustomTransition from '@/components/misc/CustomTransition.vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'create-task', title: string): Promise<ITask>
|
||||
}>()
|
||||
|
||||
const newTaskFieldActive = ref(false)
|
||||
const newTaskTitleField = ref()
|
||||
const newTaskTitle = ref('')
|
||||
|
||||
function showCreateTaskOrCreate() {
|
||||
if (!newTaskFieldActive.value) {
|
||||
// Timeout to not send the form if the field isn't even shown
|
||||
setTimeout(() => {
|
||||
newTaskFieldActive.value = true
|
||||
nextTick(() => newTaskTitleField.value.focus())
|
||||
}, 100)
|
||||
} else {
|
||||
createTask()
|
||||
}
|
||||
}
|
||||
|
||||
function hideCreateNewTask() {
|
||||
if (newTaskTitle.value === '') {
|
||||
nextTick(() => (newTaskFieldActive.value = false))
|
||||
}
|
||||
}
|
||||
|
||||
async function createTask() {
|
||||
if (!newTaskFieldActive.value) {
|
||||
return
|
||||
}
|
||||
await emit('create-task', newTaskTitle.value)
|
||||
newTaskTitle.value = ''
|
||||
hideCreateNewTask()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.add-new-task {
|
||||
padding: 1rem .7rem .4rem .7rem;
|
||||
display: flex;
|
||||
max-width: 450px;
|
||||
|
||||
.input {
|
||||
margin-right: .7rem;
|
||||
font-size: .8rem;
|
||||
}
|
||||
|
||||
.button {
|
||||
font-size: .68rem;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -41,9 +41,8 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, ref, unref, watch} from 'vue'
|
||||
import {computed, ref} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
import {debouncedWatch, type MaybeRef, tryOnMounted, useWindowSize} from '@vueuse/core'
|
||||
|
||||
import QuickAddMagic from '@/components/tasks/partials/quick-add-magic.vue'
|
||||
import type {ITask} from '@/modelTypes/ITask'
|
||||
|
@ -53,74 +52,8 @@ import TaskRelationModel from '@/models/taskRelation'
|
|||
import {RELATION_KIND} from '@/types/IRelationKind'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
import {useTaskStore} from '@/stores/tasks'
|
||||
|
||||
function useAutoHeightTextarea(value: MaybeRef<string>) {
|
||||
const textarea = ref<HTMLInputElement>()
|
||||
const minHeight = ref(0)
|
||||
|
||||
// adapted from https://github.com/LeaVerou/stretchy/blob/47f5f065c733029acccb755cae793009645809e2/src/stretchy.js#L34
|
||||
function resize(textareaEl: HTMLInputElement | undefined) {
|
||||
if (!textareaEl) return
|
||||
|
||||
let empty
|
||||
|
||||
// the value here is the attribute value
|
||||
if (!textareaEl.value && textareaEl.placeholder) {
|
||||
empty = true
|
||||
textareaEl.value = textareaEl.placeholder
|
||||
}
|
||||
|
||||
const cs = getComputedStyle(textareaEl)
|
||||
|
||||
textareaEl.style.minHeight = ''
|
||||
textareaEl.style.height = '0'
|
||||
const offset = textareaEl.offsetHeight - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom)
|
||||
const height = textareaEl.scrollHeight + offset + 'px'
|
||||
|
||||
textareaEl.style.height = height
|
||||
|
||||
// calculate min-height for the first time
|
||||
if (!minHeight.value) {
|
||||
minHeight.value = parseFloat(height)
|
||||
}
|
||||
|
||||
textareaEl.style.minHeight = minHeight.value.toString()
|
||||
|
||||
|
||||
if (empty) {
|
||||
textareaEl.value = ''
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
tryOnMounted(() => {
|
||||
if (textarea.value) {
|
||||
// we don't want scrollbars
|
||||
textarea.value.style.overflowY = 'hidden'
|
||||
}
|
||||
})
|
||||
|
||||
const {width: windowWidth} = useWindowSize()
|
||||
|
||||
debouncedWatch(
|
||||
windowWidth,
|
||||
() => resize(textarea.value),
|
||||
{debounce: 200},
|
||||
)
|
||||
|
||||
// It is not possible to get notified of a change of the value attribute of a textarea without workarounds (setTimeout)
|
||||
// So instead we watch the value that we bound to it.
|
||||
watch(
|
||||
() => [textarea.value, unref(value)],
|
||||
() => resize(textarea.value),
|
||||
{
|
||||
immediate: true, // calculate initial size
|
||||
flush: 'post', // resize after value change is rendered to DOM
|
||||
},
|
||||
)
|
||||
|
||||
return textarea
|
||||
}
|
||||
import {useAutoHeightTextarea} from '@/composables/useAutoHeightTextarea'
|
||||
import {getLabelsFromPrefix} from '@/modules/parseTaskText'
|
||||
|
||||
const props = defineProps({
|
||||
defaultPosition: {
|
||||
|
@ -150,6 +83,7 @@ function resetEmptyTitleError(e) {
|
|||
}
|
||||
|
||||
const loading = computed(() => taskStore.isLoading)
|
||||
|
||||
async function addTask() {
|
||||
if (newTaskTitle.value === '') {
|
||||
errorMessage.value = t('list.create.addTitleRequired')
|
||||
|
@ -166,14 +100,27 @@ async function addTask() {
|
|||
// by quick add magic.
|
||||
const createdTasks: { [key: ITask['title']]: ITask } = {}
|
||||
const tasksToCreate = parseSubtasksViaIndention(newTaskTitle.value)
|
||||
const newTasks = tasksToCreate.map(async ({title}) => {
|
||||
|
||||
// We ensure all labels exist prior to passing them down to the create task method
|
||||
// In the store it will only ever see one task at a time so there's no way to reliably
|
||||
// check if a new label was created before (because everything happens async).
|
||||
const allLabels = tasksToCreate.map(({title}) => getLabelsFromPrefix(title) ?? [])
|
||||
await taskStore.ensureLabelsExist(allLabels.flat())
|
||||
|
||||
const newTasks = tasksToCreate.map(async ({title, list}) => {
|
||||
if (title === '') {
|
||||
return
|
||||
}
|
||||
|
||||
// If the task has a list specified, make sure to use it
|
||||
let listId = null
|
||||
if (list !== null) {
|
||||
listId = await taskStore.findListId({list, listId: 0})
|
||||
}
|
||||
|
||||
const task = await taskStore.createNewTask({
|
||||
title,
|
||||
listId: authStore.settings.defaultListId,
|
||||
listId: listId || authStore.settings.defaultListId,
|
||||
position: props.defaultPosition,
|
||||
})
|
||||
createdTasks[title] = task
|
||||
|
@ -214,7 +161,7 @@ async function addTask() {
|
|||
return rel
|
||||
})
|
||||
await Promise.all(relations)
|
||||
} catch (e: { message?: string }) {
|
||||
} catch (e: any) {
|
||||
newTaskTitle.value = taskTitleBackup
|
||||
if (e?.message === 'NO_LIST') {
|
||||
errorMessage.value = t('list.create.addListRequired')
|
||||
|
|
|
@ -1,187 +0,0 @@
|
|||
<template>
|
||||
<card
|
||||
class="taskedit"
|
||||
:title="$t('list.list.editTask')"
|
||||
@close="$emit('close')"
|
||||
:has-close="true"
|
||||
>
|
||||
<form @submit.prevent="editTaskSubmit()">
|
||||
<div class="field">
|
||||
<label class="label" for="tasktext">{{ $t('task.attributes.title') }}</label>
|
||||
<div class="control">
|
||||
<input
|
||||
:class="{ disabled: taskService.loading }"
|
||||
:disabled="taskService.loading || undefined"
|
||||
@change="editTaskSubmit()"
|
||||
class="input"
|
||||
id="tasktext"
|
||||
type="text"
|
||||
v-focus
|
||||
v-model="taskEditTask.title"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label" for="taskdescription">{{ $t('task.attributes.description') }}</label>
|
||||
<div class="control">
|
||||
<editor
|
||||
:preview-is-default="false"
|
||||
id="taskdescription"
|
||||
:placeholder="$t('task.description.placeholder')"
|
||||
v-if="editorActive"
|
||||
v-model="taskEditTask.description"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<strong>{{ $t('task.attributes.reminders') }}</strong>
|
||||
<reminders
|
||||
v-model="taskEditTask.reminderDates"
|
||||
@update:model-value="editTaskSubmit()"
|
||||
/>
|
||||
|
||||
<div class="field">
|
||||
<label class="label">{{ $t('task.attributes.labels') }}</label>
|
||||
<div class="control">
|
||||
<edit-labels
|
||||
:task-id="taskEditTask.id"
|
||||
v-model="taskEditTask.labels"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label">{{ $t('task.attributes.color') }}</label>
|
||||
<div class="control">
|
||||
<color-picker v-model="taskEditTask.hexColor" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<x-button
|
||||
:loading="taskService.loading"
|
||||
class="is-fullwidth"
|
||||
@click="editTaskSubmit()"
|
||||
>
|
||||
{{ $t('misc.save') }}
|
||||
</x-button>
|
||||
|
||||
<router-link
|
||||
class="mt-2 has-text-centered is-block"
|
||||
:to="taskDetailRoute"
|
||||
>
|
||||
{{ $t('task.openDetail') }}
|
||||
</router-link>
|
||||
</form>
|
||||
</card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, reactive, computed, shallowReactive, watch, nextTick, type PropType} from 'vue'
|
||||
import {useRouter} from 'vue-router'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
import Editor from '@/components/input/AsyncEditor'
|
||||
|
||||
import TaskService from '@/services/task'
|
||||
import TaskModel from '@/models/task'
|
||||
import type {ITask} from '@/modelTypes/ITask'
|
||||
import EditLabels from './partials/editLabels.vue'
|
||||
import Reminders from './partials/reminders.vue'
|
||||
import ColorPicker from '../input/colorPicker.vue'
|
||||
|
||||
import {success} from '@/message'
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
const router = useRouter()
|
||||
|
||||
const props = defineProps({
|
||||
task: {
|
||||
type: Object as PropType<ITask | null>,
|
||||
},
|
||||
})
|
||||
|
||||
const taskService = shallowReactive(new TaskService())
|
||||
|
||||
const editorActive = ref(false)
|
||||
let taskEditTask: ITask | undefined
|
||||
|
||||
|
||||
// FIXME: this initialization should not be necessary here
|
||||
function initTaskFields() {
|
||||
taskEditTask.dueDate =
|
||||
+new Date(props.task.dueDate) === 0 ? null : props.task.dueDate
|
||||
taskEditTask.startDate =
|
||||
+new Date(props.task.startDate) === 0
|
||||
? null
|
||||
: props.task.startDate
|
||||
taskEditTask.endDate =
|
||||
+new Date(props.task.endDate) === 0 ? null : props.task.endDate
|
||||
// This makes the editor trigger its mounted function again which makes it forget every input
|
||||
// it currently has in its textarea. This is a counter-hack to a hack inside of vue-easymde
|
||||
// which made it impossible to detect change from the outside. Therefore the component would
|
||||
// not update if new content from the outside was made available.
|
||||
// See https://github.com/NikulinIlya/vue-easymde/issues/3
|
||||
editorActive.value = false
|
||||
nextTick(() => (editorActive.value = true))
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.task,
|
||||
() => {
|
||||
if (!taskEditTask) {
|
||||
taskEditTask = reactive(props.task)
|
||||
} else {
|
||||
Object.assign(taskEditTask, new TaskModel(props.task))
|
||||
}
|
||||
initTaskFields()
|
||||
},
|
||||
{immediate: true },
|
||||
)
|
||||
const taskDetailRoute = computed(() => {
|
||||
return {
|
||||
name: 'task.detail',
|
||||
params: { id: taskEditTask.id },
|
||||
state: { backdropView: router.currentRoute.value.fullPath },
|
||||
}
|
||||
})
|
||||
|
||||
async function editTaskSubmit() {
|
||||
const newTask = await taskService.update(taskEditTask)
|
||||
Object.assign(taskEditTask, newTask)
|
||||
initTaskFields()
|
||||
success({message: t('task.detail.updateSuccess')})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.priority-select {
|
||||
.select,
|
||||
select {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
ul.assingees {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
|
||||
li {
|
||||
padding: 0.5rem 0.5rem 0;
|
||||
|
||||
a {
|
||||
float: right;
|
||||
color: var(--danger);
|
||||
transition: all $transition;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tag {
|
||||
margin-right: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -1,642 +0,0 @@
|
|||
<template>
|
||||
<div class="gantt-chart">
|
||||
<div class="filter-container">
|
||||
<div class="items">
|
||||
<filter-popup
|
||||
v-model="params"
|
||||
@update:modelValue="loadTasks()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dates">
|
||||
<template v-for="(y, yk) in days" :key="yk + 'year'">
|
||||
<div class="months">
|
||||
<div
|
||||
:key="mk + 'month'"
|
||||
class="month"
|
||||
v-for="(m, mk) in days[yk]"
|
||||
>
|
||||
{{ formatMonthAndYear(yk, parseInt(mk) + 1) }}
|
||||
<div class="days">
|
||||
<div
|
||||
:class="{ today: d.toDateString() === now.toDateString() }"
|
||||
:key="dk + 'day'"
|
||||
:style="{ width: dayWidth + 'px' }"
|
||||
class="day"
|
||||
v-for="(d, dk) in days[yk][mk]"
|
||||
>
|
||||
<span class="theday" v-if="dayWidth > 25">
|
||||
{{ d.getDate() }}
|
||||
</span>
|
||||
<span class="weekday" v-if="dayWidth > 25">
|
||||
{{
|
||||
d.toLocaleString('en-us', {
|
||||
weekday: 'short',
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div :style="{ width: fullWidth + 'px' }" class="tasks">
|
||||
<div
|
||||
v-for="(t, k) in theTasks"
|
||||
:key="t ? t.id : 0"
|
||||
:style="{
|
||||
background:
|
||||
'repeating-linear-gradient(90deg, #ededed, #ededed 1px, ' +
|
||||
(k % 2 === 0
|
||||
? '#fafafa 1px, #fafafa '
|
||||
: '#fff 1px, #fff ') +
|
||||
dayWidth +
|
||||
'px)',
|
||||
}"
|
||||
class="row"
|
||||
>
|
||||
<VueDragResize
|
||||
:class="{
|
||||
done: t ? t.done : false,
|
||||
'is-current-edit': taskToEdit !== null && taskToEdit.id === t.id,
|
||||
'has-light-text': !colorIsDark(t.getHexColor()),
|
||||
'has-dark-text': colorIsDark(t.getHexColor()),
|
||||
}"
|
||||
:gridX="dayWidth"
|
||||
:h="31"
|
||||
:isActive="canWrite"
|
||||
:minw="dayWidth"
|
||||
:parentLimitation="true"
|
||||
:parentW="fullWidth"
|
||||
:snapToGrid="true"
|
||||
:sticks="['mr', 'ml']"
|
||||
:style="{
|
||||
'border-color': t.getHexColor(),
|
||||
'background-color': t.getHexColor(),
|
||||
}"
|
||||
:w="t.durationDays * dayWidth"
|
||||
:x="t.offsetDays * dayWidth - 6"
|
||||
:y="0"
|
||||
@dragstop="(e) => resizeTask(t, e)"
|
||||
@resizestop="(e) => resizeTask(t, e)"
|
||||
axis="x"
|
||||
class="task"
|
||||
>
|
||||
<span
|
||||
:class="{
|
||||
'has-high-priority': t.priority >= priorities.HIGH,
|
||||
'has-not-so-high-priority':
|
||||
t.priority === priorities.HIGH,
|
||||
'has-super-high-priority':
|
||||
t.priority === priorities.DO_NOW,
|
||||
}"
|
||||
>
|
||||
{{ t.title }}
|
||||
</span>
|
||||
<priority-label :priority="t.priority" :done="t.done"/>
|
||||
<!-- using the key here forces vue to use the updated version model and not the response returned by the api -->
|
||||
<!-- FIXME: add label -->
|
||||
<BaseButton @click="editTask(theTasks[k])" class="edit-toggle">
|
||||
<icon icon="pen"/>
|
||||
</BaseButton>
|
||||
</VueDragResize>
|
||||
</div>
|
||||
<template v-if="showTaskswithoutDates">
|
||||
<div
|
||||
:key="t.id"
|
||||
:style="{
|
||||
background:
|
||||
'repeating-linear-gradient(90deg, #ededed, #ededed 1px, ' +
|
||||
(k % 2 === 0
|
||||
? '#fafafa 1px, #fafafa '
|
||||
: '#fff 1px, #fff ') +
|
||||
dayWidth +
|
||||
'px)',
|
||||
}"
|
||||
class="row"
|
||||
v-for="(t, k) in tasksWithoutDates"
|
||||
>
|
||||
<VueDragResize
|
||||
:gridX="dayWidth"
|
||||
:h="31"
|
||||
:isActive="canWrite"
|
||||
:minw="dayWidth"
|
||||
:parentLimitation="true"
|
||||
:parentW="fullWidth"
|
||||
:snapToGrid="true"
|
||||
:sticks="['mr', 'ml']"
|
||||
:x="dayOffsetUntilToday * dayWidth - 6"
|
||||
:y="0"
|
||||
@dragstop="(e) => resizeTask(t, e)"
|
||||
@resizestop="(e) => resizeTask(t, e)"
|
||||
axis="x"
|
||||
class="task nodate"
|
||||
v-tooltip="$t('list.gantt.noDates')"
|
||||
>
|
||||
<span>{{ t.title }}</span>
|
||||
</VueDragResize>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<form
|
||||
@submit.prevent="addNewTask()"
|
||||
class="add-new-task"
|
||||
v-if="canWrite"
|
||||
>
|
||||
<transition name="width">
|
||||
<input
|
||||
@blur="hideCrateNewTask"
|
||||
@keyup.esc="newTaskFieldActive = false"
|
||||
class="input"
|
||||
ref="newTaskTitleField"
|
||||
type="text"
|
||||
v-if="newTaskFieldActive"
|
||||
v-model="newTaskTitle"
|
||||
/>
|
||||
</transition>
|
||||
<x-button @click="showCreateNewTask" :shadow="false" icon="plus">
|
||||
{{ $t('list.list.newTaskCta') }}
|
||||
</x-button>
|
||||
</form>
|
||||
<transition name="fade">
|
||||
<edit-task
|
||||
v-if="isTaskEdit"
|
||||
class="taskedit"
|
||||
:title="$t('list.list.editTask')"
|
||||
@close="() => {isTaskEdit = false;taskToEdit = null}"
|
||||
:task="taskToEdit"
|
||||
/>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {defineComponent} from 'vue'
|
||||
import {mapState} from 'pinia'
|
||||
|
||||
import VueDragResize from 'vue-drag-resize'
|
||||
import EditTask from './edit-task.vue'
|
||||
|
||||
import TaskService from '../../services/task'
|
||||
import TaskModel from '../../models/task'
|
||||
import {PRIORITIES as priorities} from '@/constants/priorities'
|
||||
import PriorityLabel from './partials/priorityLabel.vue'
|
||||
import TaskCollectionService from '../../services/taskCollection'
|
||||
import {RIGHTS as Rights} from '@/constants/rights'
|
||||
import FilterPopup from '@/components/list/partials/filter-popup.vue'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
|
||||
import {colorIsDark} from '@/helpers/color/colorIsDark'
|
||||
import {formatDate} from '@/helpers/time/formatDate'
|
||||
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'GanttChart',
|
||||
components: {
|
||||
BaseButton,
|
||||
FilterPopup,
|
||||
PriorityLabel,
|
||||
EditTask,
|
||||
VueDragResize,
|
||||
},
|
||||
props: {
|
||||
listId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
showTaskswithoutDates: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
dateFrom: {
|
||||
default: () => new Date(new Date().setDate(new Date().getDate() - 15)),
|
||||
},
|
||||
dateTo: {
|
||||
default: () => new Date(new Date().setDate(new Date().getDate() + 30)),
|
||||
},
|
||||
// The width of a day in pixels, used to calculate all sorts of things.
|
||||
dayWidth: {
|
||||
type: Number,
|
||||
default: 35,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
days: [],
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
theTasks: [], // Pretty much a copy of the prop, since we cant mutate the prop directly
|
||||
tasksWithoutDates: [],
|
||||
taskService: new TaskService(),
|
||||
fullWidth: 0,
|
||||
now: new Date(),
|
||||
dayOffsetUntilToday: 0,
|
||||
isTaskEdit: false,
|
||||
taskToEdit: null,
|
||||
newTaskTitle: '',
|
||||
newTaskFieldActive: false,
|
||||
priorities: priorities,
|
||||
taskCollectionService: new TaskCollectionService(),
|
||||
|
||||
params: {
|
||||
sort_by: ['done', 'id'],
|
||||
order_by: ['asc', 'desc'],
|
||||
filter_by: ['done'],
|
||||
filter_value: ['false'],
|
||||
filter_comparator: ['equals'],
|
||||
filter_concat: 'and',
|
||||
},
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
dateFrom: 'buildTheGanttChart',
|
||||
dateTo: 'buildTheGanttChart',
|
||||
listId: 'parseTasks',
|
||||
},
|
||||
mounted() {
|
||||
this.buildTheGanttChart()
|
||||
},
|
||||
computed: mapState(useBaseStore, {
|
||||
canWrite: (state) => state.currentList.maxRight > Rights.READ,
|
||||
}),
|
||||
methods: {
|
||||
colorIsDark,
|
||||
buildTheGanttChart() {
|
||||
this.setDates()
|
||||
this.prepareGanttDays()
|
||||
this.parseTasks()
|
||||
},
|
||||
setDates() {
|
||||
this.startDate = new Date(this.dateFrom)
|
||||
this.endDate = new Date(this.dateTo)
|
||||
console.debug('setDates; start date: ', this.startDate, 'end date:', this.endDate, 'date from:', this.dateFrom, 'date to:', this.dateTo)
|
||||
|
||||
this.dayOffsetUntilToday = Math.floor((this.now - this.startDate) / 1000 / 60 / 60 / 24) + 1
|
||||
},
|
||||
prepareGanttDays() {
|
||||
console.debug('prepareGanttDays; start date: ', this.startDate, 'end date:', this.endDate)
|
||||
// Layout: years => [months => [days]]
|
||||
const years = {}
|
||||
for (
|
||||
let d = this.startDate;
|
||||
d <= this.endDate;
|
||||
d.setDate(d.getDate() + 1)
|
||||
) {
|
||||
const date = new Date(d)
|
||||
if (years[date.getFullYear() + ''] === undefined) {
|
||||
years[date.getFullYear() + ''] = {}
|
||||
}
|
||||
if (years[date.getFullYear() + ''][date.getMonth() + ''] === undefined) {
|
||||
years[date.getFullYear() + ''][date.getMonth() + ''] = []
|
||||
}
|
||||
years[date.getFullYear() + ''][date.getMonth() + ''].push(date)
|
||||
this.fullWidth += this.dayWidth
|
||||
}
|
||||
console.debug('prepareGanttDays; years:', years)
|
||||
this.days = years
|
||||
},
|
||||
|
||||
parseTasks() {
|
||||
this.setDates()
|
||||
this.loadTasks()
|
||||
},
|
||||
|
||||
async loadTasks() {
|
||||
this.theTasks = []
|
||||
this.tasksWithoutDates = []
|
||||
|
||||
const getAllTasks = async (page = 1) => {
|
||||
const tasks = await this.taskCollectionService.getAll({listId: this.listId}, this.params, page)
|
||||
if (page < this.taskCollectionService.totalPages) {
|
||||
const nextTasks = await getAllTasks(page + 1)
|
||||
return tasks.concat(nextTasks)
|
||||
}
|
||||
return tasks
|
||||
}
|
||||
|
||||
const tasks = await getAllTasks()
|
||||
this.theTasks = tasks
|
||||
.filter((t) => {
|
||||
if (t.startDate === null && !t.done) {
|
||||
this.tasksWithoutDates.push(t)
|
||||
}
|
||||
return (
|
||||
t.startDate >= this.startDate &&
|
||||
t.endDate <= this.endDate
|
||||
)
|
||||
})
|
||||
.map((t) => this.addGantAttributes(t))
|
||||
.sort(function (a, b) {
|
||||
if (a.startDate < b.startDate) return -1
|
||||
if (a.startDate > b.startDate) return 1
|
||||
return 0
|
||||
})
|
||||
},
|
||||
addGantAttributes(t) {
|
||||
if (typeof t.durationDays !== 'undefined' && typeof t.offsetDays !== 'undefined') {
|
||||
return t
|
||||
}
|
||||
|
||||
t.endDate === null ? this.endDate : t.endDate
|
||||
t.durationDays = Math.floor((t.endDate - t.startDate) / 1000 / 60 / 60 / 24)
|
||||
t.offsetDays = Math.floor((t.startDate - this.startDate) / 1000 / 60 / 60 / 24)
|
||||
return t
|
||||
},
|
||||
async resizeTask(taskDragged, newRect) {
|
||||
if (this.isTaskEdit) {
|
||||
return
|
||||
}
|
||||
|
||||
let newTask = {...taskDragged}
|
||||
|
||||
const didntHaveDates = newTask.startDate === null ? true : false
|
||||
|
||||
const startDate = new Date(this.startDate)
|
||||
startDate.setDate(
|
||||
startDate.getDate() + newRect.left / this.dayWidth,
|
||||
)
|
||||
startDate.setUTCHours(0)
|
||||
startDate.setUTCMinutes(0)
|
||||
startDate.setUTCSeconds(0)
|
||||
startDate.setUTCMilliseconds(0)
|
||||
newTask.startDate = startDate
|
||||
const endDate = new Date(startDate)
|
||||
endDate.setDate(
|
||||
startDate.getDate() + newRect.width / this.dayWidth,
|
||||
)
|
||||
newTask.startDate = startDate
|
||||
newTask.endDate = endDate
|
||||
|
||||
// We take the task from the overall tasks array because the one in it has bad data after it was updated once.
|
||||
// FIXME: This is a workaround. We should use a better mechanism to get the task or, even better,
|
||||
// prevent it from containing outdated Data in the first place.
|
||||
for (const tt in this.theTasks) {
|
||||
if (this.theTasks[tt].id === newTask.id) {
|
||||
newTask = this.theTasks[tt]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const ganttData = {
|
||||
endDate: newTask.endDate,
|
||||
durationDays: newTask.durationDays,
|
||||
offsetDays: newTask.offsetDays,
|
||||
}
|
||||
|
||||
const r = await this.taskService.update(newTask)
|
||||
r.endDate = ganttData.endDate
|
||||
r.durationDays = ganttData.durationDays
|
||||
r.offsetDays = ganttData.offsetDays
|
||||
|
||||
// If the task didn't have dates before, we'll update the list
|
||||
if (didntHaveDates) {
|
||||
for (const t in this.tasksWithoutDates) {
|
||||
if (this.tasksWithoutDates[t].id === r.id) {
|
||||
this.tasksWithoutDates.splice(t, 1)
|
||||
break
|
||||
}
|
||||
}
|
||||
this.theTasks.push(this.addGantAttributes(r))
|
||||
} else {
|
||||
for (const tt in this.theTasks) {
|
||||
if (this.theTasks[tt].id === r.id) {
|
||||
this.theTasks[tt] = this.addGantAttributes(r)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
editTask(task) {
|
||||
this.taskToEdit = task
|
||||
this.isTaskEdit = true
|
||||
},
|
||||
showCreateNewTask() {
|
||||
if (!this.newTaskFieldActive) {
|
||||
// Timeout to not send the form if the field isn't even shown
|
||||
setTimeout(() => {
|
||||
this.newTaskFieldActive = true
|
||||
this.$nextTick(() => this.$refs.newTaskTitleField.focus())
|
||||
}, 100)
|
||||
}
|
||||
},
|
||||
hideCrateNewTask() {
|
||||
if (this.newTaskTitle === '') {
|
||||
this.$nextTick(() => (this.newTaskFieldActive = false))
|
||||
}
|
||||
},
|
||||
async addNewTask() {
|
||||
if (!this.newTaskFieldActive) {
|
||||
return
|
||||
}
|
||||
const task = new TaskModel({
|
||||
title: this.newTaskTitle,
|
||||
listId: this.listId,
|
||||
})
|
||||
const r = await this.taskService.create(task)
|
||||
this.tasksWithoutDates.push(this.addGantAttributes(r))
|
||||
this.newTaskTitle = ''
|
||||
this.hideCrateNewTask()
|
||||
},
|
||||
formatMonthAndYear(year, month) {
|
||||
month = month < 10 ? '0' + month : month
|
||||
const date = new Date(`${year}-${month}-01`)
|
||||
return formatDate(date, 'MMMM, yyyy')
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
$gantt-border: 1px solid var(--grey-200);
|
||||
$gantt-vertical-border-color: var(--grey-100);
|
||||
|
||||
.gantt-chart {
|
||||
overflow-x: auto;
|
||||
border-top: 1px solid var(--grey-200);
|
||||
|
||||
.dates {
|
||||
display: flex;
|
||||
text-align: center;
|
||||
|
||||
.months {
|
||||
display: flex;
|
||||
|
||||
.month {
|
||||
padding: 0.5rem 0 0;
|
||||
border-right: $gantt-border;
|
||||
font-family: $vikunja-font;
|
||||
font-weight: bold;
|
||||
|
||||
&:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.days {
|
||||
display: flex;
|
||||
|
||||
.day {
|
||||
padding: 0.5rem 0;
|
||||
font-weight: normal;
|
||||
|
||||
&.today {
|
||||
background: var(--primary);
|
||||
color: var(--white);
|
||||
border-radius: 5px 5px 0 0;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.theday {
|
||||
padding: 0 .5rem;
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.weekday {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tasks {
|
||||
max-width: unset !important;
|
||||
border-top: $gantt-border;
|
||||
|
||||
.row {
|
||||
height: 45px;
|
||||
|
||||
.task {
|
||||
display: inline-block;
|
||||
border: 2px solid var(--primary);
|
||||
font-size: 0.85rem;
|
||||
margin: 0.5rem;
|
||||
border-radius: 6px;
|
||||
padding: 0.25rem 0.5rem;
|
||||
cursor: grab;
|
||||
position: relative;
|
||||
height: 31px !important;
|
||||
|
||||
-webkit-touch-callout: none; // iOS Safari
|
||||
user-select: none; // Non-prefixed version
|
||||
|
||||
&.is-current-edit {
|
||||
border-color: var(--warning) !important;
|
||||
}
|
||||
|
||||
&.has-light-text {
|
||||
color: var(--grey-100);
|
||||
|
||||
&.done span:after {
|
||||
border-top: 1px solid var(--grey-100);
|
||||
}
|
||||
|
||||
.edit-toggle {
|
||||
color: var(--grey-100);
|
||||
}
|
||||
}
|
||||
|
||||
&.has-dark-text {
|
||||
color: var(--text);
|
||||
|
||||
&.done span:after {
|
||||
border-top: 1px solid var(--dark);
|
||||
}
|
||||
|
||||
.edit-toggle {
|
||||
color: var(--text);
|
||||
}
|
||||
}
|
||||
|
||||
&.done span {
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 0;
|
||||
left: 0;
|
||||
top: 57%;
|
||||
}
|
||||
}
|
||||
|
||||
span:not(.high-priority) {
|
||||
max-width: calc(100% - 20px);
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
|
||||
&.has-high-priority {
|
||||
max-width: calc(100% - 90px);
|
||||
}
|
||||
|
||||
&.has-not-so-high-priority {
|
||||
max-width: calc(100% - 70px);
|
||||
}
|
||||
|
||||
&.has-super-high-priority {
|
||||
max-width: calc(100% - 111px);
|
||||
}
|
||||
|
||||
&.icon {
|
||||
width: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.high-priority {
|
||||
margin: 0 0 0 .5rem;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
.edit-toggle {
|
||||
float: right;
|
||||
cursor: pointer;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
&.nodate {
|
||||
border: 2px dashed var(--grey-300);
|
||||
background: var(--grey-100);
|
||||
}
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.taskedit {
|
||||
position: fixed;
|
||||
top: 10vh;
|
||||
right: 10vw;
|
||||
z-index: 5;
|
||||
|
||||
// FIXME: should be an option of the card, e.g. overflow
|
||||
:deep(.card-content) {
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.add-new-task {
|
||||
padding: 1rem .7rem .4rem .7rem;
|
||||
display: flex;
|
||||
max-width: 450px;
|
||||
|
||||
.input {
|
||||
margin-right: .7rem;
|
||||
font-size: .8rem;
|
||||
}
|
||||
|
||||
.button {
|
||||
font-size: .68rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -130,7 +130,7 @@
|
|||
|
||||
<!-- Delete modal -->
|
||||
<modal
|
||||
v-if="attachmentToDelete !== null"
|
||||
:enabled="attachmentToDelete !== null"
|
||||
@close="setAttachmentToDelete(null)"
|
||||
@submit="deleteAttachment()"
|
||||
>
|
||||
|
@ -148,7 +148,7 @@
|
|||
|
||||
<!-- Attachment image modal -->
|
||||
<modal
|
||||
v-if="attachmentImageBlobUrl !== null"
|
||||
:enabled="attachmentImageBlobUrl !== null"
|
||||
@close="attachmentImageBlobUrl = null"
|
||||
>
|
||||
<img :src="attachmentImageBlobUrl" alt=""/>
|
||||
|
@ -165,7 +165,6 @@ import BaseButton from '@/components/base/BaseButton.vue'
|
|||
|
||||
import AttachmentService from '@/services/attachment'
|
||||
import {SUPPORTED_IMAGE_SUFFIX} from '@/models/attachment'
|
||||
import type AttachmentModel from '@/models/attachment'
|
||||
import type {IAttachment} from '@/modelTypes/IAttachment'
|
||||
import type {ITask} from '@/modelTypes/ITask'
|
||||
|
||||
|
@ -227,9 +226,9 @@ function uploadFilesToTask(files: File[] | FileList) {
|
|||
uploadFiles(attachmentService, props.task.id, files)
|
||||
}
|
||||
|
||||
const attachmentToDelete = ref<AttachmentModel | null>(null)
|
||||
const attachmentToDelete = ref<IAttachment | null>(null)
|
||||
|
||||
function setAttachmentToDelete(attachment: AttachmentModel | null) {
|
||||
function setAttachmentToDelete(attachment: IAttachment | null) {
|
||||
attachmentToDelete.value = attachment
|
||||
}
|
||||
|
||||
|
@ -250,7 +249,7 @@ async function deleteAttachment() {
|
|||
|
||||
const attachmentImageBlobUrl = ref<string | null>(null)
|
||||
|
||||
async function viewOrDownload(attachment: AttachmentModel) {
|
||||
async function viewOrDownload(attachment: IAttachment) {
|
||||
if (SUPPORTED_IMAGE_SUFFIX.some((suffix) => attachment.file.name.endsWith(suffix))) {
|
||||
attachmentImageBlobUrl.value = await attachmentService.getBlobUrl(attachment)
|
||||
} else {
|
||||
|
@ -335,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;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -433,6 +432,4 @@ async function setCoverImage(attachment: IAttachment | null) {
|
|||
border-radius: 4px;
|
||||
font-size: .75rem;
|
||||
}
|
||||
|
||||
@include modal-transition();
|
||||
</style>
|
|
@ -49,14 +49,12 @@ const label = computed(() => {
|
|||
align-items: center;
|
||||
padding-left: .5rem;
|
||||
font-size: .9rem;
|
||||
|
||||
}
|
||||
|
||||
svg {
|
||||
transform: rotate(-90deg);
|
||||
transition: stroke-dashoffset 0.35s;
|
||||
margin-right: .25rem;
|
||||
|
||||
}
|
||||
|
||||
circle {
|
||||
|
|
|
@ -43,7 +43,7 @@
|
|||
>
|
||||
· {{ $t('task.comment.edited', {date: formatDateSince(c.updated)}) }}
|
||||
</span>
|
||||
<transition name="fade">
|
||||
<CustomTransition name="fade">
|
||||
<span
|
||||
class="is-inline-flex"
|
||||
v-if="
|
||||
|
@ -63,7 +63,7 @@
|
|||
>
|
||||
{{ $t('misc.saved') }}
|
||||
</span>
|
||||
</transition>
|
||||
</CustomTransition>
|
||||
</div>
|
||||
<editor
|
||||
:hasPreview="true"
|
||||
|
@ -94,15 +94,15 @@
|
|||
</figure>
|
||||
<div class="media-content">
|
||||
<div class="form">
|
||||
<transition name="fade">
|
||||
<CustomTransition name="fade">
|
||||
<span
|
||||
class="is-inline-flex"
|
||||
v-if="taskCommentService.loading && creating"
|
||||
class="is-inline-flex"
|
||||
>
|
||||
<span class="loader is-inline-block mr-2"></span>
|
||||
{{ $t('task.comment.creating') }}
|
||||
</span>
|
||||
</transition>
|
||||
</CustomTransition>
|
||||
<div class="field">
|
||||
<editor
|
||||
:class="{
|
||||
|
@ -132,22 +132,20 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<transition name="modal">
|
||||
<modal
|
||||
v-if="showDeleteModal"
|
||||
@close="showDeleteModal = false"
|
||||
@submit="() => deleteComment(commentToDelete)"
|
||||
>
|
||||
<template #header><span>{{ $t('task.comment.delete') }}</span></template>
|
||||
<modal
|
||||
:enabled="showDeleteModal"
|
||||
@close="showDeleteModal = false"
|
||||
@submit="() => deleteComment(commentToDelete)"
|
||||
>
|
||||
<template #header><span>{{ $t('task.comment.delete') }}</span></template>
|
||||
|
||||
<template #text>
|
||||
<p>
|
||||
{{ $t('task.comment.deleteText1') }}<br/>
|
||||
<strong class="has-text-white">{{ $t('misc.cannotBeUndone') }}</strong>
|
||||
</p>
|
||||
</template>
|
||||
</modal>
|
||||
</transition>
|
||||
<template #text>
|
||||
<p>
|
||||
{{ $t('task.comment.deleteText1') }}<br/>
|
||||
<strong class="has-text-white">{{ $t('misc.cannotBeUndone') }}</strong>
|
||||
</p>
|
||||
</template>
|
||||
</modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -155,6 +153,7 @@
|
|||
import {ref, reactive, computed, shallowReactive, watch, nextTick} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
import CustomTransition from '@/components/misc/CustomTransition.vue'
|
||||
import Editor from '@/components/input/AsyncEditor'
|
||||
|
||||
import TaskCommentService from '@/services/taskComment'
|
||||
|
@ -348,9 +347,11 @@ async function deleteComment(commentToDelete: ITaskComment) {
|
|||
}
|
||||
}
|
||||
|
||||
.image.is-avatar {
|
||||
border-radius: 100%;
|
||||
}
|
||||
|
||||
.media-content {
|
||||
width: calc(100% - 48px - 2rem);
|
||||
}
|
||||
|
||||
@include modal-transition();
|
||||
</style>
|
|
@ -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>
|
|
@ -61,8 +61,8 @@ const taskService = shallowReactive(new TaskService())
|
|||
const task = ref<ITask>()
|
||||
|
||||
// We're saving the due date seperately to prevent null errors in very short periods where the task is null.
|
||||
const dueDate = ref<Date>()
|
||||
const lastValue = ref<Date>()
|
||||
const dueDate = ref<Date | null>()
|
||||
const lastValue = ref<Date | null>()
|
||||
const changeInterval = ref<ReturnType<typeof setInterval>>()
|
||||
|
||||
watch(
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
<icon icon="align-left"/>
|
||||
</span>
|
||||
{{ $t('task.attributes.description') }}
|
||||
<transition name="fade">
|
||||
<CustomTransition name="fade">
|
||||
<span class="is-small is-inline-flex" v-if="loading && saving">
|
||||
<span class="loader is-inline-block mr-2"></span>
|
||||
{{ $t('misc.saving') }}
|
||||
|
@ -14,7 +14,7 @@
|
|||
<icon icon="check"/>
|
||||
{{ $t('misc.saved') }}
|
||||
</span>
|
||||
</transition>
|
||||
</CustomTransition>
|
||||
</h3>
|
||||
<editor
|
||||
:is-edit-enabled="canWrite"
|
||||
|
@ -33,6 +33,7 @@
|
|||
<script setup lang="ts">
|
||||
import {ref,computed, watch, type PropType} from 'vue'
|
||||
|
||||
import CustomTransition from '@/components/misc/CustomTransition.vue'
|
||||
import Editor from '@/components/input/AsyncEditor'
|
||||
|
||||
import type {ITask} from '@/modelTypes/ITask'
|
||||
|
|
|
@ -1,30 +1,24 @@
|
|||
<template>
|
||||
<div
|
||||
tabindex="-1"
|
||||
@focus="focus"
|
||||
<Multiselect
|
||||
:loading="listUserService.loading"
|
||||
:placeholder="$t('task.assignee.placeholder')"
|
||||
:multiple="true"
|
||||
@search="findUser"
|
||||
:search-results="foundUsers"
|
||||
@select="addAssignee"
|
||||
label="name"
|
||||
:select-placeholder="$t('task.assignee.selectPlaceholder')"
|
||||
v-model="assignees"
|
||||
>
|
||||
<Multiselect
|
||||
:loading="listUserService.loading"
|
||||
:placeholder="$t('task.assignee.placeholder')"
|
||||
:multiple="true"
|
||||
@search="findUser"
|
||||
:search-results="foundUsers"
|
||||
@select="addAssignee"
|
||||
label="name"
|
||||
:select-placeholder="$t('task.assignee.selectPlaceholder')"
|
||||
v-model="assignees"
|
||||
ref="multiselect"
|
||||
>
|
||||
<template #tag="{item: user}">
|
||||
<span class="assignee">
|
||||
<user :avatar-size="32" :show-username="false" :user="user"/>
|
||||
<BaseButton @click="removeAssignee(user)" class="remove-assignee" v-if="!disabled">
|
||||
<icon icon="times"/>
|
||||
</BaseButton>
|
||||
</span>
|
||||
</template>
|
||||
</Multiselect>
|
||||
</div>
|
||||
<template #tag="{item: user}">
|
||||
<span class="assignee">
|
||||
<user :avatar-size="32" :show-username="false" :user="user"/>
|
||||
<BaseButton @click="removeAssignee(user)" class="remove-assignee" v-if="!disabled">
|
||||
<icon icon="times"/>
|
||||
</BaseButton>
|
||||
</span>
|
||||
</template>
|
||||
</Multiselect>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
@ -41,6 +35,7 @@ import {success} from '@/message'
|
|||
import {useTaskStore} from '@/stores/tasks'
|
||||
|
||||
import type {IUser} from '@/modelTypes/IUser'
|
||||
import {getDisplayName} from '@/models/user'
|
||||
|
||||
const props = defineProps({
|
||||
taskId: {
|
||||
|
@ -65,7 +60,7 @@ const taskStore = useTaskStore()
|
|||
const {t} = useI18n({useScope: 'global'})
|
||||
|
||||
const listUserService = shallowReactive(new ListUserService())
|
||||
const foundUsers = ref([])
|
||||
const foundUsers = ref<IUser[]>([])
|
||||
const assignees = ref<IUser[]>([])
|
||||
let isAdding = false
|
||||
|
||||
|
@ -110,30 +105,21 @@ async function removeAssignee(user: IUser) {
|
|||
|
||||
async function findUser(query: string) {
|
||||
if (query === '') {
|
||||
clearAllFoundUsers()
|
||||
foundUsers.value = []
|
||||
return
|
||||
}
|
||||
|
||||
const response = await listUserService.getAll({listId: props.listId}, {s: query})
|
||||
const response = await listUserService.getAll({listId: props.listId}, {s: query}) as IUser[]
|
||||
|
||||
// Filter the results to not include users who are already assigned
|
||||
foundUsers.value = response.filter(({id}) => !includesById(assignees.value, id))
|
||||
foundUsers.value = response
|
||||
.filter(({id}) => !includesById(assignees.value, id))
|
||||
.map(u => {
|
||||
// Users may not have a display name set, so we fall back on the username in that case
|
||||
u.name = u.name === '' ? u.username : u.name
|
||||
u.name = getDisplayName(u)
|
||||
return u
|
||||
})
|
||||
}
|
||||
|
||||
function clearAllFoundUsers() {
|
||||
foundUsers.value = []
|
||||
}
|
||||
|
||||
const multiselect = ref()
|
||||
|
||||
function focus() {
|
||||
multiselect.value.focus()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
@ -149,19 +135,20 @@ function focus() {
|
|||
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>
|
|
@ -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)
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
<Done class="heading__done" :is-done="task.done"/>
|
||||
<ColorBubble
|
||||
v-if="task.hexColor !== ''"
|
||||
:color="task.getHexColor()"
|
||||
:color="getHexColor(task.hexColor)"
|
||||
class="mt-1 ml-2"
|
||||
/>
|
||||
<h1
|
||||
|
@ -17,7 +17,7 @@
|
|||
>
|
||||
{{ task.title.trim() }}
|
||||
</h1>
|
||||
<transition name="fade">
|
||||
<CustomTransition name="fade">
|
||||
<span
|
||||
v-if="loading && saving"
|
||||
class="is-inline-flex is-align-items-center"
|
||||
|
@ -32,7 +32,7 @@
|
|||
<icon icon="check" class="mr-2"/>
|
||||
{{ $t('misc.saved') }}
|
||||
</span>
|
||||
</transition>
|
||||
</CustomTransition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -41,6 +41,7 @@ import {ref, computed, type PropType} from 'vue'
|
|||
import {useRouter} from 'vue-router'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import CustomTransition from '@/components/misc/CustomTransition.vue'
|
||||
import ColorBubble from '@/components/misc/colorBubble.vue'
|
||||
import Done from '@/components/misc/Done.vue'
|
||||
|
||||
|
@ -48,6 +49,7 @@ import {useCopyToClipboard} from '@/composables/useCopyToClipboard'
|
|||
import {useTaskStore} from '@/stores/tasks'
|
||||
|
||||
import type {ITask} from '@/modelTypes/ITask'
|
||||
import {getHexColor} from '@/models/task'
|
||||
|
||||
const props = defineProps({
|
||||
task: {
|
||||
|
@ -84,9 +86,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
|
||||
}
|
||||
|
@ -109,6 +109,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;
|
||||
}
|
||||
|
|
|
@ -2,16 +2,17 @@
|
|||
<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="props">
|
||||
<span class="list-namespace-title search-result">{{ namespace(props.option.namespaceId) }} ></span>
|
||||
{{ props.option.title }}
|
||||
<template #searchResult="{option}">
|
||||
<span class="list-namespace-title search-result">{{ namespace((option as IList).namespaceId) }} ></span>
|
||||
{{ (option as IList).title }}
|
||||
</template>
|
||||
</Multiselect>
|
||||
</template>
|
||||
|
@ -20,12 +21,17 @@
|
|||
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 ListModel from '@/models/list'
|
||||
|
||||
import Multiselect from '@/components/input/multiselect.vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Object as PropType<IList>,
|
||||
|
@ -65,7 +71,7 @@ function select(l: IList | null) {
|
|||
emit('update:modelValue', list)
|
||||
}
|
||||
|
||||
function namespace(namespaceId: number) {
|
||||
function namespace(namespaceId: INamespace['id']) {
|
||||
const namespace = namespaceStore.getNamespaceById(namespaceId)
|
||||
return namespace !== null
|
||||
? namespace.title
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -5,8 +5,8 @@
|
|||
<ButtonLink @click="() => visible = true">{{ $t('task.quickAddMagic.what') }}</ButtonLink>
|
||||
</p>
|
||||
<modal
|
||||
@close="() => visible = false"
|
||||
:enabled="visible"
|
||||
@close="() => visible = false"
|
||||
transition-name="fade"
|
||||
:overflow="true"
|
||||
variant="hint-modal"
|
||||
|
|
|
@ -14,7 +14,7 @@
|
|||
<template v-if="editEnabled && showCreate">
|
||||
<label class="label" key="label">
|
||||
{{ $t('task.relation.new') }}
|
||||
<transition name="fade">
|
||||
<CustomTransition name="fade">
|
||||
<span class="is-inline-flex" v-if="taskRelationService.loading">
|
||||
<span class="loader is-inline-block mr-2"></span>
|
||||
{{ $t('misc.saving') }}
|
||||
|
@ -22,7 +22,7 @@
|
|||
<span class="has-text-success" v-else-if="!taskRelationService.loading && saved">
|
||||
{{ $t('misc.saved') }}
|
||||
</span>
|
||||
</transition>
|
||||
</CustomTransition>
|
||||
</label>
|
||||
<div class="field" key="field-search">
|
||||
<Multiselect
|
||||
|
@ -133,7 +133,7 @@
|
|||
</p>
|
||||
|
||||
<modal
|
||||
v-if="relationToDelete !== undefined"
|
||||
:enabled="relationToDelete !== undefined"
|
||||
@close="relationToDelete = undefined"
|
||||
@submit="removeTaskRelation()"
|
||||
>
|
||||
|
@ -163,6 +163,7 @@ import {RELATION_KINDS, RELATION_KIND, type IRelationKind} from '@/types/IRelati
|
|||
import TaskRelationService from '@/services/taskRelation'
|
||||
import TaskRelationModel from '@/models/taskRelation'
|
||||
|
||||
import CustomTransition from '@/components/misc/CustomTransition.vue'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import Multiselect from '@/components/input/multiselect.vue'
|
||||
import Fancycheckbox from '@/components/input/fancycheckbox.vue'
|
||||
|
@ -442,6 +443,4 @@ async function toggleTaskDone(task: ITask) {
|
|||
padding: 0;
|
||||
height: 18px; // The exact height of the checkbox in the container
|
||||
}
|
||||
|
||||
@include modal-transition();
|
||||
</style>
|
|
@ -1,30 +1,38 @@
|
|||
<template>
|
||||
<div :class="{'is-loading': taskService.loading}" class="task loader-container">
|
||||
<fancycheckbox :disabled="(isArchived || disabled) && !canMarkAsDone" @change="markAsDone" v-model="task.done"/>
|
||||
<fancycheckbox
|
||||
:disabled="(isArchived || disabled) && !canMarkAsDone"
|
||||
@change="markAsDone"
|
||||
v-model="task.done"
|
||||
/>
|
||||
|
||||
<ColorBubble
|
||||
v-if="showListColor && listColor !== ''"
|
||||
:color="listColor"
|
||||
class="mr-1"
|
||||
/>
|
||||
|
||||
<router-link
|
||||
:to="taskDetailRoute"
|
||||
:class="{ 'done': task.done}"
|
||||
class="tasktext">
|
||||
:class="{ 'done': task.done, 'show-list': showList && taskList !== null}"
|
||||
class="tasktext"
|
||||
>
|
||||
<span>
|
||||
<router-link
|
||||
v-if="showList && taskList !== null"
|
||||
:to="{ name: 'list.list', params: { listId: task.listId } }"
|
||||
class="task-list"
|
||||
:class="{'mr-2': task.hexColor !== ''}"
|
||||
v-if="showList && getListById(task.listId) !== null"
|
||||
v-tooltip="$t('task.detail.belongsToList', {list: getListById(task.listId).title})">
|
||||
{{ getListById(task.listId).title }}
|
||||
v-tooltip="$t('task.detail.belongsToList', {list: taskList.title})">
|
||||
{{ taskList.title }}
|
||||
</router-link>
|
||||
|
||||
<ColorBubble
|
||||
v-if="task.hexColor !== ''"
|
||||
:color="task.getHexColor()"
|
||||
:color="getHexColor(task.hexColor)"
|
||||
class="mr-1"
|
||||
/>
|
||||
|
||||
<!-- Show any parent tasks to make it clear this task is a sub task of something -->
|
||||
<span class="parent-tasks" v-if="typeof task.relatedTasks.parenttask !== 'undefined'">
|
||||
<template v-for="(pt, i) in task.relatedTasks.parenttask">
|
||||
|
@ -35,15 +43,22 @@
|
|||
{{ task.title }}
|
||||
</span>
|
||||
|
||||
<labels class="labels ml-2 mr-1" :labels="task.labels" v-if="task.labels.length > 0" />
|
||||
<user
|
||||
<labels
|
||||
v-if="task.labels.length > 0"
|
||||
class="labels ml-2 mr-1"
|
||||
:labels="task.labels"
|
||||
/>
|
||||
|
||||
<User
|
||||
v-for="(a, i) in task.assignees"
|
||||
:avatar-size="27"
|
||||
:is-inline="true"
|
||||
:key="task.id + 'assignee' + a.id + i"
|
||||
:show-username="false"
|
||||
:user="a"
|
||||
v-for="(a, i) in task.assignees"
|
||||
/>
|
||||
|
||||
<!-- FIXME: use popup -->
|
||||
<BaseButton
|
||||
v-if="+new Date(task.dueDate) > 0"
|
||||
class="dueDate"
|
||||
|
@ -59,10 +74,12 @@
|
|||
- {{ $t('task.detail.due', {at: formatDateSince(task.dueDate)}) }}
|
||||
</time>
|
||||
</BaseButton>
|
||||
<transition name="fade">
|
||||
<CustomTransition name="fade">
|
||||
<defer-task v-if="+new Date(task.dueDate) > 0 && showDefer" v-model="task" ref="deferDueDate"/>
|
||||
</transition>
|
||||
</CustomTransition>
|
||||
|
||||
<priority-label :priority="task.priority" :done="task.done"/>
|
||||
|
||||
<span>
|
||||
<span class="list-task-icon" v-if="task.attachments.length > 0">
|
||||
<icon icon="paperclip"/>
|
||||
|
@ -74,184 +91,187 @@
|
|||
<icon icon="history"/>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<checklist-summary :task="task"/>
|
||||
</router-link>
|
||||
|
||||
<progress
|
||||
class="progress is-small"
|
||||
v-if="task.percentDone > 0"
|
||||
:value="task.percentDone * 100" max="100">
|
||||
:value="task.percentDone * 100" max="100"
|
||||
>
|
||||
{{ task.percentDone * 100 }}%
|
||||
</progress>
|
||||
|
||||
<router-link
|
||||
v-if="!showList && currentList.id !== task.listId && taskList !== null"
|
||||
:to="{ name: 'list.list', params: { listId: task.listId } }"
|
||||
class="task-list"
|
||||
v-if="!showList && currentList.id !== task.listId && getListById(task.listId) !== null"
|
||||
v-tooltip="$t('task.detail.belongsToList', {list: getListById(task.listId).title})">
|
||||
{{ getListById(task.listId).title }}
|
||||
v-tooltip="$t('task.detail.belongsToList', {list: taskList.title})"
|
||||
>
|
||||
{{ taskList.title }}
|
||||
</router-link>
|
||||
|
||||
<BaseButton
|
||||
:class="{'is-favorite': task.isFavorite}"
|
||||
@click="toggleFavorite"
|
||||
class="favorite">
|
||||
class="favorite"
|
||||
>
|
||||
<icon icon="star" v-if="task.isFavorite"/>
|
||||
<icon :icon="['far', 'star']" v-else/>
|
||||
</BaseButton>
|
||||
<slot></slot>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {defineComponent, type PropType} from 'vue'
|
||||
import {mapState} from 'pinia'
|
||||
<script setup lang="ts">
|
||||
import {ref, watch, shallowReactive, toRef, type PropType, onMounted, onBeforeUnmount, computed} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
import TaskModel from '@/models/task'
|
||||
import TaskModel, { getHexColor } from '@/models/task'
|
||||
import type {ITask} from '@/modelTypes/ITask'
|
||||
import PriorityLabel from './priorityLabel.vue'
|
||||
import TaskService from '../../../services/task'
|
||||
import Labels from '@/components/tasks/partials/labels.vue'
|
||||
|
||||
import PriorityLabel from '@/components/tasks/partials/priorityLabel.vue'
|
||||
import Labels from '@/components/tasks/partials//labels.vue'
|
||||
import DeferTask from '@/components/tasks/partials//defer-task.vue'
|
||||
import ChecklistSummary from '@/components/tasks/partials/checklist-summary.vue'
|
||||
|
||||
import User from '@/components/misc/user.vue'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import Fancycheckbox from '../../input/fancycheckbox.vue'
|
||||
import DeferTask from './defer-task.vue'
|
||||
import {closeWhenClickedOutside} from '@/helpers/closeWhenClickedOutside'
|
||||
import ChecklistSummary from './checklist-summary.vue'
|
||||
import {formatDateSince, formatISO, formatDateLong} from '@/helpers/time/formatDate'
|
||||
import Fancycheckbox from '@/components/input/fancycheckbox.vue'
|
||||
import ColorBubble from '@/components/misc/colorBubble.vue'
|
||||
import CustomTransition from '@/components/misc/CustomTransition.vue'
|
||||
|
||||
import TaskService from '@/services/task'
|
||||
|
||||
import {closeWhenClickedOutside} from '@/helpers/closeWhenClickedOutside'
|
||||
import {formatDateSince, formatISO, formatDateLong} from '@/helpers/time/formatDate'
|
||||
import {success} from '@/message'
|
||||
|
||||
import {useListStore} from '@/stores/lists'
|
||||
import {useNamespaceStore} from '@/stores/namespaces'
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
import {useTaskStore} from '@/stores/tasks'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'singleTaskInList',
|
||||
data() {
|
||||
return {
|
||||
taskService: new TaskService(),
|
||||
task: new TaskModel(),
|
||||
showDefer: false,
|
||||
}
|
||||
const props = defineProps({
|
||||
theTask: {
|
||||
type: Object as PropType<ITask>,
|
||||
required: true,
|
||||
},
|
||||
components: {
|
||||
ColorBubble,
|
||||
BaseButton,
|
||||
ChecklistSummary,
|
||||
DeferTask,
|
||||
Fancycheckbox,
|
||||
User,
|
||||
Labels,
|
||||
PriorityLabel,
|
||||
isArchived: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
props: {
|
||||
theTask: {
|
||||
type: Object as PropType<ITask>,
|
||||
required: true,
|
||||
},
|
||||
isArchived: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showList: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showListColor: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
canMarkAsDone: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showList: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
emits: ['task-updated'],
|
||||
watch: {
|
||||
theTask(newVal) {
|
||||
this.task = newVal
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
mounted() {
|
||||
this.task = this.theTask
|
||||
document.addEventListener('click', this.hideDeferDueDatePopup)
|
||||
showListColor: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
beforeUnmount() {
|
||||
document.removeEventListener('click', this.hideDeferDueDatePopup)
|
||||
},
|
||||
computed: {
|
||||
...mapState(useListStore, {
|
||||
getListById: 'getListById',
|
||||
}),
|
||||
listColor() {
|
||||
const list = this.getListById(this.task.listId)
|
||||
return list !== null ? list.hexColor : ''
|
||||
},
|
||||
currentList() {
|
||||
const baseStore = useBaseStore()
|
||||
return typeof baseStore.currentList === 'undefined' ? {
|
||||
id: 0,
|
||||
title: '',
|
||||
} : baseStore.currentList
|
||||
},
|
||||
taskDetailRoute() {
|
||||
return {
|
||||
name: 'task.detail',
|
||||
params: {id: this.task.id},
|
||||
// TODO: re-enable opening task detail in modal
|
||||
// state: { backdropView: this.$router.currentRoute.value.fullPath },
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
formatDateSince,
|
||||
formatISO,
|
||||
formatDateLong,
|
||||
|
||||
async markAsDone(checked: boolean) {
|
||||
const updateFunc = async () => {
|
||||
const task = await useTaskStore().update(this.task)
|
||||
this.task = task
|
||||
this.$emit('task-updated', task)
|
||||
this.$message.success({
|
||||
message: this.task.done ?
|
||||
this.$t('task.doneSuccess') :
|
||||
this.$t('task.undoneSuccess'),
|
||||
}, [{
|
||||
title: 'Undo',
|
||||
callback: () => this.undoDone(checked),
|
||||
}])
|
||||
}
|
||||
|
||||
if (checked) {
|
||||
setTimeout(updateFunc, 300) // Delay it to show the animation when marking a task as done
|
||||
} else {
|
||||
await updateFunc() // Don't delay it when un-marking it as it doesn't have an animation the other way around
|
||||
}
|
||||
},
|
||||
|
||||
undoDone(checked: boolean) {
|
||||
this.task.done = !this.task.done
|
||||
this.markAsDone(!checked)
|
||||
},
|
||||
|
||||
async toggleFavorite() {
|
||||
this.task.isFavorite = !this.task.isFavorite
|
||||
this.task = await this.taskService.update(this.task)
|
||||
this.$emit('task-updated', this.task)
|
||||
useNamespaceStore().loadNamespacesIfFavoritesDontExist()
|
||||
},
|
||||
hideDeferDueDatePopup(e) {
|
||||
if (!this.showDefer) {
|
||||
return
|
||||
}
|
||||
closeWhenClickedOutside(e, this.$refs.deferDueDate.$el, () => {
|
||||
this.showDefer = false
|
||||
})
|
||||
},
|
||||
canMarkAsDone: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['task-updated'])
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
|
||||
const taskService = shallowReactive(new TaskService())
|
||||
const task = ref<ITask>(new TaskModel())
|
||||
const showDefer = ref(false)
|
||||
|
||||
const theTask = toRef(props, 'theTask')
|
||||
|
||||
watch(
|
||||
theTask,
|
||||
newVal => {
|
||||
task.value = newVal
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
task.value = theTask.value
|
||||
document.addEventListener('click', hideDeferDueDatePopup)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', hideDeferDueDatePopup)
|
||||
})
|
||||
|
||||
const baseStore = useBaseStore()
|
||||
const listStore = useListStore()
|
||||
const taskStore = useTaskStore()
|
||||
const namespaceStore = useNamespaceStore()
|
||||
|
||||
const taskList = computed(() => listStore.getListById(task.value.listId))
|
||||
const listColor = computed(() => taskList.value !== null ? taskList.value.hexColor : '')
|
||||
|
||||
const currentList = computed(() => {
|
||||
return typeof baseStore.currentList === 'undefined' ? {
|
||||
id: 0,
|
||||
title: '',
|
||||
} : baseStore.currentList
|
||||
})
|
||||
|
||||
const taskDetailRoute = computed(() => ({
|
||||
name: 'task.detail',
|
||||
params: {id: task.value.id},
|
||||
// TODO: re-enable opening task detail in modal
|
||||
// state: { backdropView: router.currentRoute.value.fullPath },
|
||||
}))
|
||||
|
||||
|
||||
async function markAsDone(checked: boolean) {
|
||||
const updateFunc = async () => {
|
||||
const newTask = await taskStore.update(task.value)
|
||||
task.value = newTask
|
||||
emit('task-updated', newTask)
|
||||
success({
|
||||
message: task.value.done ?
|
||||
t('task.doneSuccess') :
|
||||
t('task.undoneSuccess'),
|
||||
}, [{
|
||||
title: 'Undo',
|
||||
callback: () => undoDone(checked),
|
||||
}])
|
||||
}
|
||||
|
||||
if (checked) {
|
||||
setTimeout(updateFunc, 300) // Delay it to show the animation when marking a task as done
|
||||
} else {
|
||||
await updateFunc() // Don't delay it when un-marking it as it doesn't have an animation the other way around
|
||||
}
|
||||
}
|
||||
|
||||
function undoDone(checked: boolean) {
|
||||
task.value.done = !task.value.done
|
||||
markAsDone(!checked)
|
||||
}
|
||||
|
||||
async function toggleFavorite() {
|
||||
task.value.isFavorite = !task.value.isFavorite
|
||||
task.value = await taskService.update(task.value)
|
||||
emit('task-updated', task.value)
|
||||
namespaceStore.loadNamespacesIfFavoritesDontExist()
|
||||
}
|
||||
|
||||
const deferDueDate = ref<typeof DeferTask | null>(null)
|
||||
function hideDeferDueDatePopup(e) {
|
||||
if (!showDefer.value) {
|
||||
return
|
||||
}
|
||||
closeWhenClickedOutside(e, deferDueDate.value.$el, () => {
|
||||
showDefer.value = false
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
@ -371,6 +391,10 @@ export default defineComponent({
|
|||
width: auto;
|
||||
}
|
||||
|
||||
.show-list .parent-tasks {
|
||||
padding-left: .25rem;
|
||||
}
|
||||
|
||||
.remove {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,72 @@
|
|||
import {ref, unref, watch} from 'vue'
|
||||
import {debouncedWatch, tryOnMounted, useWindowSize, type MaybeRef} from '@vueuse/core'
|
||||
|
||||
// TODO: also add related styles
|
||||
// OR: replace with vueuse function
|
||||
export function useAutoHeightTextarea(value: MaybeRef<string>) {
|
||||
const textarea = ref<HTMLInputElement>()
|
||||
const minHeight = ref(0)
|
||||
|
||||
// adapted from https://github.com/LeaVerou/stretchy/blob/47f5f065c733029acccb755cae793009645809e2/src/stretchy.js#L34
|
||||
function resize(textareaEl: HTMLInputElement | undefined) {
|
||||
if (!textareaEl) return
|
||||
|
||||
let empty
|
||||
|
||||
// the value here is the attribute value
|
||||
if (!textareaEl.value && textareaEl.placeholder) {
|
||||
empty = true
|
||||
textareaEl.value = textareaEl.placeholder
|
||||
}
|
||||
|
||||
const cs = getComputedStyle(textareaEl)
|
||||
|
||||
textareaEl.style.minHeight = ''
|
||||
textareaEl.style.height = '0'
|
||||
const offset = textareaEl.offsetHeight - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom)
|
||||
const height = textareaEl.scrollHeight + offset + 'px'
|
||||
|
||||
textareaEl.style.height = height
|
||||
|
||||
// calculate min-height for the first time
|
||||
if (!minHeight.value) {
|
||||
minHeight.value = parseFloat(height)
|
||||
}
|
||||
|
||||
textareaEl.style.minHeight = minHeight.value.toString()
|
||||
|
||||
|
||||
if (empty) {
|
||||
textareaEl.value = ''
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
tryOnMounted(() => {
|
||||
if (textarea.value) {
|
||||
// we don't want scrollbars
|
||||
textarea.value.style.overflowY = 'hidden'
|
||||
}
|
||||
})
|
||||
|
||||
const {width: windowWidth} = useWindowSize()
|
||||
|
||||
debouncedWatch(
|
||||
windowWidth,
|
||||
() => resize(textarea.value),
|
||||
{debounce: 200},
|
||||
)
|
||||
|
||||
// It is not possible to get notified of a change of the value attribute of a textarea without workarounds (setTimeout)
|
||||
// So instead we watch the value that we bound to it.
|
||||
watch(
|
||||
() => [textarea.value, unref(value)],
|
||||
() => resize(textarea.value),
|
||||
{
|
||||
immediate: true, // calculate initial size
|
||||
flush: 'post', // resize after value change is rendered to DOM
|
||||
},
|
||||
)
|
||||
|
||||
return textarea
|
||||
}
|
|
@ -1,31 +0,0 @@
|
|||
import {describe, it, expect} from 'vitest'
|
||||
import {hourToSalutation} from './useDateTimeSalutation'
|
||||
|
||||
const dateWithHour = (hours: number): Date => {
|
||||
const date = new Date()
|
||||
date.setHours(hours)
|
||||
return date
|
||||
}
|
||||
|
||||
describe('Salutation', () => {
|
||||
it('shows the right salutation in the night', () => {
|
||||
const salutation = hourToSalutation(dateWithHour(4))
|
||||
expect(salutation).toBe('home.welcomeNight')
|
||||
})
|
||||
it('shows the right salutation in the morning', () => {
|
||||
const salutation = hourToSalutation(dateWithHour(8))
|
||||
expect(salutation).toBe('home.welcomeMorning')
|
||||
})
|
||||
it('shows the right salutation in the day', () => {
|
||||
const salutation = hourToSalutation(dateWithHour(13))
|
||||
expect(salutation).toBe('home.welcomeDay')
|
||||
})
|
||||
it('shows the right salutation in the night', () => {
|
||||
const salutation = hourToSalutation(dateWithHour(20))
|
||||
expect(salutation).toBe('home.welcomeEvening')
|
||||
})
|
||||
it('shows the right salutation in the night again', () => {
|
||||
const salutation = hourToSalutation(dateWithHour(23))
|
||||
expect(salutation).toBe('home.welcomeNight')
|
||||
})
|
||||
})
|
|
@ -1,31 +0,0 @@
|
|||
import {computed} from 'vue'
|
||||
import {useNow} from '@vueuse/core'
|
||||
|
||||
const TRANSLATION_KEY_PREFIX = 'home.welcome'
|
||||
|
||||
export function hourToSalutation(now: Date) {
|
||||
const hours = now.getHours()
|
||||
|
||||
if (hours < 5) {
|
||||
return `${TRANSLATION_KEY_PREFIX}Night`
|
||||
}
|
||||
|
||||
if (hours < 11) {
|
||||
return `${TRANSLATION_KEY_PREFIX}Morning`
|
||||
}
|
||||
|
||||
if (hours < 18) {
|
||||
return `${TRANSLATION_KEY_PREFIX}Day`
|
||||
}
|
||||
|
||||
if (hours < 23) {
|
||||
return `${TRANSLATION_KEY_PREFIX}Evening`
|
||||
}
|
||||
|
||||
return `${TRANSLATION_KEY_PREFIX}Night`
|
||||
}
|
||||
|
||||
export function useDateTimeSalutation() {
|
||||
const now = useNow()
|
||||
return computed(() => hourToSalutation(now.value))
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
import {computed} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
import {useNow} from '@vueuse/core'
|
||||
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
import {hourToDaytime} from '@/helpers/hourToDaytime'
|
||||
|
||||
export type Daytime = 'night' | 'morning' | 'day' | 'evening'
|
||||
|
||||
export function useDaytimeSalutation() {
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
const now = useNow()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const name = computed(() => authStore.userDisplayName)
|
||||
const daytime = computed(() => hourToDaytime(now.value))
|
||||
|
||||
const salutations = {
|
||||
'night': () => t('home.welcomeNight', {username: name.value}),
|
||||
'morning': () => t('home.welcomeMorning', {username: name.value}),
|
||||
'day': () => t('home.welcomeDay', {username: name.value}),
|
||||
'evening': () => t('home.welcomeEvening', {username: name.value}),
|
||||
} as Record<Daytime, () => string>
|
||||
|
||||
return computed(() => name.value ? salutations[daytime.value]() : undefined)
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
import {useRouter} from 'vue-router'
|
||||
import {getLastVisited, clearLastVisited} from '@/helpers/saveLastVisited'
|
||||
|
||||
export function useRedirectToLastVisited() {
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
function redirectIfSaved() {
|
||||
const last = getLastVisited()
|
||||
if (last !== null) {
|
||||
router.push({
|
||||
name: last.name,
|
||||
params: last.params,
|
||||
query: last.query,
|
||||
})
|
||||
clearLastVisited()
|
||||
return
|
||||
}
|
||||
|
||||
router.push({name: 'home'})
|
||||
}
|
||||
|
||||
return {
|
||||
redirectIfSaved,
|
||||
}
|
||||
}
|
|
@ -3,6 +3,9 @@ import {useRouter} from 'vue-router'
|
|||
import {useEventListener} from '@vueuse/core'
|
||||
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
import {MILLISECONDS_A_HOUR, SECONDS_A_HOUR} from '@/constants/date'
|
||||
|
||||
const SECONDS_TOKEN_VALID = 60 * SECONDS_A_HOUR
|
||||
|
||||
export function useRenewTokenOnFocus() {
|
||||
const router = useRouter()
|
||||
|
@ -16,23 +19,23 @@ export function useRenewTokenOnFocus() {
|
|||
authStore.renewToken()
|
||||
|
||||
// Check if the token is still valid if the window gets focus again to maybe renew it
|
||||
useEventListener('focus', () => {
|
||||
useEventListener('focus', async () => {
|
||||
if (!authenticated.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const expiresIn = (userInfo.value !== null ? userInfo.value.exp : 0) - +new Date() / 1000
|
||||
const expiresIn = (userInfo.value !== null ? userInfo.value.exp : 0) - new Date().valueOf() / MILLISECONDS_A_HOUR
|
||||
|
||||
// If the token expiry is negative, it is already expired and we have no choice but to redirect
|
||||
// the user to the login page
|
||||
if (expiresIn < 0) {
|
||||
authStore.checkAuth()
|
||||
router.push({name: 'user.login'})
|
||||
await authStore.checkAuth()
|
||||
await router.push({name: 'user.login'})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the token is valid for less than 60 hours and renew if thats the case
|
||||
if (expiresIn < 60 * 3600) {
|
||||
if (expiresIn < SECONDS_TOKEN_VALID) {
|
||||
authStore.renewToken()
|
||||
console.debug('renewed token')
|
||||
}
|
||||
|
|
|
@ -0,0 +1,71 @@
|
|||
import {computed, ref, watch, type Ref} from 'vue'
|
||||
import {useRouter, type RouteLocationNormalized, type RouteLocationRaw} from 'vue-router'
|
||||
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,
|
||||
) {
|
||||
const router = useRouter()
|
||||
|
||||
const filters = ref<CurrentFilters>(routeToFilters(route.value))
|
||||
|
||||
const routeFromFiltersFullPath = computed(() => router.resolve(filtersToRoute(filters.value)).fullPath)
|
||||
|
||||
watch(
|
||||
route,
|
||||
(route, oldRoute) => {
|
||||
if (
|
||||
route?.name !== oldRoute?.name ||
|
||||
routeFromFiltersFullPath.value === route.fullPath
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
filters.value = routeToFilters(route)
|
||||
},
|
||||
{
|
||||
immediate: true, // set the filter from the initial route
|
||||
},
|
||||
)
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
|
@ -19,7 +19,7 @@ export function useRouteWithModal() {
|
|||
return
|
||||
}
|
||||
|
||||
// logic from vue-router
|
||||
// this is adapted from vue-router
|
||||
// https://github.com/vuejs/vue-router-next/blob/798cab0d1e21f9b4d45a2bd12b840d2c7415f38a/src/RouterView.ts#L125
|
||||
const routePropsOption = route.matched[0]?.props.default
|
||||
const routeProps = routePropsOption
|
||||
|
@ -28,7 +28,9 @@ export function useRouteWithModal() {
|
|||
: typeof routePropsOption === 'function'
|
||||
? routePropsOption(route)
|
||||
: routePropsOption
|
||||
: null
|
||||
: {}
|
||||
|
||||
routeProps.backdropView = backdropView.value
|
||||
|
||||
const component = route.matched[0]?.components?.default
|
||||
|
||||
|
|
|
@ -2,7 +2,8 @@ import {ref, shallowReactive, watch, computed} from 'vue'
|
|||
import {useRoute} from 'vue-router'
|
||||
|
||||
import TaskCollectionService from '@/services/taskCollection'
|
||||
import type { ITask } from '@/modelTypes/ITask'
|
||||
import type {ITask} from '@/modelTypes/ITask'
|
||||
import {error} from '@/message'
|
||||
|
||||
// FIXME: merge with DEFAULT_PARAMS in filters.vue
|
||||
export const getDefaultParams = () => ({
|
||||
|
@ -18,23 +19,12 @@ const SORT_BY_DEFAULT = {
|
|||
id: 'desc',
|
||||
}
|
||||
|
||||
/**
|
||||
* This mixin provides a base set of methods and properties to get tasks on a list.
|
||||
*/
|
||||
export function useTaskList(listId, sortByDefault = SORT_BY_DEFAULT) {
|
||||
const params = ref({...getDefaultParams()})
|
||||
|
||||
const search = ref('')
|
||||
const page = ref(1)
|
||||
|
||||
const sortBy = ref({ ...sortByDefault })
|
||||
|
||||
// This makes sure an id sort order is always sorted last.
|
||||
// When tasks would be sorted first by id and then by whatever else was specified, the id sort takes
|
||||
// precedence over everything else, making any other sort columns pretty useless.
|
||||
function formatSortOrder(params) {
|
||||
function formatSortOrder(sortBy, params) {
|
||||
let hasIdFilter = false
|
||||
const sortKeys = Object.keys(sortBy.value)
|
||||
const sortKeys = Object.keys(sortBy)
|
||||
for (const s of sortKeys) {
|
||||
if (s === 'id') {
|
||||
sortKeys.splice(s, 1)
|
||||
|
@ -46,11 +36,24 @@ export function useTaskList(listId, sortByDefault = SORT_BY_DEFAULT) {
|
|||
sortKeys.push('id')
|
||||
}
|
||||
params.sort_by = sortKeys
|
||||
params.order_by = sortKeys.map(s => sortBy.value[s])
|
||||
params.order_by = sortKeys.map(s => sortBy[s])
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
/**
|
||||
* This mixin provides a base set of methods and properties to get tasks on a list.
|
||||
*/
|
||||
export function useTaskList(listId, sortByDefault = SORT_BY_DEFAULT) {
|
||||
const params = ref({...getDefaultParams()})
|
||||
|
||||
const search = ref('')
|
||||
const page = ref(1)
|
||||
|
||||
const sortBy = ref({ ...sortByDefault })
|
||||
|
||||
|
||||
|
||||
const getAllTasksParams = computed(() => {
|
||||
let loadParams = {...params.value}
|
||||
|
||||
|
@ -58,7 +61,7 @@ export function useTaskList(listId, sortByDefault = SORT_BY_DEFAULT) {
|
|||
loadParams.s = search.value
|
||||
}
|
||||
|
||||
loadParams = formatSortOrder(loadParams)
|
||||
loadParams = formatSortOrder(sortBy.value, loadParams)
|
||||
|
||||
return [
|
||||
{listId: listId.value},
|
||||
|
@ -74,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
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
export const DATEFNS_DATE_FORMAT_KEBAB = 'yyyy-LL-dd'
|
||||
|
||||
export const SECONDS_A_MINUTE = 60
|
||||
export const SECONDS_A_HOUR = SECONDS_A_MINUTE * 60
|
||||
export const SECONDS_A_DAY = SECONDS_A_HOUR * 24
|
||||
export const SECONDS_A_WEEK = SECONDS_A_DAY * 7
|
||||
export const SECONDS_A_MONTH = SECONDS_A_DAY * 30
|
||||
export const SECONDS_A_YEAR = SECONDS_A_DAY * 365
|
||||
|
||||
export const MILLISECONDS_A_SECOND = 1000
|
||||
export const MILLISECONDS_A_MINUTE = SECONDS_A_MINUTE * MILLISECONDS_A_SECOND
|
||||
export const MILLISECONDS_A_HOUR = SECONDS_A_HOUR * MILLISECONDS_A_SECOND
|
||||
export const MILLISECONDS_A_DAY = SECONDS_A_DAY * MILLISECONDS_A_SECOND
|
||||
export const MILLISECONDS_A_WEEK = SECONDS_A_WEEK * MILLISECONDS_A_SECOND
|
|
@ -2,7 +2,7 @@ import type {Directive} from 'vue'
|
|||
import {install, uninstall} from '@github/hotkey'
|
||||
import {isAppleDevice} from '@/helpers/isAppleDevice'
|
||||
|
||||
const directive: Directive = {
|
||||
const directive = <Directive<HTMLElement,string>>{
|
||||
mounted(el, {value}) {
|
||||
if(value === '') {
|
||||
return
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import {AuthenticatedHTTPFactory} from '@/http-common'
|
||||
import {AuthenticatedHTTPFactory} from '@/helpers/fetcher'
|
||||
import type {AxiosResponse} from 'axios'
|
||||
|
||||
let savedToken: string | null = null
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue