Compare commits

...

491 Commits

Author SHA1 Message Date
Frederick [Bot] 86b460d09c chore(i18n): update translations via Crowdin
continuous-integration/drone/push Build is passing Details
2024-05-11 00:06:24 +00:00
renovate b93e237899 chore(deps): update dev-dependencies
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2024-05-09 06:06:20 +00:00
Frederick [Bot] 9706ebb2fc chore(i18n): update translations via Crowdin
continuous-integration/drone/push Build is passing Details
2024-05-09 00:06:59 +00:00
renovate 1d2ee77e8a fix(deps): update tiptap to v2.3.2
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2024-05-08 21:05:52 +00:00
renovate 5098363e56 fix(deps): update sentry-javascript monorepo to v7.114.0
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2024-05-08 09:05:57 +00:00
renovate 4c1dc6930d chore(deps): update dev-dependencies
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2024-05-08 05:05:55 +00:00
renovate db6d88fff5 chore(deps): update dependency go to v1.22.3
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2024-05-07 17:06:20 +00:00
renovate b021dd7237 chore(deps): update dependency node to v20.13.0
continuous-integration/drone/push Build is passing Details
2024-05-07 16:40:39 +00:00
kolaente e1dcf2e859
feat: do not save language on the server when in demo mode
continuous-integration/drone/push Build is failing Details
When the demo mode is enabled, people set the language to their own language - which is understandable. However, this is really confusing for other people when they log in and the language is something unexpected.
This change overrides the configured language when saving it while Vikunja is in demo mode.
2024-05-07 18:39:50 +02:00
kolaente 6e759b3bee
fix(i18n): clarify from current date string
continuous-integration/drone/push Build is failing Details
Resolves https://community.vikunja.io/t/suggestion-rename-from-current-date-to-after-completed/2344
2024-05-07 18:28:22 +02:00
kolaente d3a7d79eb9
fix: use correct project title in project card
continuous-integration/drone/push Build is failing Details
2024-05-07 18:18:19 +02:00
kolaente e0ce3e50bd
fix(attachment): correct spacing around creation date
continuous-integration/drone/push Build is failing Details
2024-05-07 18:11:40 +02:00
kolaente 272f643955
fix(project): show "remove background" button only when the project has a background set
continuous-integration/drone/push Build is passing Details
2024-05-07 17:17:06 +02:00
kolaente cf46c76811
fix(i18n): use correct title for background settings menu 2024-05-07 17:14:04 +02:00
kolaente 31e502d711
fix(project): do not remove project from navigation after removing background image 2024-05-07 17:13:22 +02:00
kolaente fa628edc0c
fix(project): make sure gantt and kanban views shared with link share are full width
continuous-integration/drone/push Build is passing Details
Resolves https://github.com/go-vikunja/vikunja/issues/258
2024-05-07 16:53:21 +02:00
kolaente 053c4d5842
fix(project): bottom spacing in list view
continuous-integration/drone/push Build is passing Details
2024-05-07 16:27:13 +02:00
kolaente 8d1fc08de6
docs: clarify where to file issues
continuous-integration/drone/push Build is failing Details
Resolves https://github.com/go-vikunja/vikunja/issues/262
2024-05-07 16:13:25 +02:00
kolaente eee7b060b6
fix(docs): typos
continuous-integration/drone/push Build is failing Details
Apply patch from https://github.com/go-vikunja/vikunja/issues/263
2024-05-07 16:06:17 +02:00
renovate 794fc4c1bb fix(deps): update dependency vue to v3.4.27
continuous-integration/drone/pr Build is failing Details
continuous-integration/drone/push Build is passing Details
2024-05-07 09:05:51 +00:00
renovate e58d10bc72 chore(deps): update dev-dependencies
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2024-05-07 03:05:41 +00:00
kolaente 7837bcfaae
fix(task): only count unique tasks in a bucket when checking bucket limit
continuous-integration/drone/push Build is passing Details
This fixes a bug where the current number of tasks in a bucket was computed wrong when moving tasks into a bucket with a limit. Sometimes the bug would prevent adding a task to a bucket which seemed to have space left but ultimately failed when moving the task.
2024-05-06 20:07:06 +02:00
renovate 615d40f4cd fix(deps): update module golang.org/x/crypto to v0.23.0
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2024-05-06 15:05:56 +00:00
renovate fbf7037974 chore(deps): update pnpm to v9.1.0
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is failing Details
2024-05-06 11:05:37 +00:00
renovate 212c5506af fix(deps): update module golang.org/x/image to v0.16.0
continuous-integration/drone/push Build is failing Details
2024-05-05 14:57:30 +00:00
renovate c861970f41 fix(deps): update module golang.org/x/term to v0.20.0
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is failing Details
2024-05-05 14:06:28 +00:00
kolaente 37d3715eeb
fix(task): show repeating indicator in task list for monthly repeating tasks
continuous-integration/drone/push Build is failing Details
Resolves #2319
2024-05-05 15:03:43 +02:00
kolaente b0db3ce34c
fix(quick add magic): parse full month name as month, do not replace only the abbreviation
continuous-integration/drone/push Build is failing Details
Resolves #2320
2024-05-05 14:14:30 +02:00
renovate 358e11c404 chore(deps): update dev-dependencies
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is failing Details
2024-05-05 00:06:06 +00:00
renovate 0fd7fc1452 fix(deps): update module golang.org/x/text to v0.15.0
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is failing Details
2024-05-04 21:05:53 +00:00
renovate a7db576aad fix(deps): update module golang.org/x/sys to v0.20.0
continuous-integration/drone/push Build is failing Details
2024-05-04 20:13:44 +00:00
renovate 59281c39cf fix(deps): update module golang.org/x/oauth2 to v0.20.0
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is failing Details
2024-05-04 16:05:40 +00:00
renovate f8b502f344 chore(deps): update dev-dependencies
continuous-integration/drone/push Build is failing Details
2024-05-04 06:56:59 +00:00
Frederick [Bot] ddf8db3b1f chore(i18n): update translations via Crowdin
continuous-integration/drone/push Build is failing Details
2024-05-04 00:07:32 +00:00
renovate 9260b3f1d3 chore(deps): update dependency vite to v5.2.11
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2024-05-03 00:06:00 +00:00
renovate ab74b08314 fix(deps): update sentry-javascript monorepo to v7.113.0
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2024-05-02 12:05:42 +00:00
renovate 9637db5a6b chore(deps): update dev-dependencies
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2024-05-02 05:05:36 +00:00
renovate 08645d38a0 fix(deps): update dependency dompurify to v3.1.2
continuous-integration/drone/push Build is failing Details
2024-05-01 18:47:25 +00:00
renovate f155d6bb60 fix(deps): update tiptap to v2.3.1
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is failing Details
2024-04-30 16:05:36 +00:00
renovate 9a3d63a713 fix(deps): update dependency vue to v3.4.26
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2024-04-29 13:05:49 +00:00
renovate 050f4313c8 chore(deps): update dev-dependencies
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2024-04-29 05:05:40 +00:00
renovate e917323d91 fix(deps): update dependency dayjs to v1.11.11
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2024-04-28 12:05:38 +00:00
renovate 8ad7d00559 chore(deps): update dev-dependencies
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2024-04-28 00:06:00 +00:00
renovate fd126fa234 fix(deps): update dependency dompurify to v3.1.1
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2024-04-26 12:06:39 +00:00
renovate 0c39a3dd38 chore(deps): update dependency vitest to v1.5.2
continuous-integration/drone/pr Build is passing Details
2024-04-26 00:12:26 +00:00
kolaente 66e96322ea
fix: do not remove empty openid teams when none are present
continuous-integration/drone/push Build is passing Details
Maybe resolves https://community.vikunja.io/t/empty-openid-team-cleanup-cron-error-removing-empty-openid-team-database-is-locked-error-when-exporting-data/2306/3
2024-04-25 14:21:31 +02:00
kolaente 00a96663ba
fix(caldav): check if vtodo contains any components
continuous-integration/drone/push Build is passing Details
Resolves https://vikunja.sentry.io/share/issue/1ae2fd1601aa40dea4aee41927cfcf78/
2024-04-25 13:40:23 +02:00
kolaente 741370b613
fix(caldav): return more than 1000 tasks
continuous-integration/drone/push Build is failing Details
Resolves #2302
2024-04-25 13:37:04 +02:00
renovate 70183dd7c6 chore(deps): update pnpm to v9.0.6
continuous-integration/drone/push Build is passing Details
2024-04-25 06:59:45 +00:00
renovate 760bec5e76 chore(deps): update dependency vitest to v1.5.1
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is failing Details
2024-04-25 01:10:55 +00:00
renovate 78f03373b8 fix(deps): update dependency vue to v3.4.25
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2024-04-24 13:09:36 +00:00
renovate 09c6d095df fix(deps): update sentry-javascript monorepo to v7.112.2
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build encountered an error Details
2024-04-24 12:06:21 +00:00
renovate b102fe8188 chore(deps): update dev-dependencies
continuous-integration/drone/pr Build is failing Details
continuous-integration/drone/push Build is passing Details
2024-04-24 00:11:24 +00:00
renovate f7c367b5bb fix(deps): update dependency workbox-precaching to v7.1.0
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2024-04-23 21:08:15 +00:00
renovate b94053e42e fix(deps): update sentry-javascript monorepo to v7.112.1
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2024-04-23 14:06:37 +00:00
renovate 6e98a6d7ff fix(deps): update sentry-javascript monorepo to v7.112.0
continuous-integration/drone/push Build is passing Details
2024-04-23 10:18:38 +00:00
renovate 42bfe107ae chore(deps): update dev-dependencies
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is failing Details
2024-04-23 06:10:30 +00:00
Frederick [Bot] a1892ea10b chore(i18n): update translations via Crowdin
continuous-integration/drone/push Build is passing Details
2024-04-23 00:07:19 +00:00
renovate 899f8f9bc1 fix(deps): update github.com/dustinkirkland/golang-petname digest to 76c06c4
continuous-integration/drone/push Build is passing Details
2024-04-22 22:11:33 +00:00
renovate 40f0ca6670 fix(deps): update dependency vue-i18n to v9.13.1
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is failing Details
2024-04-22 17:06:27 +00:00
renovate f8d35396dc fix(deps): update dependency vue to v3.4.24
continuous-integration/drone/push Build is passing Details
2024-04-22 16:36:18 +00:00
kolaente 409822442b
fix(backgrounds): return full project after uploading image
continuous-integration/drone/push Build is failing Details
2024-04-22 18:33:43 +02:00
kolaente aec60f3591
feat(backgrounds): resize images to a maximum of 4K
continuous-integration/drone/push Build is failing Details
Resolves #1373 (comment)
2024-04-22 18:29:58 +02:00
renovate 9b5ae38784 chore(deps): update dev-dependencies
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2024-04-22 02:08:31 +00:00
renovate 3e40a43d56 chore(deps): update pnpm to v9.0.5
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2024-04-21 22:07:37 +00:00
kolaente 15e0c716ad
fix(reminders): do not show relative reminders as minutes when they round to hours
continuous-integration/drone/push Build was killed Details
Regression from fd520dab0a
2024-04-22 00:05:12 +02:00
kolaente 26ada628a2
fix(editor): use colors from color scheme to render table cells
continuous-integration/drone/push Build is failing Details
Resolves https://github.com/go-vikunja/vikunja/issues/253
2024-04-21 23:57:07 +02:00
kolaente d86fdcb756
fix(table view): do not sort table column fields when the field in question is hidden
continuous-integration/drone/push Build was killed Details
Resolves #2272
2024-04-21 23:48:40 +02:00
kolaente 84197dd9c1
fix: correctly return error and bubble up when the api could not be reached
continuous-integration/drone/push Build was killed Details
2024-04-21 23:33:50 +02:00
kolaente 324df991ce
chore(desktop): switch from yarn to pnpm
continuous-integration/drone/push Build is passing Details
2024-04-21 21:04:07 +02:00
kolaente 1f6a1f8ad4
fix(kanban): fetch project and view when checking permissions
continuous-integration/drone/push Build is passing Details
2024-04-21 19:44:47 +02:00
kolaente ea7527a3cf
fix(test): cast result before comparing 2024-04-21 19:43:57 +02:00
kolaente 574c7f218e
fix(labels): allow link shares to add existing labels to a task
continuous-integration/drone/push Build is failing Details
Resolves https://github.com/go-vikunja/vikunja/issues/252
2024-04-21 15:12:27 +02:00
kolaente 1074a8d916
fix(views): only allow project admins to manage views
continuous-integration/drone/push Build is failing Details
Resolves https://community.vikunja.io/t/manage-views-only-for-project-admins/2279
2024-04-21 14:36:09 +02:00
kolaente e88f95e501
fix(migration): remove buckets table name when dropping index
continuous-integration/drone/push Build is passing Details
Related to #2243
2024-04-21 13:50:03 +02:00
kolaente 0962aa4262
fix(restore): transform json fields during restore
continuous-integration/drone/push Build is failing Details
Resolves https://community.vikunja.io/t/unable-to-restore-after-dump-and-export-also-not-working/2263/5
2024-04-21 13:45:49 +02:00
renovate a48ad6c9e1 chore(deps): update dev-dependencies
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2024-04-21 05:09:05 +00:00
renovate bc8fe05e9e chore(deps): update pnpm to v9.0.4
continuous-integration/drone/push Build is passing Details
2024-04-19 06:59:31 +00:00
renovate b4c12273af chore(deps): update dev-dependencies
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is failing Details
2024-04-19 00:07:11 +00:00
renovate be004793aa chore(deps): update dependency node to v20.12.2 (#2238)
continuous-integration/drone/push Build is passing Details
Reviewed-on: #2238
Reviewed-by: konrad <k@knt.li>
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2024-04-18 20:46:48 +00:00
renovate a080400d3e chore(deps): update pnpm to v9
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2024-04-18 19:05:45 +00:00
renovate d4ec0978ee fix(deps): update dependency vue-i18n to v9.13.0
continuous-integration/drone/push Build is passing Details
2024-04-18 17:20:39 +00:00
renovate c37e08635a fix(deps): update module github.com/labstack/echo/v4 to v4.12.0
continuous-integration/drone/push Build is failing Details
2024-04-18 17:20:18 +00:00
renovate 31f448e50f fix(deps): update sentry-javascript monorepo to v7.111.0
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is failing Details
2024-04-18 17:06:02 +00:00
renovate 00c323891a fix(deps): update module golang.org/x/oauth2 to v0.19.0
continuous-integration/drone/push Build is passing Details
2024-04-18 15:56:46 +00:00
renovate ff06bb202b fix(deps): update module github.com/tkuchiki/go-timezone to v0.2.3
continuous-integration/drone/push Build is failing Details
2024-04-18 15:56:02 +00:00
renovate e806cbaf22 fix(deps): update dependency vue-router to v4.3.2
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is failing Details
2024-04-18 10:09:09 +00:00
Frederick [Bot] d35ff0b380 chore(i18n): update translations via Crowdin
continuous-integration/drone/push Build is passing Details
2024-04-18 00:06:32 +00:00
renovate 982884ee05 fix(deps): update module golang.org/x/sync to v0.7.0 (#2258)
continuous-integration/drone/push Build is passing Details
Reviewed-on: #2258
Reviewed-by: konrad <k@knt.li>
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2024-04-17 20:37:47 +00:00
renovate e43d9e9bbd fix(deps): update dependency @intlify/unplugin-vue-i18n to v4
continuous-integration/drone/push Build is failing Details
2024-04-17 20:06:11 +00:00
renovate da8cee0ba5 fix(deps): update dependency vue to v3.4.23
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is failing Details
2024-04-17 10:07:15 +00:00
renovate 352381f377 chore(deps): update pnpm to v8.15.7
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2024-04-17 07:06:42 +00:00
renovate 61455b8795 fix(deps): update sentry-javascript monorepo to v7.110.1
continuous-integration/drone/push Build is passing Details
2024-04-17 06:21:21 +00:00
treysullivent aceaccbf11 docs: fix typo in README.md (#2271)
continuous-integration/drone/push Build is failing Details
Fixed "exausted" to "exhausted"

Reviewed-on: #2271
Reviewed-by: konrad <k@knt.li>
Co-authored-by: treysullivent <trey.sullivent@gmail.com>
Co-committed-by: treysullivent <trey.sullivent@gmail.com>
2024-04-17 06:20:44 +00:00
kolaente 392ce66edb
chore(deps): update github.com/adlio/trello to v1.12.0
continuous-integration/drone/push Build is passing Details
2024-04-16 23:14:51 +02:00
kolaente ecbefdb921
fix(buckets): return correct task count for tasks in buckets
continuous-integration/drone/push Build is passing Details
2024-04-14 17:21:53 +02:00
kolaente d8ca1a2de1
fix(favorites): make favorites work with configurable views
continuous-integration/drone/push Build is failing Details
2024-04-14 17:12:16 +02:00
kolaente 2d084c091e
feat: new login image
continuous-integration/drone/push Build is passing Details
2024-04-14 12:43:22 +02:00
kolaente 5a84d37fca
fix(kanban): do not focus on task list in bucket when clicking on a task
continuous-integration/drone/push Build is passing Details
2024-04-14 11:21:59 +02:00
kolaente fd520dab0a
fix(reminders): do not fall back to hours when the reminder interval is minutes
Resolves https://github.com/go-vikunja/vikunja/issues/225
2024-04-14 11:20:20 +02:00
kolaente 144a6e4140
fix(kanban): do not add bottom spacing to view
continuous-integration/drone/push Build is failing Details
2024-04-14 11:15:53 +02:00
kolaente a7aa74227a
fix(kanban): do not focus kanban board
continuous-integration/drone/push Build is failing Details
2024-04-14 11:12:26 +02:00
kolaente d2adbc53c6
fix(test): add task to bucket in test
continuous-integration/drone/push Build is failing Details
2024-04-14 11:00:41 +02:00
kolaente 422e4371f8
fix(project): add more spacing between filter button and view switcher on mobile
continuous-integration/drone/push Build is failing Details
2024-04-14 00:06:26 +02:00
kolaente 6e5b31f1e0
fix(filters): always persist filter or search in query path and load it correctly into filter query input when loading the page
continuous-integration/drone/push Build is failing Details
Previously, when using the filter query as a search input, it would load the search as requested but the filter query parameter in the url would be empty, which meant the search would not be loaded correctly when reloading (or otherwise newly accessing) the page. We're now persisting the filter and search in the task loading logic, to make sure they are always populated correctly.
2024-04-13 23:34:25 +02:00
kolaente 5756da412b
fix(project): return full project after duplicating it
continuous-integration/drone/push Build is failing Details
2024-04-13 22:39:40 +02:00
kolaente 4e05b8e97c
fix(project): do not crash when duplicating a project with no tasks
continuous-integration/drone/push Build is failing Details
2024-04-13 22:36:41 +02:00
kolaente 5177f516c4
fix(views): make sure view changes are reflected in switcher
continuous-integration/drone/push Build is failing Details
2024-04-13 22:24:12 +02:00
kolaente 637c8f6ba5
fix(views): make sure the view is saved properly in localStorage
continuous-integration/drone/push Build is failing Details
2024-04-13 22:15:41 +02:00
kolaente 1460d212ee
fix: do not push nil errors to sentry
continuous-integration/drone/push Build is failing Details
2024-04-13 21:46:07 +02:00
kolaente e9de7d8a24
fix(project): delete all related entities when deleting a project
continuous-integration/drone/push Build is failing Details
2024-04-13 21:43:44 +02:00
kolaente ce1d7778c7
fix(export): make export work with project views and new task positions
continuous-integration/drone/push Build is passing Details
2024-04-13 21:07:06 +02:00
kolaente 9a16f6f817
fix: license in cmd help text
continuous-integration/drone/push Build is passing Details
2024-04-13 20:13:24 +02:00
kolaente 7d755fcb89
fix: lint
continuous-integration/drone/push Build is passing Details
2024-04-13 17:58:53 +02:00
kolaente 77e95642a9
fix(tasks): make fetching tasks in buckets via typesense work
continuous-integration/drone/push Build is failing Details
2024-04-13 17:52:47 +02:00
kolaente a5d02380a3
fix(typesense): make fetching task positions per view more efficient
continuous-integration/drone/push Build is failing Details
2024-04-13 17:26:38 +02:00
kolaente 3519b8b2fe
fix(tasks): index and order by task position when using typesense 2024-04-13 17:19:27 +02:00
kolaente cb648e5ad8
fix(typesense): fix reindexing views and positions in typesense 2024-04-13 16:38:45 +02:00
kolaente 75f830457b
fix(comments): order comments by created timestamp instead of id
continuous-integration/drone/push Build is failing Details
Partially resolves https://community.vikunja.io/t/trello-import-comments-and-assignments/2174/14
2024-04-13 14:45:12 +02:00
kolaente 6e2b540394
fix(migration): import task comments with original timestamps
Partially resolves https://community.vikunja.io/t/trello-import-comments-and-assignments/2174/14
2024-04-13 14:44:55 +02:00
kolaente bf3c8ac9da
fix(views): check if bucket index already exists before creating new index
continuous-integration/drone/push Build is failing Details
Resolves #2243
2024-04-13 14:20:27 +02:00
kolaente 3e7225ebee
fix(editor): do not prevent shift+enter to add a line break in text
continuous-integration/drone/push Build is failing Details
Resolves https://github.com/go-vikunja/vikunja/issues/250
2024-04-13 14:08:27 +02:00
kolaente 9eb19e0362
fix(project): do not crash when views were not loaded yet
continuous-integration/drone/push Build is passing Details
The project view crashed when accessing a task from /projects because the currentProject in store was not set, hence the views weren't set either. This change adds a fallback to it.

Related to #2246
Related to https://community.vikunja.io/t/vikunja-freezes/2246/5
2024-04-13 13:18:14 +02:00
kolaente 73bf119409
docs: clarify version checkout when building from source
continuous-integration/drone/push Build is passing Details
Related to #2270 (comment)
2024-04-12 23:39:27 +02:00
kolaente 500b761fe6
fix(projects): do not return parent project id when authenticating as link share
continuous-integration/drone/push Build is passing Details
Related to https://community.vikunja.io/t/vikunja-freezes/2246
Related to https://github.com/go-vikunja/vikunja/issues/233
2024-04-12 18:02:39 +02:00
kolaente 0bc9a670d7
fix(task): do not crash when loading a task if parent projects are not loaded
continuous-integration/drone/push Build is failing Details
Related to https://community.vikunja.io/t/vikunja-freezes/2246
Related to https://github.com/go-vikunja/vikunja/issues/233
2024-04-12 17:56:19 +02:00
renovate a3e5e98c64 fix(deps): update module github.com/arran4/golang-ical to v0.2.8
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2024-04-11 16:07:49 +00:00
Elscrux a3a4d05e89 feat(editor): checklist visual improvements (#2264)
continuous-integration/drone/push Build is passing Details
This makes task lists (especially big ones) easier to read. I've set a margin so there is a distance between task items which makes them easier to stand out.
I've also changed the visuals of the checked elements (strike through + grey font color) so the unchecked ones stand out more. Note that this currently seems to be a big bugged outside of edit mode as `data-checked` doesn't seem to be updating correctly in this state which seems to be an issue that is already noted for the TipTap editor.

Co-authored-by: Elscrux <nickposer2102@gmail.com>
Reviewed-on: #2264
Reviewed-by: konrad <k@knt.li>
Co-authored-by: Elscrux <elscrux@gmail.com>
Co-committed-by: Elscrux <elscrux@gmail.com>
2024-04-11 15:46:10 +00:00
renovate 72c3e1a03f chore(deps): update dev-dependencies
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build encountered an error Details
2024-04-11 00:09:10 +00:00
Elscrux 61ee0bd5e2 feat(migration): include non upload attachments from Trello (#2261)
continuous-integration/drone/push Build is passing Details
This makes the Trello migrator include attachments that are not file uploads. To include them in Vikunja without missing data, their text (usually links) will be appended to the Vikunja description.

Co-authored-by: Elscrux <nickposer2102@gmail.com>
Reviewed-on: #2261
Reviewed-by: konrad <k@knt.li>
Co-authored-by: Elscrux <elscrux@gmail.com>
Co-committed-by: Elscrux <elscrux@gmail.com>
2024-04-10 22:12:06 +00:00
kolaente 423558f58a
fix(migration): invalid field in organization struct
continuous-integration/drone/push Build is failing Details
2024-04-10 23:52:10 +02:00
kolaente 75fd17c750
docs: clarify vikunja cli usage in docker
continuous-integration/drone/push Build is passing Details
2024-04-10 23:05:45 +02:00
kolaente 4e49ec9e16
docs: clarify automatic openid team creation 2024-04-10 23:05:45 +02:00
renovate 58e0ec3d35 fix(deps): update tiptap to v2.3.0
continuous-integration/drone/push Build is failing Details
2024-04-10 20:59:51 +00:00
kolaente ed4be389ab
fix(navigation): scrolling when many projects are present
continuous-integration/drone/push Build is failing Details
Regression from ee3d20e1d2
Resolves https://github.com/go-vikunja/vikunja/issues/249
2024-04-10 22:54:36 +02:00
renovate cb2c2eeae8 fix(deps): update dependency vue-i18n to v9.11.1
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2024-04-10 17:06:05 +00:00
renovate e19ac57130 chore(deps): update dev-dependencies
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2024-04-10 15:06:07 +00:00
kolaente 0557d4b5bb
docs: clarify transitioning from unstable to release
continuous-integration/drone/push Build is passing Details
2024-04-09 22:43:27 +02:00
kolaente bc19a2fb78
fix(migration): import card comments from Trello when migrating
continuous-integration/drone/push Build is passing Details
Related: https://community.vikunja.io/t/trello-import-comments-and-assignments/2174/3
2024-04-09 13:56:17 +02:00
kolaente 994aaeb920
fix(migration): trello: only fetch attachments when the card actually has attachments 2024-04-09 13:25:03 +02:00
kolaente ee3d20e1d2
fix(navigation): do not hide shadows of dropdown menu
continuous-integration/drone/push Build is passing Details
2024-04-09 13:07:01 +02:00
Elscrux 8458e77341 feat(migration): Trello organization based migration (#2211)
continuous-integration/drone/push Build is failing Details
Migrate Trello organization after organization to limit total memory allocation.
Related discussion: https://community.vikunja.io/t/trello-import-issues/2110

Co-authored-by: Elscrux <nickposer2102@gmail.com>
Co-authored-by: konrad <k@knt.li>
Co-authored-by: kolaente <k@knt.li>
Reviewed-on: #2211
Reviewed-by: konrad <k@knt.li>
Co-authored-by: Elscrux <elscrux@gmail.com>
Co-committed-by: Elscrux <elscrux@gmail.com>
2024-04-09 10:54:38 +00:00
kolaente af3b0bbea1
fix: lint
continuous-integration/drone/push Build is passing Details
2024-04-08 13:23:15 +02:00
renovate d2317b9531 fix(deps): update dependency vue-i18n to v9.11.0
continuous-integration/drone/push Build is failing Details
2024-04-08 10:35:09 +00:00
renovate 552f8580a4 fix(deps): update dependency dompurify to v3.1.0
continuous-integration/drone/push Build is failing Details
2024-04-08 10:27:31 +00:00
renovate c842cb27b2 fix(deps): update tiptap to v2.2.6
continuous-integration/drone/push Build is failing Details
2024-04-08 10:19:11 +00:00
kolaente e10cd368bf
feat(migration): notify the user when a migration failed
continuous-integration/drone/push Build is failing Details
This change introduces notifications via mail when a migration fails. It will contain the error message and a hint to post it in the forum when Sentry is disabled, otherwise the error message will be sent directly to sentry and the notification will inform accordingly.
I've tried to balance "this thing failed, go figure it out" with "here is what we know and how you can get help", we'll see how well that approach works.
2024-04-08 12:15:24 +02:00
renovate 61322d2e2e fix(deps): update module github.com/yuin/goldmark to v1.7.1
continuous-integration/drone/push Build is passing Details
2024-04-08 09:28:22 +00:00
renovate a41e248e5f fix(deps): update font awesome to v6.5.2
continuous-integration/drone/push Build is failing Details
2024-04-08 09:10:31 +00:00
kolaente 6e37934b61
chore(deps): update goreleaser/nfpm docker tag to v2.36.1
continuous-integration/drone/push Build is failing Details
2024-04-08 11:09:39 +02:00
renovate d64322bb7a fix(deps): update dependency @infectoone/vue-ganttastic to v2.3.2
continuous-integration/drone/push Build is failing Details
2024-04-08 08:58:21 +00:00
renovate fa3b657e7e chore(deps): update pnpm to v8.15.6
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2024-04-08 08:06:35 +00:00
Raymi306 1adaa73141 docs: fix build-from-sources docs mistake (#2251)
continuous-integration/drone/push Build is passing Details
While attempting to build on OpenBSD without having built the frontend, I ran into the following error:

`frontend/embed.go:21:12: pattern dist: no matching files found`

I saw in the docs to create a directory and touch a file, this resulted in a second error:

`frontend/embed.go:21:12: pattern dist: cannot embed directory dist: contains no embeddable files`

Creating the index.html file inside the new directory allowed me to build Vikunja

Reviewed-on: #2251
Co-authored-by: Raymi306 <raymi306@gmail.com>
Co-committed-by: Raymi306 <raymi306@gmail.com>
2024-04-08 07:48:12 +00:00
renovate 3e77e3043e chore(deps): update dev-dependencies
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is failing Details
2024-04-08 07:09:20 +00:00
kolaente d082c0399d
fix(test): visit one more project in project history test
continuous-integration/drone/push Build is passing Details
2024-04-07 22:36:09 +02:00
kolaente 0b9ef27d04
fix(migration): show correct message after starting a migration
continuous-integration/drone/push Build is failing Details
Related to https://github.com/go-vikunja/vikunja/issues/238
2024-04-07 15:11:59 +02:00
kolaente 7acd1a7e51
fix(project): remove child projects from state when deleting a project
continuous-integration/drone/push Build is failing Details
2024-04-07 15:03:18 +02:00
kolaente 8bee5aa806
fix(project): return the full project when setting a background
continuous-integration/drone/push Build is failing Details
Related to #2246
2024-04-07 14:53:57 +02:00
kolaente 6641cbebc2
fix(project): save the last 6 projects in history, show only 5 on desktop
continuous-integration/drone/push Build is failing Details
The project grid on the home page with the recently visited projects now contains an even number of projects which makes for a much nicer grid (because it's now uniform).
2024-04-07 14:34:18 +02:00
kolaente 5892622676
fix(notifications): rendering of plaintext mails
continuous-integration/drone/push Build is passing Details
2024-04-07 14:12:44 +02:00
kolaente 191a476823
fix(notifications): only sanitze html content in notifications, do not convert it to markdown
continuous-integration/drone/push Build is failing Details
Resolves https://community.vikunja.io/t/trello-import-html-mails/2197
2024-04-07 13:34:53 +02:00
renovate c146b72d64 chore(deps): update golangci/golangci-lint docker tag to v1.57.2 (#2225)
continuous-integration/drone/push Build is passing Details
Reviewed-on: #2225
Reviewed-by: konrad <k@knt.li>
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2024-04-07 11:09:14 +00:00
kolaente ca33c0b2bc
fix: drop bucket index before recreating it
continuous-integration/drone/push Build is failing Details
Resolves #2243
2024-04-07 12:50:42 +02:00
kolaente 4d78ae7fa8
chore(dev): move nix flake to top level, add api tooling
continuous-integration/drone/push Build is passing Details
2024-04-07 12:16:13 +02:00
kolaente c1d06c5e5a
fix(projects): do not return parent project id of parents where the user does not have access
continuous-integration/drone/push Build is failing Details
This caused the frontend to not show such projects, throwing errors in the process and sometimes made it hang.
2024-04-07 12:10:20 +02:00
kolaente f1c3ce5eeb
fix(projects): allow arbitrary nesting of new projects 2024-04-07 12:00:39 +02:00
kolaente 2f6b395334
feat(kanban): set task position to 0 (top) when it is moved into the done bucket automatically after marking it done
continuous-integration/drone/push Build is passing Details
2024-04-06 14:35:05 +02:00
kolaente 1cd5dd2b2f
fix: lint
continuous-integration/drone/push Build is passing Details
2024-04-06 14:12:08 +02:00
kolaente 521300613f
fix: update task in typesense when adding a label or assignee to them
continuous-integration/drone/push Build is failing Details
Resolves https://community.vikunja.io/t/typesense-only-works-if-i-re-index/2212
2024-04-06 14:04:04 +02:00
kolaente 037022e857
fix: do not try to fetch nonexistant bucket
continuous-integration/drone/push Build is failing Details
2024-04-06 13:55:11 +02:00
kolaente ec1ff80791
fix(kanban): save done and default bucket on the view and not on the project
The frontend was still trying to update the two in the project which won't work since they are now saved at the view level, not the project.
2024-04-06 13:32:54 +02:00
kolaente 7b8fab33a5
fix(kanban): Make sure all saved taskBucket positions are saved with their project view id
continuous-integration/drone/push Build is passing Details
When the tasks were migrated from belonging directly to a bucket to only belonging to a view, I forgot to add the view in that migration, resulting in task buckets where the view was 0. These entries were not deleted when a task was moved between buckets, but the new task bucket relation nevertheless inserted. This resulted in tasks showing up multiple times on the kanban board.

This change adds a new migration which adds the correct project view id (as derived from the bucket) and fixes the old migration as well.

Resolves https://community.vikunja.io/t/no-longer-able-to-properly-move-tasks-between-kanban-columns/2175
2024-04-06 13:04:36 +02:00
kolaente e0417c8bda
docs: add Korganizer to supported caldav clients
continuous-integration/drone/push Build is passing Details
2024-04-06 12:15:08 +02:00
kolaente 6fbd24d5f6
fix(filter): move spaces out of button to after the matched filter value to prevent removal of spaces
continuous-integration/drone/push Build is failing Details
2024-04-06 12:08:58 +02:00
kolaente e534a6a5bf
fix(modal): do not set p in modal card as flex
This fixes a bug where the description of a project or filter would be aligned right.
2024-04-06 12:08:58 +02:00
kolaente bf85cb0505
fix(filters): always show filter values in a readable color 2024-04-06 12:08:57 +02:00
kolaente 20e2314128
fix(filters): enclose values with a slash in them as strings so that date math values work
Previously, in a filter like "due_date = now/d", the / was parsed as the beginning of a comment, but as it did not contain the full value, this is an invalid comment, resulting in an error message.

Resolves https://community.vikunja.io/t/filter-setting-s/1791/12
2024-04-06 12:08:57 +02:00
kolaente 1ebb551864
fix(filters): make sure the same filter attribute is transformed in all instances
Resolves https://community.vikunja.io/t/filter-setting-s/1791/13
2024-04-06 12:08:57 +02:00
renovate 30c1a46ed4 fix(deps): update src.techknowlogick.com/xgo digest to e01c4fb
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is failing Details
2024-04-06 09:07:13 +00:00
kolaente 1910f69392
fix(test): correctly mock localstorage in unit tests
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2024-04-06 10:34:41 +02:00
renovate fe4a093825 chore(deps): update dev-dependencies
continuous-integration/drone/pr Build is failing Details
2024-04-06 00:07:33 +00:00
renovate 90055d063c chore(deps): update dev-dependencies (#2229)
continuous-integration/drone/push Build is passing Details
Reviewed-on: #2229
Reviewed-by: konrad <k@knt.li>
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2024-04-05 20:41:15 +00:00
waza-ari f0d695e789 fix(views): remove default filter from frontend, apply by default to new list views instead (#2240)
continuous-integration/drone/push Build is passing Details
Fixes #2234

Co-authored-by: Daniel Herrmann <daniel.herrmann1@gmail.com>
Reviewed-on: #2240
Reviewed-by: konrad <k@knt.li>
Co-authored-by: waza-ari <daniel.herrmann@makerspace-darmstadt.de>
Co-committed-by: waza-ari <daniel.herrmann@makerspace-darmstadt.de>
2024-04-02 13:49:38 +00:00
kolaente 95276ceebe
fix(reactions): do not enable reaction picker when the current user does not have write access
continuous-integration/drone/push Build is passing Details
2024-04-02 14:48:13 +02:00
kolaente 1558921f42
fix(test): use correct selector in Cypress test
continuous-integration/drone/push Build is failing Details
2024-04-02 14:31:15 +02:00
kolaente bf5088e546
fix(sharing): show user display name and avatar when displaying search results
continuous-integration/drone/push Build is failing Details
Resolves https://community.vikunja.io/t/autogenerated-username-using-google-openid/2183/12
2024-04-02 14:29:22 +02:00
kolaente 6f366d4907
feat(views): lint
continuous-integration/drone/push Build is failing Details
2024-04-02 14:04:17 +02:00
kolaente d7554d9e70
feat(views): hide view switcher when there is only one view 2024-04-02 14:02:59 +02:00
kolaente 8a72fe26f8
fix(views): refactor filter button slot in wrapper
Before this change, the filter button on the top right was positioned using absolute positioning and plenty of tricks, which were brittle and not really maintainable. Now, the buttons are positioned using flexbox, which should make this a lot more maintainable.
2024-04-02 14:02:31 +02:00
kolaente 13cab62d14
fix(views): transform view filter before and after loading it from the api
continuous-integration/drone/push Build is failing Details
Previously, the actual filter was kept as-is when sending it to the api, essentially creating an invalid filter. This change fixes this, transforming the filter before saving and after loading.

Resolves #2233
2024-04-02 13:20:17 +02:00
kolaente 81de986d8d
fix(gantt): correctly show day in chart 2024-04-02 12:53:14 +02:00
kolaente 915f677c2a
fix(views): correctly pass view id to wrapper when gantt view is active 2024-04-02 12:50:10 +02:00
kolaente 8a6e3d5bd7
fix(views): use correct assertion in test
continuous-integration/drone/push Build is passing Details
2024-04-02 12:42:07 +02:00
kolaente 81fe8391e4
fix(project): load full project after creating a project
continuous-integration/drone/push Build is failing Details
When a new project was created, it contained all details already. This led to duplicated views and overridden attributes in the response.

Resolves #2242
2024-03-29 19:28:17 +01:00
kolaente 89e37b88d9
fix(views): update all fields when updating a view
continuous-integration/drone/push Build is failing Details
Resolves #2241
2024-03-29 18:19:16 +01:00
kolaente cc6801c5b1
fix(filter): make sure highlight works for doneAt attribute
continuous-integration/drone/push Build is failing Details
2024-03-29 18:09:02 +01:00
kolaente 767b058915
fix(filter): add white background to filter input 2024-03-29 18:07:37 +01:00
kolaente 2c0d3f2885
fix(views): add bottom spacing 2024-03-29 18:05:30 +01:00
renovate fa170b9397 fix(deps): update sentry-javascript monorepo to v7.109.0
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2024-03-28 22:06:03 +00:00
kolaente a5fd6f834a
chore(deps): sign drone config
continuous-integration/drone/push Build is passing Details
2024-03-28 23:00:24 +01:00
renovate 8984e0e9f0 fix(deps): update module github.com/go-sql-driver/mysql to v1.8.1
continuous-integration/drone/pr Build is failing Details
continuous-integration/drone/push Build is failing Details
2024-03-28 21:06:31 +00:00
renovate 176c41dc40 fix(deps): update dependency express to v4.19.2
continuous-integration/drone/push Build is passing Details
2024-03-28 20:57:07 +00:00
waza-ari c4d3d99cd4 fix: pick first available view if currently configured view got deleted (#2235)
continuous-integration/drone/push Build is failing Details
Resolves #2232

Co-authored-by: Daniel Herrmann <daniel.herrmann1@gmail.com>
Reviewed-on: #2235
Reviewed-by: konrad <k@knt.li>
Co-authored-by: waza-ari <daniel.herrmann@makerspace-darmstadt.de>
Co-committed-by: waza-ari <daniel.herrmann@makerspace-darmstadt.de>
2024-03-28 20:55:52 +00:00
renovate 30b4ed6b23 fix(deps): update dependency dompurify to v3.0.11
continuous-integration/drone/push Build is failing Details
2024-03-28 20:53:00 +00:00
renovate aed92d1cd2 fix(deps): update sentry-javascript monorepo to v7.108.0
continuous-integration/drone/push Build is failing Details
2024-03-28 20:49:17 +00:00
Frederick [Bot] 2239d73797 chore(i18n): update translations via Crowdin
continuous-integration/drone/push Build is passing Details
2024-03-27 00:11:11 +00:00
Frederick [Bot] 98b833f61a chore(i18n): update translations via Crowdin
continuous-integration/drone/push Build is passing Details
2024-03-25 00:10:35 +00:00
Frederick [Bot] ecd002dca3 chore(i18n): update translations via Crowdin
continuous-integration/drone/push Build is passing Details
2024-03-21 00:05:22 +00:00
renovate 5e3616bda3 fix(deps): update dependency ufo to v1.5.3
continuous-integration/drone/push Build is passing Details
2024-03-20 16:32:22 +00:00
renovate 3e5ff77b91 fix(deps): update dependency express to v4.19.0
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is failing Details
2024-03-20 16:05:36 +00:00
kolaente 403db6adbf
fix(reminder): do not close the popup directly after changing the value
continuous-integration/drone/push Build is passing Details
Fixes https://github.com/go-vikunja/vikunja/issues/225
2024-03-20 11:58:29 +01:00
kolaente fd4312382e
fix(kanban): remove unused function
continuous-integration/drone/push Build is failing Details
2024-03-20 11:46:36 +01:00
kolaente 2dcf6c6861
fix(kanban): do not use the bucket id saved on the task
continuous-integration/drone/push Build was killed Details
2024-03-20 11:36:54 +01:00
kolaente 8f85af07ca
fix(task): clear timeout for description save when closing the task detail 2024-03-20 11:26:54 +01:00
kolaente 9f89fbe5a6
fix(tests): do not try to create tasks with bucket_id
continuous-integration/drone/push Build is failing Details
2024-03-20 10:54:37 +01:00
kolaente 6ad83c0685
chore: do not import message dynamically
continuous-integration/drone/push Build is failing Details
Since it was not done consistently, it would not get imported dynamically anyway. This fixes the compile warnings.
2024-03-20 10:52:59 +01:00
kolaente 97b7592e7c
fix(views): do not map bucket id from xorm
continuous-integration/drone/push Build was killed Details
2024-03-20 10:41:58 +01:00
kolaente 19c9cd9bc2
fix(docker): don't install cypress in docker image
continuous-integration/drone/push Build is passing Details
2024-03-20 09:38:28 +01:00
Frederick [Bot] e53fcd3367 [skip ci] Updated swagger docs 2024-03-20 08:35:16 +00:00
kolaente d635fd2dd3
fix(projects): remove done bucket id field from projects struct
continuous-integration/drone/push Build is passing Details
2024-03-20 09:21:40 +01:00
renovate b8584301a3 fix(deps): update dependency @infectoone/vue-ganttastic to v2.3.1
continuous-integration/drone/push Build is passing Details
2024-03-20 08:01:25 +00:00
renovate 2bb4c31f20 chore(deps): update dev-dependencies
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is failing Details
2024-03-20 03:07:47 +00:00
Frederick [Bot] d22ebef0b3 chore(i18n): update translations via Crowdin
continuous-integration/drone/push Build is passing Details
2024-03-20 00:06:31 +00:00
Frederick [Bot] 68d8ed5a7a [skip ci] Updated swagger docs 2024-03-19 19:28:04 +00:00
konrad 7230db1603 feat: decouple views from projects (#2217)
continuous-integration/drone/push Build is passing Details
This PR decouples views from projects. On the surface, everything stays the same - by default, there are the same views as right now in main - List, Gantt, Table, Kanban. With this feature, it is possible to modify these or create new ones. That means you can remove views you never need or create multiple ones if you need different configurations.

Each view can have an optional filter to change what you see in the frontend on that view. For kanban, you can either set it to "manual" mode, where you can create buckets and move tasks around, or "filter" mode, where each bucket is the result of a filter (and you cannot move them around).

All positions and buckets are now tied to the view, not the project. This means you can (finally!) have views for saved filters.

Reviewed-on: #2217
2024-03-19 19:16:11 +00:00
kolaente 32e8a15f1f
fix(views): create default bucket
continuous-integration/drone/pr Build is passing Details
2024-03-19 19:53:46 +01:00
renovate f80ffcd541 fix(deps): update module github.com/coreos/go-oidc/v3 to v3.10.0
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2024-03-19 18:05:40 +00:00
kolaente 89ed71777e
fix(views): create bucket in test
continuous-integration/drone/pr Build is failing Details
2024-03-19 17:57:00 +01:00
kolaente 5b01710943
fix(views): intercept request
continuous-integration/drone/pr Build is failing Details
2024-03-19 17:42:59 +01:00
kolaente d7b40f393e
fix(views): redirect to project after authenticating with a link share 2024-03-19 17:38:33 +01:00
kolaente fee75e55a3
fix(views): stable assertion for bucket in tests 2024-03-19 17:27:03 +01:00
kolaente fa137b1ffc
fix(views): include order by fields in distinct clause when sorting by task position
continuous-integration/drone/pr Build is failing Details
2024-03-19 17:05:12 +01:00
kolaente e7d6ee2392
fix(views): update done status of recurring tasks 2024-03-19 17:04:39 +01:00
kolaente 62ff05695f
fix(views): kanban test assertions 2024-03-19 16:59:46 +01:00
kolaente 6f51b56589
fix: lint 2024-03-19 16:49:39 +01:00
kolaente 5e9edef3b3
fix: lint
continuous-integration/drone/pr Build is failing Details
2024-03-19 16:33:23 +01:00
kolaente f18fcc5569
fix(views): make task cypress tests work again
continuous-integration/drone/pr Build is failing Details
2024-03-19 15:27:31 +01:00
kolaente bc8b5da61d
fix(views): make overview cypress tests work again 2024-03-19 15:23:36 +01:00
kolaente b3a96ea251
fix(views): make link share cypress tests work again 2024-03-19 15:09:38 +01:00
kolaente b0ad087a36
fix(views): correctly save and retrieve last accessed project views 2024-03-19 14:57:16 +01:00
kolaente b65d05ec3d
fix(views): make table view cypress tests work again 2024-03-19 14:43:52 +01:00
kolaente 974c9cdd21
fix(views): always redirect to the first view when none was specified 2024-03-19 14:39:10 +01:00
kolaente 8206cc8767
fix(views): make list cypress tests work again 2024-03-19 14:38:52 +01:00
kolaente 53e57d524a
fix(views): make kanban cypress tests work again 2024-03-19 14:21:32 +01:00
kolaente 165d291cd5
fix(views): reset bucket when moving tasks between projects 2024-03-19 14:16:05 +01:00
kolaente e940db6d32
fix(views): return only tasks when the bucket id was already specified 2024-03-19 13:55:28 +01:00
kolaente 7c30b00668
fix(views): correctly pass project id when loading more tasks in kanban views 2024-03-19 13:55:05 +01:00
renovate 0ee8150e24 fix(deps): update dependency dompurify to v3.0.10
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2024-03-19 12:06:01 +00:00
kolaente cf9b2fa203
fix(views): tests for kanban and gantt views
continuous-integration/drone/pr Build is failing Details
2024-03-19 12:11:27 +01:00
renovate a9622fe03a chore(deps): update dev-dependencies
continuous-integration/drone/push Build is passing Details
2024-03-19 10:08:57 +00:00
renovate c67f0ae3cc fix(deps): update dependency @kyvg/vue3-notification to v3.2.1
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is failing Details
2024-03-19 09:07:58 +00:00
renovate 8e7828f71d fix(deps): update dependency ufo to v1.5.2
continuous-integration/drone/push Build is passing Details
2024-03-19 08:44:45 +00:00
renovate 5e60edd9ae chore(deps): update dependency happy-dom to v14
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is failing Details
2024-03-19 01:06:24 +00:00
kolaente 511c9aa824
fix(views): make tests for project history kind of work again
continuous-integration/drone/pr Build is failing Details
2024-03-19 00:47:52 +01:00
kolaente 4b903c4f48
fix(views): lint 2024-03-19 00:47:52 +01:00
kolaente 30b41bd143
fix(views): lint 2024-03-19 00:47:51 +01:00
kolaente f3cdd7d15f
fix(views): import 2024-03-19 00:47:51 +01:00
kolaente 8b90eb4a15
fix(views): integration tests 2024-03-19 00:47:51 +01:00
kolaente 803f58f402
fix(views): return correct error 2024-03-19 00:47:51 +01:00
kolaente b7b3169169
fix(views): count task buckets 2024-03-19 00:47:51 +01:00
kolaente 409f9a0cc6
fix(views): test assertions 2024-03-19 00:47:51 +01:00
kolaente 9075a45cb8
fix(views): update test fixtures for new structure 2024-03-19 00:47:51 +01:00
kolaente d4bdd2d4e8
fix(views): duplicate all views and related entities when duplicating a project 2024-03-19 00:47:51 +01:00
kolaente 9cc273d9bd
fix(views): move all tasks to the default bucket when deleting a bucket 2024-03-19 00:47:51 +01:00
kolaente 0f60a92873
fix(views): make kanban tests work again 2024-03-19 00:47:51 +01:00
kolaente bec9e3eb7d
fix(views): set current project after modifying views 2024-03-19 00:47:51 +01:00
kolaente 3f8c5a5feb
fix(views): set correct default view 2024-03-19 00:47:51 +01:00
kolaente 24fa3b206f
fix(views): create view 2024-03-19 00:47:50 +01:00
kolaente 434b1ea0e8
feat(views): crud in frontend 2024-03-19 00:47:50 +01:00
kolaente 433584813a
fix(views): view deletion 2024-03-19 00:47:50 +01:00
kolaente 3ec3bb76af
fix(views): make parsing work 2024-03-19 00:47:50 +01:00
kolaente 6e53bf4ebe
feat(filter): add unique id to filter input 2024-03-19 00:47:50 +01:00
kolaente b8ff7910b0
feat(filter): make filter input label configurable 2024-03-19 00:47:50 +01:00
kolaente f6485be9e2
chore(views): move actual project views into their own folder 2024-03-19 00:47:50 +01:00
kolaente 004f1e06bb
fix(views): do not return kanban tasks multiple times 2024-03-19 00:47:50 +01:00
kolaente 27cb6e3372
fix(views): make bucket edit work 2024-03-19 00:47:50 +01:00
kolaente 445f1c06fa
fix(views): make bucket creation work again 2024-03-19 00:47:50 +01:00
kolaente 4c1a53beed
chore(views): use view id instead of passing whole view object 2024-03-19 00:47:50 +01:00
kolaente 7368a51f18
fix(views): make setting task position in saved filters work 2024-03-19 00:47:49 +01:00
kolaente e1774cc49a
feat(views): show tasks on kanban board in saved filter 2024-03-19 00:47:49 +01:00
kolaente 61e27ae3eb
feat(views): create task bucket relation when creating a new bucket 2024-03-19 00:47:49 +01:00
kolaente 7f1788eba9
fix(views): get tasks in saved filter 2024-03-19 00:47:49 +01:00
kolaente 39c9928421
fix(views): do not load views async 2024-03-19 00:47:49 +01:00
kolaente 59ced554cd
chore(views): remove old view routes 2024-03-19 00:47:49 +01:00
kolaente bc34a33922
fix(views): move to new project view when moving tasks 2024-03-19 00:47:49 +01:00
kolaente 2dfb3a6379
fix(views): make no initial view work in the frontend 2024-03-19 00:47:49 +01:00
kolaente 337d289a39
chore: remove old saved views migration 2024-03-19 00:47:49 +01:00
kolaente 5451ddf58d
fix(views): return tasks directly or in buckets, no matter if accessing via user or link share 2024-03-19 00:47:49 +01:00
kolaente a3714c74fd
feat(views): load views when navigating with link share 2024-03-19 00:47:49 +01:00
kolaente 4170f5468f
feat(views): save task position in list view 2024-03-19 00:47:49 +01:00
kolaente f364f3bec8
feat(views): return position when retriving tasks 2024-03-19 00:47:48 +01:00
kolaente 786e67f692
feat(views): save task position 2024-03-19 00:47:48 +01:00
kolaente c36fdb9f5d
chore(views): add fixme 2024-03-19 00:47:48 +01:00
kolaente dee78be579
fix(views): return buckets when fetching tasks via kanban view 2024-03-19 00:47:48 +01:00
kolaente 398c9f1056
fix(views): return tasks in their buckets 2024-03-19 00:47:48 +01:00
kolaente ca0550acea
fix(views): fetch buckets through view 2024-03-19 00:47:48 +01:00
kolaente cb111df2b7
fix(views): make fetching tasks in kanban buckets through view actually work 2024-03-19 00:47:48 +01:00
kolaente df415f97a9
fix(views): make table view load tasks again 2024-03-19 00:47:48 +01:00
kolaente 86039b1dd2
fix(views): make gantt view load tasks again 2024-03-19 00:47:48 +01:00
kolaente 73e5483e87
fix(views): do not break filters when combining them with view filters 2024-03-19 00:47:48 +01:00
kolaente 6913334b17
fix(views): correctly fetch project when fetching tasks 2024-03-19 00:47:48 +01:00
kolaente 7866543198
feat(views): generate swagger docs 2024-03-19 00:47:48 +01:00
kolaente cf15cc6f12
feat(views): fetch tasks via view context when accessing them through views 2024-03-19 00:47:47 +01:00
kolaente ee6ea03506
feat(views): sort by position 2024-03-19 00:47:47 +01:00
kolaente 43f24661d7
feat(views): save view and position in Typesense 2024-03-19 00:47:47 +01:00
kolaente 5641da27f7
feat(views): save position in Typesense 2024-03-19 00:47:47 +01:00
kolaente 14353b24d7
feat(views): set default position 2024-03-19 00:47:47 +01:00
kolaente ca4e3e01c5
feat(views): recalculate all positions when updating 2024-03-19 00:47:47 +01:00
kolaente 8ce476491e
feat(views): only update the bucket when necessary 2024-03-19 00:47:47 +01:00
kolaente f2a0d69670
feat(views)!: make updating a bucket work again 2024-03-19 00:47:47 +01:00
kolaente a13276e28e
feat(views)!: decouple bucket <-> task relationship 2024-03-19 00:47:47 +01:00
kolaente 9cf84646a1
feat(views)!: move done and default bucket setting to view 2024-03-19 00:47:47 +01:00
kolaente 006f932dc4
feat(views)!: decouple bucket CRUD from projects 2024-03-19 00:47:47 +01:00
kolaente 0a3f45ab11
feat(views): decouple buckets from projects 2024-03-19 00:47:47 +01:00
kolaente d1d07f462c
feat(views): sort tasks by their position relative to the view they're in 2024-03-19 00:47:46 +01:00
kolaente 2502776460
feat(views)!: move task position handling to its own crud entity
BREAKING CHANGE: the position of tasks now can't be updated anymore via the task update endpoint. Instead, there is a new endpoint which takes the project view into account as well.
2024-03-19 00:47:46 +01:00
kolaente 238baf86f7
feat(views)!: return tasks in buckets by view
BREAKING CHANGE: tasks in their bucket are now only retrievable via their view. The /project/:id/buckets endpoint now only returns the buckets for that project, which is more in line with the other endpoints
2024-03-19 00:47:46 +01:00
kolaente 652bf4b4ed
feat(views): (un)marshal custom project view mode types 2024-03-19 00:47:46 +01:00
kolaente a9020e976d
feat(views): add bucket configuration mode 2024-03-19 00:47:46 +01:00
kolaente 38457aaca5
feat(views): use project id when fetching views 2024-03-19 00:47:46 +01:00
kolaente 98b7cc9254
feat(views): do not override filters in view 2024-03-19 00:47:46 +01:00
kolaente 4149ebed3a
feat(views): create default views when creating a filter 2024-03-19 00:47:46 +01:00
kolaente 2096fc5274
feat(views): return tasks in a view 2024-03-19 00:47:46 +01:00
kolaente e4b1a5d2db
feat(views): create default 4 default view for projects 2024-03-19 00:47:46 +01:00
kolaente 2fa3e2c2f5
feat(views): return views with their projects 2024-03-19 00:47:46 +01:00
kolaente ee228106fc
feat(views): add new default views for filters 2024-03-19 00:47:45 +01:00
kolaente b39c5580c2
feat(views): add crud handlers and routes for views 2024-03-19 00:47:45 +01:00
kolaente 6bdb33fb46
feat(views): add new model and migration 2024-03-19 00:47:45 +01:00
renovate 091e03a39d fix(deps): update module xorm.io/xorm to v1.3.9
continuous-integration/drone/push Build is passing Details
2024-03-18 09:23:11 +00:00
renovate 55c9403dda chore(deps): update pnpm to v8.15.5
continuous-integration/drone/push Build is passing Details
2024-03-18 09:08:09 +00:00
renovate 3f33f903b5 chore(deps): update dev-dependencies
continuous-integration/drone/push Build is failing Details
2024-03-18 09:05:33 +00:00
renovate 650c6cb339 fix(deps): update dependency date-fns to v3.6.0
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build encountered an error Details
2024-03-18 02:07:06 +00:00
kolaente 2fff9f1c59
fix(deps): update module github.com/adlio/trello to v1.11.0
continuous-integration/drone/push Build is passing Details
2024-03-17 21:44:20 +01:00
renovate 2cbd20a084 fix(deps): update dependency date-fns to v3.5.0
continuous-integration/drone/push Build is failing Details
2024-03-16 10:12:02 +00:00
renovate 15949adc2b fix(deps): update dependency ufo to v1.5.1
continuous-integration/drone/push Build is failing Details
2024-03-16 10:11:29 +00:00
renovate 11f2db0e9c chore(deps): update dev-dependencies
continuous-integration/drone/push Build is failing Details
2024-03-16 10:11:17 +00:00
Frederick [Bot] 87ebe85972 chore(i18n): update translations via Crowdin
continuous-integration/drone/push Build is passing Details
2024-03-16 00:05:25 +00:00
renovate 0cf11228cf fix(deps): update dependency vue-i18n to v9.10.2
continuous-integration/drone/push Build is failing Details
2024-03-15 18:16:11 +00:00
renovate 9d01b9105a fix(deps): update dependency ufo to v1.5.0
continuous-integration/drone/push Build is failing Details
2024-03-15 18:15:56 +00:00
renovate d6bc09b0cf fix(deps): update dependency axios to v1.6.8
continuous-integration/drone/pr Build is failing Details
continuous-integration/drone/push Build is failing Details
2024-03-15 17:07:21 +00:00
waza-ari be54a361fd docs: add details about supported and required OIDC claims (#2201)
continuous-integration/drone/push Build is passing Details
Again based on a [community question](https://community.vikunja.io/t/oidc-how-can-i-prevent-username-from-being-set-randomly-how-can-users-find-each-other/2138/2), it might make sense to add a few more details about the OIDC behaviour to the docs.

Co-authored-by: Daniel Herrmann <daniel.herrmann1@gmail.com>
Reviewed-on: #2201
Co-authored-by: waza-ari <daniel.herrmann@makerspace-darmstadt.de>
Co-committed-by: waza-ari <daniel.herrmann@makerspace-darmstadt.de>
2024-03-15 13:50:46 +00:00
renovate 725a04b93c fix(deps): update sentry-javascript monorepo to v7.107.0
continuous-integration/drone/push Build is failing Details
2024-03-15 07:50:09 +00:00
renovate 2add517d6e chore(deps): update dev-dependencies
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is failing Details
2024-03-15 00:06:35 +00:00
kolaente 96186250f4
fix(filters): clear autocomplete results when starting the next character
continuous-integration/drone/push Build is passing Details
2024-03-14 09:05:07 +01:00
kolaente 6cf3a578c0
fix(filters): correctly replace values when clicking on an autocomplete result
continuous-integration/drone/push Build is failing Details
Related #2194
2024-03-14 09:02:57 +01:00
kolaente c8b35d49ca
fix(filters): correctly return project from filter
continuous-integration/drone/push Build is passing Details
Related #2194
2024-03-14 08:49:02 +01:00
kolaente 161bb1b192
fix(filters): do not watch debounced 2024-03-14 08:40:03 +01:00
kolaente 3ab22d8e06
chore(deps): update google.golang.org/protobuf from 1.32.0 to 1.33.0
continuous-integration/drone/push Build is passing Details
2024-03-14 08:33:13 +01:00
renovate 273f5ddf59 chore(deps): update dev-dependencies
continuous-integration/drone/push Build is failing Details
2024-03-14 07:22:29 +00:00
Frederick [Bot] 88fdfb50b7 chore(i18n): update translations via Crowdin
continuous-integration/drone/push Build is passing Details
2024-03-14 00:06:47 +00:00
kolaente 07e84f2abf
fix(reminders): make debounce logic actually work
continuous-integration/drone/push Build is passing Details
2024-03-13 20:11:00 +01:00
kolaente d4605905d3
fix(filters): do not fire filter change immediately
continuous-integration/drone/push Build is failing Details
Related to #2194 (comment)
2024-03-13 19:58:24 +01:00
kolaente 8c826c44d2
fix(webhooks): fire webhooks set on parent projects as well
continuous-integration/drone/push Build is failing Details
2024-03-13 19:41:34 +01:00
kolaente 117079bbda
fix(sentry): do not send api errors to sentry
continuous-integration/drone/push Build is failing Details
2024-03-13 19:31:43 +01:00
kolaente f34577f293
fix(editor): do not use Tiptap to open links when clicking on them, use the browser native attributes instead
continuous-integration/drone/push Build is failing Details
It looks like links are opened twice, when the openOnClick option is enabled. That means they will get opened twice when clicking on them. Disabling that option will not fire the click handler and only rely on browser functionality to open links.

Resolves #2155
2024-03-13 19:23:02 +01:00
kolaente 8ff59d4649
fix(task): navigate back to project when the project was the last page in the history the user visited
continuous-integration/drone/push Build is failing Details
2024-03-13 19:11:49 +01:00
kolaente 7bf2664e55
fix(filters): persist filters in url
continuous-integration/drone/push Build is failing Details
This allows us to keep the filters when navigating back from a task or other url.
2024-03-13 19:03:23 +01:00
kolaente ccb708a56f
fix(reminders): emit reminder changes at the correct time (and make sure they are actually emitted)
continuous-integration/drone/push Build is failing Details
Resolves https://github.com/go-vikunja/vikunja/issues/225
2024-03-13 18:42:55 +01:00
kolaente 1de39b1cd1
fix(quick actions): do not allow creating a task when the current project is a saved filter
continuous-integration/drone/push Build is passing Details
Resolves https://community.vikunja.io/t/creating-task-on-saved-filter-page-doesnt-save/2127
2024-03-13 18:16:18 +01:00
kolaente b3caece256
fix(datepicker): emit date value changes as soon as they happen
continuous-integration/drone/push Build is passing Details
Flatpickr only returns a change event when the value in the input it's referring to changes. That means it will usually only trigger when the focus is moved out of the input field. This is fine most of the time. However, since we're displaying flatpickr in a popup, the whole html dom instance might get destroyed, before the change event had a chance to fire. In that case, it would not update the date value. To fix this, we're now listening on every change and bubble them up as soon as they happen.

Resolves https://community.vikunja.io/t/due-date-confirm-button-not-working/2104
2024-03-13 18:03:49 +01:00
kolaente a6edf1d325
feat(filters): make clear filters button less obvious
continuous-integration/drone/push Build is passing Details
2024-03-13 17:33:34 +01:00
kolaente fc4eed6eb4
fix(filters): lint 2024-03-13 17:21:20 +01:00
kolaente 15215b30a0
fix(filters): rework filter popup button
continuous-integration/drone/push Build is failing Details
2024-03-13 17:19:15 +01:00
kolaente 79577c14b7
fix(filters): set default filter value to only undone tasks
continuous-integration/drone/push Build is failing Details
2024-03-13 17:07:10 +01:00
kolaente 99c5524115
fix(editor): don't allow image upload when it's not possible to do it
continuous-integration/drone/push Build is failing Details
2024-03-13 16:59:57 +01:00
renovate 17e222edfd chore(deps): update dependency happy-dom to v13.8.2
continuous-integration/drone/push Build is passing Details
2024-03-13 07:44:23 +00:00
Frederick [Bot] fb5b2542a5 chore(i18n): update translations via Crowdin
continuous-integration/drone/push Build is passing Details
2024-03-13 00:05:52 +00:00
kolaente 5b2b7f7bdc
fix(kanban): reset done and default bucket when the bucket itself is deleted
continuous-integration/drone/push Build is passing Details
Resolves https://github.com/go-vikunja/vikunja/issues/234
2024-03-12 22:23:35 +01:00
kolaente e1c972d64d
fix(filters): replace project titles at the match position, not anywhere in the filter string
continuous-integration/drone/push Build is passing Details
This fixes a bug where the project title would not be replaced correctly in cases where the project title contained parts of the word "project".

Resolves #2194
2024-03-12 22:05:26 +01:00
kolaente cf6b476b7d
chore: cleanup leftover console.log
continuous-integration/drone/push Build is passing Details
2024-03-12 21:33:24 +01:00
kolaente eb4f880c64
fix(filter): do not show filter footer when creating a filter
continuous-integration/drone/push Build is failing Details
2024-03-12 21:30:59 +01:00
kolaente e44897e0d4
fix(filter): do not match join operator
Partial fix for #2194
2024-03-12 21:30:59 +01:00
renovate 0e2ad5dde6 fix(deps): pin dependency vuemoji-picker to 0.2.1
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is failing Details
2024-03-12 20:07:21 +00:00
Frederick [Bot] 792bf88dcf [skip ci] Updated swagger docs 2024-03-12 19:47:16 +00:00
kolaente a5c51d4b1e feat: emoji reactions for tasks and comments (#2196)
continuous-integration/drone/push Build is passing Details
This PR adds reactions for tasks and comments, similar to what you can do on Gitea, GitHub, Slack and plenty of other tools.

Reviewed-on: #2196
Co-authored-by: kolaente <k@knt.li>
Co-committed-by: kolaente <k@knt.li>
2024-03-12 19:25:58 +00:00
renovate b9c513f681 fix(deps): update sentry-javascript monorepo to v7.106.1
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2024-03-12 09:07:14 +00:00
renovate 40bdecfe0d fix(deps): update dependency date-fns to v3.4.0
continuous-integration/drone/push Build is passing Details
2024-03-12 08:56:15 +00:00
renovate da53c8e7ef chore(deps): update dev-dependencies
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is failing Details
2024-03-12 06:07:20 +00:00
kolaente 85fb8e3443
fix(filters): invalid filter range when converting dates to strings
continuous-integration/drone/push Build is passing Details
Resolves https://community.vikunja.io/t/my-vikunja-instance-creates-tasks-with-due-date-time-of-9am-for-tasks-with-the-word-today-word-in-it/2105/10
2024-03-11 23:28:35 +01:00
Frederick [Bot] 3f380e0d61 [skip ci] Updated swagger docs 2024-03-11 16:41:16 +00:00
kolaente 659de54db1
feat(kanban): do not remove focus from the input after creating a new bucket
continuous-integration/drone/push Build is passing Details
2024-03-11 17:29:28 +01:00
kolaente 49ab90fc19
fix: lint 2024-03-11 17:24:40 +01:00
kolaente 0910d5d2f2
chore(auth): refactor removing empty openid teams to cron job
continuous-integration/drone/push Build is failing Details
2024-03-11 17:20:05 +01:00
kolaente 09d5128050
fix(filters): don't escape valid escaped in queries
continuous-integration/drone/push Build is failing Details
2024-03-11 17:02:04 +01:00
kolaente e097721817
fix(tasks): use correct filter query when filtering 2024-03-11 16:39:27 +01:00
kolaente a66e26678e
feat(filters): pass timezone down when filtering with relative date math
Resolves https://community.vikunja.io/t/my-vikunja-instance-creates-tasks-with-due-date-time-of-9am-for-tasks-with-the-word-today-word-in-it/2105/8
2024-03-11 16:28:25 +01:00
kolaente 6fc3d1e98f
fix: lint
continuous-integration/drone/push Build is failing Details
2024-03-11 15:42:09 +01:00
kolaente dbfe162cd2
fix(filters): label highlighting and autocomplete fields now work with in operator
continuous-integration/drone/push Build was killed Details
Previously, when creating a filter query with the 'in' operator and multiple values, autocompletion and highlighting was not available. This change now implements a split for each value, seperated by a comma.
2024-03-11 15:41:06 +01:00
kolaente 0529f30e77
fix(filters): parse labels and projects correctly when using `in` filter operator 2024-03-11 15:16:39 +01:00
kolaente 3896c680d3
fix(filters): do not require string for in comparator
continuous-integration/drone/push Build is failing Details
2024-03-11 14:36:59 +01:00
kolaente 3b77fff4c9
fix(project): correctly show the number of tasks and projects when deleting a project
continuous-integration/drone/push Build is passing Details
2024-03-11 14:21:42 +01:00
renovate 12fbde8e84 chore(deps): update dev-dependencies
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2024-03-11 05:06:04 +00:00
waza-ari 6c98052176 fix(teams): fix duplicate teams being shown when new public team visibility feature is enabled (#2187)
continuous-integration/drone/push Build is passing Details
Due to the `INNER JOIN` on the `team_members` table and the new `OR` conditions allowing teams with the `isPublic` flag set to `true`, teams are returned multiple times. As we're only after the teams, a simple distinct query should fix the issue.

Co-authored-by: Daniel Herrmann <daniel.herrmann1@gmail.com>
Reviewed-on: #2187
Co-authored-by: waza-ari <daniel.herrmann@makerspace-darmstadt.de>
Co-committed-by: waza-ari <daniel.herrmann@makerspace-darmstadt.de>
2024-03-10 21:42:34 +00:00
kolaente 0057ac5836
fix(migration): only download uploaded attachments
continuous-integration/drone/push Build is passing Details
2024-03-10 18:41:37 +01:00
kolaente 22dcedcd7d
fix(filter): correctly replace project title in filter query
continuous-integration/drone/push Build is failing Details
Resolves https://community.vikunja.io/t/filter-option-to-exclude-a-tag-project-etc/1523/6
2024-03-10 18:32:15 +01:00
kolaente ca0de680ad
fix(migration): import card covers when migrating from Trello
continuous-integration/drone/push Build is passing Details
2024-03-10 16:30:06 +01:00
waza-ari 4bb1d5edfc fix(docs): openid docs whitespace formatting (#2186)
continuous-integration/drone/push Build is passing Details
Co-authored-by: Daniel Herrmann <daniel.herrmann1@gmail.com>
Reviewed-on: #2186
Reviewed-by: konrad <k@knt.li>
Co-authored-by: waza-ari <daniel.herrmann@makerspace-darmstadt.de>
Co-committed-by: waza-ari <daniel.herrmann@makerspace-darmstadt.de>
2024-03-10 14:43:04 +00:00
Elscrux 4b4a7f3c0a docs: fix broken link in migration docs (#2185)
continuous-integration/drone/push Build is passing Details
Seems like one link was broken, this attempts to fix that.

Co-authored-by: Elscrux <nickposer2102@gmail.com>
Reviewed-on: #2185
Reviewed-by: konrad <k@knt.li>
Co-authored-by: Elscrux <elscrux@gmail.com>
Co-committed-by: Elscrux <elscrux@gmail.com>
2024-03-10 14:12:00 +00:00
waza-ari ffa82556e0 feat(teams): add public flags to teams to allow easier sharing with other teams (#2179)
continuous-integration/drone/push Build is failing Details
Resolves #2173
Co-authored-by: Daniel Herrmann <daniel.herrmann1@gmail.com>
Reviewed-on: #2179
Reviewed-by: konrad <k@knt.li>
Co-authored-by: waza-ari <daniel.herrmann@makerspace-darmstadt.de>
Co-committed-by: waza-ari <daniel.herrmann@makerspace-darmstadt.de>
2024-03-10 14:04:32 +00:00
renovate d7fdefcead chore(deps): update golangci/golangci-lint docker tag to v1.56.2 (#2099)
continuous-integration/drone/push Build is failing Details
Co-authored-by: kolaente <k@knt.li>
Reviewed-on: #2099
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2024-03-10 13:47:19 +00:00
Elscrux 1d5517b53a docs: add migrations setup doc (#2183)
continuous-integration/drone/push Build is failing Details
This should hopefully make the migration process for obvious, as discussed here https://community.vikunja.io/t/trello-import-issues/2110/7

Co-authored-by: Elscrux <nickposer2102@gmail.com>
Reviewed-on: #2183
Reviewed-by: konrad <k@knt.li>
Co-authored-by: Elscrux <elscrux@gmail.com>
Co-committed-by: Elscrux <elscrux@gmail.com>
2024-03-10 13:32:27 +00:00
kolaente 25742385ba chore(deps): sign drone config
continuous-integration/drone/push Build is passing Details
2024-03-10 12:24:06 +00:00
renovate 2fa576d9f5 chore(deps): update dependency node to v20.11.1 2024-03-10 12:24:06 +00:00
Frederick [Bot] 116b909d31 [skip ci] Updated swagger docs 2024-03-10 12:18:34 +00:00
konrad e95159a33c feat(filters): query-based filter logic (#2177)
continuous-integration/drone/push Build is passing Details
Reviewed-on: #2177
2024-03-10 12:01:47 +00:00
renovate b340bb418b chore(deps): update dependency happy-dom to v13.7.1
continuous-integration/drone/push Build is failing Details
2024-03-10 11:57:10 +00:00
waza-ari 01fb80d7a1 fix(teams): do not show leave button for OIDC teams (#2181)
continuous-integration/drone/push Build is failing Details
Hide leave team button if team is created through OIDC.

Co-authored-by: Daniel Herrmann <daniel.herrmann1@gmail.com>
Reviewed-on: #2181
Co-authored-by: waza-ari <daniel.herrmann@makerspace-darmstadt.de>
Co-committed-by: waza-ari <daniel.herrmann@makerspace-darmstadt.de>
2024-03-10 11:42:24 +00:00
Daniel Herrmann 6e52db76dc docs: clarify what to use for authurl
continuous-integration/drone/push Build is failing Details
2024-03-10 11:39:23 +00:00
Hangya c5e8ff66fb fix(migration): updated Trello color map to import all labels (#2178)
continuous-integration/drone/push Build is passing Details
Trello has [added 20 color variants](https://www.atlassian.com/blog/trello/20-new-trello-label-colors) that were not imported, added them. Also added a fallback to save labels even if the color is not mapped yet.

Resolves https://community.vikunja.io/t/get-info-about-importation-trello/1968/16
Reviewed-on: #2178
Reviewed-by: konrad <k@knt.li>
Co-authored-by: Hangya <ronkayj@gmail.com>
Co-committed-by: Hangya <ronkayj@gmail.com>
2024-03-10 11:23:38 +00:00
kolaente 009e9b5455
fix(filters): correctly use date filters in gantt chart
continuous-integration/drone/pr Build is passing Details
2024-03-10 12:16:21 +01:00
Frederick [Bot] d963667a29 chore(i18n): update translations via Crowdin
continuous-integration/drone/push Build is passing Details
2024-03-10 00:07:19 +00:00
kolaente 654e95d99f
fix(filters): test fixture
continuous-integration/drone/pr Build is failing Details
2024-03-09 20:21:05 +01:00
kolaente d628471d0e
fix(filters): close filter popup when clicking on show results
continuous-integration/drone/pr Build was killed Details
2024-03-09 20:08:18 +01:00
kolaente 4e6e0608c7
fix(filters): lint 2024-03-09 20:08:17 +01:00
kolaente 6fea5640e8
chore(deps): go mod tidy
continuous-integration/drone/pr Build is failing Details
2024-03-09 19:07:54 +01:00
kolaente b874b02412
feat(filters): highlight label colors in filter 2024-03-09 19:07:32 +01:00
kolaente 084a62e835
fix(filters): layout problems with assignee user avatar 2024-03-09 19:07:31 +01:00
kolaente f3e2b1b89b
fix(filters): remove footer when editing a saved filter 2024-03-09 19:07:31 +01:00
kolaente 4e26fa0b85
fix(filters): correctly use filter in saved filter 2024-03-09 19:07:31 +01:00
kolaente 32e1a2018a
chore: generate swagger docs 2024-03-09 19:07:31 +01:00
kolaente 05d3bb4fb6
fix(filters): swagger docs for kanban buckets 2024-03-09 19:07:31 +01:00
kolaente 38985a8318
fix(filters): pass correct filter query to kanban and gantt loading 2024-03-09 19:07:31 +01:00
kolaente d0b762d761
docs(filter): add filter query explanation 2024-03-09 19:07:31 +01:00
kolaente e0a7f46e5d
feat(filter): fall back to simple search when filter query does not contain any filter inputs 2024-03-09 19:07:31 +01:00
kolaente be253333c2
fix(filter): don't transform anything when input is empty 2024-03-09 19:07:31 +01:00
kolaente 533e778b93
fix(filter): bubble filter query changes up on blur only 2024-03-09 19:07:31 +01:00
kolaente 1d2f3ca546
feat(filter): resolve label and project ids back to titles when loading a filter 2024-03-09 19:07:31 +01:00
kolaente 55b806d311
feat(filter): resolve labels and projects to ids before filtering 2024-03-09 19:07:30 +01:00
kolaente 0c947790e8
feat(filter): add button to show filter results 2024-03-09 19:07:30 +01:00
kolaente b35eb4adbf
fix(filter): correctly pass down options 2024-03-09 19:07:30 +01:00
kolaente a22652b737
feat(filter): remove now unused code 2024-03-09 19:07:30 +01:00
kolaente 4dcd3abe9e
feat(filter): emit filter query 2024-03-09 19:07:30 +01:00
kolaente 5a13c2b423
fix(filter): add role=search to filter card 2024-03-09 19:07:30 +01:00
kolaente 9eac746984
feat(filter): autocomplete for projects 2024-03-09 19:07:30 +01:00
kolaente b1d9dc6fc3
feat(filter): autocomplete for assignees 2024-03-09 19:07:30 +01:00
kolaente 8fa2f6686a
feat(filter): add actual label search when autocompleting 2024-03-09 19:07:30 +01:00
kolaente 9ade917ac4
feat(filter): make the autocomplete look pretty 2024-03-09 19:07:30 +01:00
kolaente 7fc1f27ef5
feat(filter): add autocompletion poc for labels 2024-03-09 19:07:30 +01:00
kolaente 356399f853
chore: format 2024-03-09 19:07:29 +01:00
kolaente 9ed93b181d
fix(filters): make sure spaces before and after are not removed 2024-03-09 19:07:29 +01:00
kolaente 981f2d0e70
fix(filters): color 2024-03-09 19:07:29 +01:00
kolaente 2990c01d0a
fix(filters): make the button look less like a button to avoid spacing problems 2024-03-09 19:07:29 +01:00
kolaente 2daecbc2bc
feat(filters): add basic autocomplete component 2024-03-09 19:07:29 +01:00
kolaente 35487093c6
chore: update lockfile 2024-03-09 19:07:26 +01:00
kolaente 571bcf8996
feat(filters): show user name and avatar for assignee filters 2024-03-09 19:06:52 +01:00
kolaente 388a3a68ba
fix(filters): date filter value not populated 2024-03-09 19:06:52 +01:00
kolaente 992d108bfa
feat(filters): add date values 2024-03-09 19:06:52 +01:00
kolaente c22daab28c
feat(filters): make date values in filter query editable 2024-03-09 19:06:52 +01:00
kolaente 3bd639a110
chore(filters): copy datepicker 2024-03-09 19:06:52 +01:00
kolaente 0d12d72b73
chore(filters): add histoire story file 2024-03-09 19:06:52 +01:00
kolaente 1827102a0a
feat(filters): parse date properties to enable datepicker button 2024-03-09 19:06:52 +01:00
kolaente 4586e525ce
fix(filters): use readable colors for dark and light mode 2024-03-09 19:06:52 +01:00
kolaente c162a5a457
feat(filter): add auto resize for filter query input 2024-03-09 19:06:52 +01:00
kolaente b978d344ca
feat(filter): add basic highlighting filter query component 2024-03-09 19:06:51 +01:00
kolaente 28fa2c517a
feat(filters): make new filter syntax work with Typesense 2024-03-09 19:06:48 +01:00
kolaente bc6d812eb0
fix(filters): lint 2024-03-09 19:06:35 +01:00
kolaente 87c027aafd
chore(filters): cleanup old variables 2024-03-09 19:06:35 +01:00
kolaente 65e1357705
fix(tests): make filter tests work again 2024-03-09 19:06:35 +01:00
kolaente eebfee73d3
fix(filter): correctly filter for buckets 2024-03-09 19:06:35 +01:00
kolaente ef1cc9720c
feat(filter): add in keyword 2024-03-09 19:06:35 +01:00
kolaente c6b682507a
feat(filter): add better error message when passing an invalid filter expression 2024-03-09 19:06:35 +01:00
kolaente 9d3fb6f81d
chore(filter): cleanup 2024-03-09 19:06:35 +01:00
kolaente 3ea81db836
feat(filter): migrate existing saved filters 2024-03-09 19:06:35 +01:00
kolaente 76ed2cff5f
feat(filter): nesting 2024-03-09 19:06:35 +01:00
kolaente e43349618b
feat(filter): more tests 2024-03-09 19:06:35 +01:00
kolaente 9624cc9e97
fix(filter): translate all tests 2024-03-09 19:06:35 +01:00
kolaente 764bc15d49
fix(filter): allow filtering for "project" 2024-03-09 19:06:34 +01:00
kolaente 3fc4aaa2a1
fix(filter): allow filtering on "in" condition 2024-03-09 19:06:34 +01:00
kolaente 9f73e2c5f9
fix(filter): don't crash on empty filter 2024-03-09 19:06:34 +01:00
kolaente c1e137d8ee
fix(filter): make sure single filter condition works 2024-03-09 19:06:34 +01:00
kolaente de320aac72
feat(filters): basic text filter works now 2024-03-09 19:06:34 +01:00
kolaente 307ffe11c4
feat(filters): very basic filter parsing 2024-03-09 19:06:31 +01:00
Christoph Ritzer 86983f50d4 fix(migration): Trello checklists (#2140)
continuous-integration/drone/push Build is passing Details
Trello checklists are now properly converted to html checklists and put into the description.

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: #2140
Reviewed-by: konrad <k@knt.li>
Co-authored-by: Christoph Ritzer <chris@cloumail.at>
Co-committed-by: Christoph Ritzer <chris@cloumail.at>
2024-03-09 09:01:02 +00:00
kolaente e65c3ffe6b
fix(migration): convert trello card descriptions from markdown to html
continuous-integration/drone/push Build is passing Details
2024-03-09 09:31:57 +01:00
renovate 9ad7bc5932 fix(deps): update module github.com/go-sql-driver/mysql to v1.8.0
continuous-integration/drone/push Build is failing Details
2024-03-09 08:24:14 +00:00
kolaente 2101f37aa2
fix(ci): exclude tasks from cron runs
continuous-integration/drone/push Build is passing Details
2024-03-09 09:10:49 +01:00
renovate e8d77e61e4 chore(deps): update dependency @vue/eslint-config-typescript to v13
continuous-integration/drone/push Build is failing Details
2024-03-09 08:00:20 +00:00
renovate 7e69c7cbe0 chore(deps): update dev-dependencies
continuous-integration/drone/push Build is failing Details
2024-03-09 07:59:59 +00:00
Frederick [Bot] f6204c307e chore(i18n): update translations via Crowdin
continuous-integration/drone/push Build is passing Details
2024-03-09 00:06:36 +00:00
kolaente 28d72d4d47
feat(i18n): add pt-br as selectable language in the frontend
continuous-integration/drone/push Build is passing Details
2024-03-08 20:06:48 +01:00
renovate 2c5f8126c5 fix(deps): update module github.com/golang-jwt/jwt/v5 to v5.2.1
continuous-integration/drone/push Build is passing Details
2024-03-08 13:38:47 +00:00
renovate 9aea5830f3 fix(deps): update sentry-javascript monorepo to v7.106.0
continuous-integration/drone/pr Build is failing Details
continuous-integration/drone/push Build is passing Details
2024-03-08 12:07:55 +00:00
kolaente 14452428a2
chore(deps): update github.com/go-jose/go-jose to 3.0.3
continuous-integration/drone/push Build is passing Details
2024-03-08 09:59:55 +01:00
renovate b5682ecc18 chore(deps): update dependency electron to v29.1.1
continuous-integration/drone/push Build is passing Details
2024-03-08 07:59:34 +00:00
renovate 0bec7ee1ab fix(deps): update dependency @intlify/unplugin-vue-i18n to v3.0.1
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is failing Details
2024-03-08 02:07:52 +00:00
renovate d1a3eb9701 fix(deps): update dependency @intlify/unplugin-vue-i18n to v3
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2024-03-07 12:06:52 +00:00
renovate cba09c8eb1 chore(deps): update dev-dependencies
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2024-03-07 07:06:40 +00:00
kolaente dc291a51f5
fix(migration): do not expire trello token
continuous-integration/drone/push Build is passing Details
2024-03-06 15:13:54 +01:00
renovate e5e66d73f5 chore(deps): update dev-dependencies
continuous-integration/drone/pr Build is failing Details
continuous-integration/drone/push Build is passing Details
2024-03-06 00:06:22 +00:00
waza-ari d69fc28125 fix(openid): OIDC teams should not have admins (#2161)
continuous-integration/drone/push Build is passing Details
This PR fixes an issue discussed in #2152. Before this PR, the user who triggered team creation automatically got the admin flag set for this group, which makes perfect sense for the normal UI workflow. OIDC managed teams cannot be edited in Vikunja, and they're created automatically by the first user logging in having this team assigned. This PR therefore makes sure that OIDC managed team members do not receive the admin flag.

Co-authored-by: Daniel Herrmann <daniel.herrmann1@gmail.com>
Reviewed-on: #2161
Reviewed-by: konrad <k@knt.li>
Co-authored-by: waza-ari <daniel.herrmann@makerspace-darmstadt.de>
Co-committed-by: waza-ari <daniel.herrmann@makerspace-darmstadt.de>
2024-03-05 22:08:39 +00:00
renovate 4793282baf fix(deps): update src.techknowlogick.com/xgo digest to 770b8ea
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2024-03-05 19:06:41 +00:00
302 changed files with 30192 additions and 15909 deletions

View File

@ -3,6 +3,11 @@ kind: pipeline
type: docker
name: build-and-test
trigger:
event:
exclude:
- cron
workspace:
base: /go
path: src/code.vikunja.io/api
@ -134,7 +139,7 @@ steps:
event: [ push, tag, pull_request ]
- name: api-lint
image: golangci/golangci-lint:v1.55.2
image: golangci/golangci-lint:v1.57.2
pull: always
environment:
GOPROXY: 'https://goproxy.kolaente.de'
@ -359,7 +364,7 @@ steps:
- api-build
- name: frontend-dependencies
image: node:20.11.0-alpine
image: node:20.12.2-alpine
pull: always
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
@ -373,7 +378,7 @@ steps:
# - restore-cache
- name: frontend-lint
image: node:20.11.0-alpine
image: node:20.12.2-alpine
pull: always
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
@ -385,7 +390,7 @@ steps:
- frontend-dependencies
- name: frontend-build-prod
image: node:20.11.0-alpine
image: node:20.12.2-alpine
pull: always
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
@ -397,7 +402,7 @@ steps:
- frontend-dependencies
- name: frontend-test-unit
image: node:20.11.0-alpine
image: node:20.12.2-alpine
pull: always
commands:
- cd frontend
@ -408,7 +413,7 @@ steps:
- name: frontend-typecheck
failure: ignore
image: node:20.11.0-alpine
image: node:20.12.2-alpine
pull: always
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
@ -456,7 +461,6 @@ steps:
- cp -r dist-test dist-preview
# Override the default api url used for preview
- sed -i 's|http://localhost:3456|https://try.vikunja.io|g' dist-preview/index.html
- apk add --no-cache perl-utils
# create via:
# `shasum -a 384 ./scripts/deploy-preview-netlify.mjs > ./scripts/deploy-preview-netlify.mjs.sha384`
- shasum -a 384 -c ./scripts/deploy-preview-netlify.mjs.sha384
@ -528,6 +532,9 @@ trigger:
ref:
- refs/heads/main
- "refs/tags/**"
event:
exclude:
- cron
steps:
# Needed to get the versions right as they depend on tags
@ -537,7 +544,7 @@ steps:
- git fetch --tags
- name: frontend-dependencies
image: node:20.11.0-alpine
image: node:20.12.2-alpine
pull: always
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
@ -549,7 +556,7 @@ steps:
- pnpm install --fetch-timeout 100000
- name: frontend-build
image: node:20.11.0-alpine
image: node:20.12.2-alpine
pull: always
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
@ -709,7 +716,7 @@ steps:
# Build os packages and push it to our bucket
- name: build-os-packages-unstable
image: goreleaser/nfpm:v2.35.3
image: goreleaser/nfpm:v2.36.1
pull: always
commands:
- apk add git go
@ -725,7 +732,7 @@ steps:
depends_on: [ after-build-compress ]
- name: build-os-packages-version
image: goreleaser/nfpm:v2.35.3
image: goreleaser/nfpm:v2.36.1
pull: always
commands:
- apk add git go
@ -808,6 +815,9 @@ trigger:
ref:
- refs/heads/main
- "refs/tags/**"
event:
exclude:
- cron
steps:
- name: fetch-tags
@ -891,7 +901,7 @@ steps:
- git fetch --tags
- name: build
image: node:20.11.0-alpine
image: node:20.12.2-alpine
pull: always
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
@ -952,7 +962,7 @@ steps:
- git fetch --tags
- name: build
image: node:20.11.0-alpine
image: node:20.12.2-alpine
pull: always
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
@ -1145,6 +1155,9 @@ trigger:
ref:
- refs/heads/main
- "refs/tags/**"
event:
exclude:
- cron
steps:
- name: fetch-tags
@ -1360,6 +1373,9 @@ trigger:
ref:
- refs/heads/main
- "refs/tags/**"
event:
exclude:
- cron
depends_on:
- build-and-test
@ -1384,6 +1400,6 @@ steps:
- failure
---
kind: signature
hmac: 008b86263a8d03806da907c128a837a380901f1a2190a658c22d4e06cadc1b64
hmac: f2753482faf9e2a3d34a9111587a75dfb4519cb77002cc64a51266540fd2478e
...

1
.gitignore vendored
View File

@ -28,3 +28,4 @@ vendor/
os-packages/
mage_output_file.go
mage-static
.direnv/

View File

@ -18,6 +18,7 @@ linters:
- scopelint # Obsolete, using exportloopref instead
- durationcheck
- goconst
- musttag
presets:
- bugs
- unused
@ -99,3 +100,11 @@ issues:
- path: pkg/modules/migration/ticktick/ticktick_test.go
linters:
- testifylint
- path: pkg/migration/*
text: "parameter 'tx' seems to be unused, consider removing or renaming it as"
linters:
- revive
- path: pkg/models/typesense.go
text: 'structtag: struct field Position repeats json tag "position" also at'
linters:
- govet

View File

@ -5,7 +5,7 @@
"eslint.packageManager": "pnpm",
"editor.formatOnSave": false,
"editor.codeActionsOnSave": {
"source.fixAll": true
"source.fixAll": "explicit"
},
"eslint.format.enable": true,
"[javascript]": {

View File

@ -1,10 +1,11 @@
# syntax=docker/dockerfile:1
FROM --platform=$BUILDPLATFORM node:20.11.0-alpine AS frontendbuilder
FROM --platform=$BUILDPLATFORM node:20.12.2-alpine AS frontendbuilder
WORKDIR /build
ENV PNPM_CACHE_FOLDER .cache/pnpm/
ENV PUPPETEER_SKIP_DOWNLOAD true
ENV CYPRESS_INSTALL_BINARY 0
COPY frontend/ ./

View File

@ -29,7 +29,7 @@ If you find any security-related issues you don't want to disclose publicly, ple
## Features
See [the features page](https://vikunja.io/features/) on our website for a more exaustive list or
See [the features page](https://vikunja.io/features/) on our website for a more exhaustive list or
try it on [try.vikunja.io](https://try.vikunja.io)!
## Docs

View File

@ -62,6 +62,9 @@ service:
allowiconchanges: true
# Allow using a custom logo via external URL.
customlogourl: ''
# Enables the public team feature. If enabled, it is possible to configure teams to be public, which makes them
# discoverable when sharing a project, therefore not only showing teams the user is member of.
enablepublicteams: false
sentry:
# If set to true, enables anonymous error tracking of api errors via Sentry. This allows us to gather more
@ -153,7 +156,7 @@ mailer:
username: "user"
# SMTP password
password: ""
# Wether to skip verification of the tls certificate on the server
# Whether to skip verification of the tls certificate on the server
skiptlsverify: false
# The default from address when sending emails
fromemail: "mail@vikunja"
@ -303,7 +306,7 @@ auth:
# The provider needs to support the `openid`, `profile` and `email` scopes.<br/>
# **Note:** Some openid providers (like Gitlab) only make the email of the user available through OpenID if they have set it to be publicly visible.
# If the email is not public in those cases, authenticating will fail.
# **Note 2:** The frontend expects the third party to rediect the user <frontend-url>/auth/openid/<auth key> after authentication. Please make sure to configure the redirect url in your third party auth service accordingly if you're using the default vikunja frontend.
# +**Note 2:** The frontend expects the third party to redirect the user <frontend-url>/auth/openid/<auth key> after authentication. Please make sure to configure the redirect url in your third party auth service accordingly if you're using the default vikunja frontend.
# The frontend will automatically provide the API with the redirect url, composed from the current url where it's hosted.
# If you want to use the desktop client with OpenID, make sure to allow redirects to `127.0.0.1`.
# Take a look at the [default config file](https://kolaente.dev/vikunja/vikunja/src/branch/main/config.yml.sample) for more information about how to configure openid authentication.
@ -365,8 +368,8 @@ defaultsettings:
webhooks:
# Whether to enable support for webhooks
enabled: true
# The timout in seconds until a webhook request fails when no response has been received.
timoutseconds: 30
# The timeout in seconds until a webhook request fails when no response has been received.
timeoutseconds: 30
# The URL of [a mole instance](https://github.com/frain-dev/mole) to use to proxy outgoing webhook requests. You should use this and configure appropriately if you're not the only one using your Vikunja instance. More info about why: https://webhooks.fyi/best-practices/webhook-providers#implement-security-on-egress-communication. Must be used in combination with `webhooks.password` (see below).
proxyurl:
# The proxy password to use when authenticating against the proxy.

View File

@ -1,9 +0,0 @@
{ pkgs ? import <nixpkgs> {}
}:
pkgs.mkShell {
name="electron-dev";
buildInputs = [
pkgs.electron
];
}

View File

@ -51,11 +51,11 @@
}
},
"devDependencies": {
"electron": "29.1.0",
"electron": "29.3.3",
"electron-builder": "24.13.3"
},
"dependencies": {
"connect-history-api-fallback": "2.0.0",
"express": "4.18.3"
"express": "4.19.2"
}
}

2325
desktop/pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -24,7 +24,7 @@ In other packages, use the `db.NewSession()` method to get a new database sessio
To add a new table to the database, create the struct and [add a migration for it]({{< ref "db-migrations.md" >}}).
To learn more about how to configure your struct to create "good" tables, refer to [the xorm documentaion](https://xorm.io/docs/).
To learn more about how to configure your struct to create "good" tables, refer to [the xorm documentation](https://xorm.io/docs/).
In most cases you will also need to implement the `TableName() string` method on the new struct to make sure the table name matches the rest of the tables - plural.

View File

@ -26,6 +26,15 @@ If you plan to do a bigger change, it is better to open an issue for discussion
The main repo is [`vikunja/vikunja`](https://kolaente.dev/vikunja/vikunja), it contains all code for the api, frontend and desktop applications.
## Where to file issues
You can file issues on [the Gitea repo](https://kolaente.dev/vikunja/vikunja) or [on the GitHub mirror](https://github.com/go-vikunja/vikunja), when you don't want to create an account on the Gitea instance.
Please note that due to a spam problem, we need to manually enable accounts on Gitea after you've registered there.
To get that started, please reach out on another channel with your username.
Another option is [the community forum](https://community.vikunja.io), especially if you want to discuss a feature in more detail.
## API
You'll need at least Go 1.21 to build Vikunja's api.

View File

@ -101,7 +101,7 @@ You should also document the routes with [swagger annotations]({{< ref "swagger-
There is a method available in the `migration` package which takes a fully nested Vikunja structure and creates it with all relations.
This means you start by adding a project, then add projects inside that project, then tasks in the lists and so on.
In general, it is reccommended to have one root project with all projects of the other service as child projects.
In general, it is recommended to have one root project with all projects of the other service as child projects.
The root structure must be present as `[]*models.ProjectWithTasksAndBuckets`. It allows to represent all of Vikunja's hierarchy as a single data structure.

View File

@ -16,7 +16,7 @@ menu:
The following parts are about the kinds of tests in the API package and how to run them.
### Prerequesites
### Prerequisites
To run any kind of test, you need to specify Vikunja's [root path](https://vikunja.io/docs/config-options/#rootpath).
This is required to make sure all test fixtures are correctly loaded.
@ -39,7 +39,7 @@ This definition is a bit blurry, but we haven't found a better one yet.
All integration tests live in `pkg/integrations`.
You can run them by executing `mage test:integration`.
The integration tests use the same config and fixtures as the unit tests and therefor have the same options available,
The integration tests use the same config and fixtures as the unit tests and therefore have the same options available,
see at the beginning of this document.
To run integration tests, use `mage test:integration`.

View File

@ -18,6 +18,7 @@ To fully build Vikunja from source files, you need to build the api and frontend
1. Make sure you have git installed
2. Clone the repo with `git clone https://code.vikunja.io/vikunja` and switch into the directory.
3. Check out the version you want to build with `git checkout VERSION` - replace `VERSION` with the version want to use. If you don't do this, you'll build the [latest unstable build]({{< ref "versions.md">}}), which might contain bugs.
## Frontend
@ -35,7 +36,7 @@ That means compiling it boils down to these steps:
1. Make sure [Go](https://golang.org/doc/install) is properly installed on your system. You'll need at least Go `1.21`.
2. Make sure [Mage](https://magefile.org) is properly installed on your system.
3. If you did not build the frontend in the steps before, you need to either do that or create a dummy index file with `mkdir -p frontend/dist && touch index.html`.
3. If you did not build the frontend in the steps before, you need to either do that or create a dummy index file with `mkdir -p frontend/dist && touch frontend/dist/index.html`.
4. Run `mage build` in the source of the main repo. This will build a binary in the root of the repo which will be able to run on your system.
### Build for different architectures

View File

@ -346,6 +346,18 @@ Full path: `service.customlogourl`
Environment path: `VIKUNJA_SERVICE_CUSTOMLOGOURL`
### enablepublicteams
Enables the public team feature. If enabled, it is possible to configure teams to be public, which makes them
discoverable when sharing a project, therefore not only showing teams the user is member of.
Default: `false`
Full path: `service.enablepublicteams`
Environment path: `VIKUNJA_SERVICE_ENABLEPUBLICTEAMS`
---
## sentry
@ -768,7 +780,7 @@ Environment path: `VIKUNJA_MAILER_PASSWORD`
### skiptlsverify
Wether to skip verification of the tls certificate on the server
Whether to skip verification of the tls certificate on the server
Default: `false`
@ -1211,7 +1223,7 @@ OpenID configuration will allow users to authenticate through a third-party Open
The provider needs to support the `openid`, `profile` and `email` scopes.<br/>
**Note:** Some openid providers (like Gitlab) only make the email of the user available through OpenID if they have set it to be publicly visible.
If the email is not public in those cases, authenticating will fail.
**Note 2:** The frontend expects the third party to rediect the user <frontend-url>/auth/openid/<auth key> after authentication. Please make sure to configure the redirect url in your third party auth service accordingly if you're using the default vikunja frontend.
+**Note 2:** The frontend expects the third party to redirect the user <frontend-url>/auth/openid/<auth key> after authentication. Please make sure to configure the redirect url in your third party auth service accordingly if you're using the default vikunja frontend.
The frontend will automatically provide the API with the redirect url, composed from the current url where it's hosted.
If you want to use the desktop client with OpenID, make sure to allow redirects to `127.0.0.1`.
Take a look at the [default config file](https://kolaente.dev/vikunja/vikunja/src/branch/main/config.yml.sample) for more information about how to configure openid authentication.
@ -1410,15 +1422,15 @@ Full path: `webhooks.enabled`
Environment path: `VIKUNJA_WEBHOOKS_ENABLED`
### timoutseconds
### timeoutseconds
The timout in seconds until a webhook request fails when no response has been received.
The timeout in seconds until a webhook request fails when no response has been received.
Default: `30`
Full path: `webhooks.timoutseconds`
Full path: `webhooks.timeoutseconds`
Environment path: `VIKUNJA_WEBHOOKS_TIMOUTSECONDS`
Environment path: `VIKUNJA_WEBHOOKS_TIMEOUTSECONDS`
### proxyurl

View File

@ -40,7 +40,7 @@ chown 1000 $PWD/files
You'll need to do this before running any of the examples on this page.
Vikunja will not try to aquire ownership of the files folder, as that would mean it had to run as root.
Vikunja will not try to acquire ownership of the files folder, as that would mean it had to run as root.
## PostgreSQL
@ -312,7 +312,7 @@ To do that, you can
* Either activate SSH and paste the adapted compose file in a terminal (using Putty or similar)
* Without activating SSH as a "custom script" (go to Control Panel / Task Scheduler / Create / Scheduled Task / User-defined script)
* Without activating SSH, by using Portainer (you have to install first, check out [this tutorial](https://www.portainer.io/blog/how-to-install-portainer-on-a-synology-nas) for exmple):
* Without activating SSH, by using Portainer (you have to install first, check out [this tutorial](https://www.portainer.io/blog/how-to-install-portainer-on-a-synology-nas) for example):
1. Go to **Dashboard / Stacks** click the button **"Add Stack"**
2. Give it the name Vikunja and paste the adapted docker compose file
3. Deploy the Stack with the "Deploy Stack" button:

View File

@ -37,7 +37,7 @@ This document provides an overview and instructions for the different methods:
* [FreeBSD](#freebsd--freenas)
* [Kubernetes]({{< ref "k8s.md" >}})
And after you installed Vikunja, you may want to check out these other ressources:
And after you installed Vikunja, you may want to check out these other resources:
* [Configuration]({{< ref "config.md">}})
* [UTF-8 Settings]({{< ref "utf-8.md">}})
@ -225,10 +225,13 @@ go install github.com/magefile/mage
```
mkdir /mnt/GO/code.vikunja.io
cd /mnt/GO/code.vikunja.io
git clone https://code.vikunja.io/api
cd /mnt/GO/code.vikunja.io/api
git clone https://code.vikunja.io/vikunja
cd vikunja
```
**Note:** Check out the version you want to build with `git checkout VERSION` - replace `VERSION` with the version want to use.
If you don't do this, you'll build the [latest unstable build]({{< ref "versions.md">}}), which might contain bugs.
### Compile binaries
```

View File

@ -0,0 +1,57 @@
---
date: "2023-03-09:00:00+02:00"
title: "Migration from third-party services"
draft: false
type: "doc"
menu:
sidebar:
parent: "setup"
weight: 5
---
# Migration from third-party services
There are several importers available for third-party services like Trello, Microsoft To Do or Todoist.
All available migration options can be found [here](https://kolaente.dev/vikunja/vikunja/src/branch/main/config.yml.sample#L218).
You can develop migrations for more services, see the [documentation]({{< ref "../development/migration.md">}}) for more info.
{{< table_of_contents >}}
## Trello
### Config Setup
Log into Trello and navigate to the [site](https://trello.com/app-key) to manage your API keys.
Save your `Personal Key` for later and add your Vikunja domain to the Allowed Origins list.
Create a `config.yml` file based on [default config file](https://kolaente.dev/vikunja/vikunja/src/branch/main/config.yml.sample) if you haven't already.
- Copy the [Trello options](https://kolaente.dev/vikunja/vikunja/src/branch/main/config.yml.sample#L233) from the default config file
- Set `enable` to true
- Set `key` to your [trello API key](https://trello.com/app-key)
- Replace `<frontend url>` in `redirecturl` with your url
### Config Loading
To load the config with Vikunja, see the [installation]({{< ref "install.md">}}) documentation for instructions to load the `config.yml` file and start Vikunja.
### Config Loading with Docker Compose
In case you are using Docker Compose you need to edit `docker-compose.yml` to load `config.yml`.
Mount the `config.yml` file into the Vikunja container, by adding this line to the volumes of the Vikunja container and replacing the `./path/to/config.yml` with the relative path from the `docker-compose.yml` to your `config.yml`.
```yaml
volumes:
- ./path/to/config.yml:/etc/vikunja/config.yml
```
After all the setup is done, start Vikunja as shown in the [Docker Compose setup]({{< ref "full-docker-example.md">}}).
### Start the Migration Process
Log in, and navigate to Settings > Import from other services. In the list of available third-party services, there should be a Trello icon now.
If not, ensure that you are properly loading your config file. Refer to the Vikunja log to see if the config file was loaded or not.
In case the config file was loaded, and there is no Trello icon, make sure your [config setup](#config-setup) is correct.
Click on Trello and on Get Started. This will redirect you to Trello where you need to allow Vikunja Migration to access your account. In case there is an error when being directed to Trello, make sure that your Vikunja domain is in your Trello Allowed Origins list.
Once this is done, you will be redirected to Vikunja which should tell you that the migration is in progress now. Note that this can take up to several hours depending on the amount of boards in your Trello account.

View File

@ -17,7 +17,7 @@ Vikunja allows for authentication with an external identity source such as Authe
## OpenID Connect Overview
OpenID Connect is a standardized identity layer built on top of the more generic OAuth 2.0 specification, simplying interaction between the involved parties significantly.
OpenID Connect is a standardized identity layer built on top of the more generic OAuth 2.0 specification, simplifying interaction between the involved parties significantly.
While the [OpenID specification](https://openid.net/specs/openid-connect-core-1_0.html#Overview) is worth a read, we summarize the most important basics here.
The involved parties are:
@ -34,6 +34,22 @@ Claims in turn are assertions containing information about the token bearer, usu
**Scopes** are requested by the client when redirecting the end-user to the Authorization Server for authentication, and indirectly control which claims are included in the resulting tokens.
There's certain default scopes, but its also possible to define custom scopes, which are used by the feature assigning users to Teams automatically.
## Supported and required claims
Vikunja only requires a few claims to be present in the ID token to successfully authenticate the user.
Additional claims can be added though to customize behaviour during user creation.
The following table gives an overview about the claims supported by Vikunja. The scope column lists the scope that should request the claim according to the [OpenID Connect Standard](https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims). It omits the claims such as `sub` or `issuer` required by the `openid` scope, which must always be present.
| Claim | Type | Scope | Comment |
| ------|------|-------|---------|
| email | required | email | Sets the email address of the user. Taken from the `userinfo` endpoint if not present in ID token. User creation fails if claim not present and userinfo lookup fails. |
| name | optional | profile | Sets the display name of the user. Taken from the `userinfo` endpoint if not present in ID token. |
| preferred_username | optional | profile | Sets the username of the user. Taken from the `userinfo` endpoint if not present in ID token. If this also doesn't contain the claim, use the `nickname` claim from `userinfo` instead. If that one is not available either, the username is auto-generated by Vikunja. |
| vikunja_groups | optional | N/A | Can be used to automatically assign users to teams. See below for a more detailed explanation about the expected format and implementation examples. |
If one of the claims `email`, `name` or `preferred_username` is missing from the ID token, Vikunja will attempt to query the `userinfo` endpoint to obtain the information from there.
## Configuring OIDC Authentication
To achieve authentication via an external provider, it is required to (a) configure a confidential Client on your OAuth 2.0 provider and (b) configure Vikunja to authenticate against this provider.
@ -51,7 +67,7 @@ In general, this involves the following steps at a minimum:
- Make sure the required scopes (`openid profile email` are the default scopes used by Vikunja) are supported
- Optional: configure an additional scope for automatic team assignment, see below for details
More detailled instructions for various different identity providers can be [found here]({{< ref "openid-examples.md">}})
More detailed instructions for various different identity providers can be [found here]({{< ref "openid-examples.md">}})
### Step 2: Configure Vikunja
@ -64,13 +80,17 @@ auth:
redirecturl: https://vikunja.mydomain.com/auth/openid/ <---- slash at the end is important
providers:
- name: <provider-name>
authurl: <auth-url>
authurl: <auth-url> <----- Used for OIDC Discovery, usually the issuer
clientid: <vikunja client-id>
clientsecret: <vikunja client-secret>
scope: openid profile email
```
The values for `authurl` can be obtained from the Metadata of your provider, while `clientid` and `clientsecret` are obtained when configuring the client.
The value for `authurl` can be obtained from the metadata of your provider.
Note that the `authurl` is used for [OIDC Discovery](https://openid.net/specs/openid-connect-discovery-1_0.html).
Typically, you'll want to use the `issuer` URL as found in the provider metadata.
The values for `clientid` and `clientsecret` are typically obtained when configuring the client.
The scope usually doesn't need to be specified or changed, unless you want to configure the automatic team assignment.
Optionally it is possible to disable local authentication and therefore forcing users to login via OpenID connect:
@ -83,7 +103,7 @@ auth:
## Automatically assign users to teams
Vikunja is capable of automatically adding users to a team based on OIDC claims added by the identity provider.
Starting with version 0.24.0, Vikunja is capable of automatically adding users to a team based on OIDC claims added by the identity provider.
If configured, Vikunja will sync teams, automatically create new ones and make sure the members are part of the configured teams.
Teams which exist only because they were created from oidc attributes are not editable in Vikunja.
@ -95,7 +115,7 @@ It depends on the provider being used as well as the preferences of the administ
Typically you'd want to request an additional scope (e.g. `vikunja_scope`) which then triggers the identity provider to add the claim.
If the `vikunja_groups` is part of the **ID token**, Vikunja will start the procedure and import teams and team memberships.
The claim structure expexted by Vikunja is as follows:
The minimal claim structure expected by Vikunja is as follows:
```json
{
@ -112,6 +132,21 @@ The claim structure expexted by Vikunja is as follows:
}
```
It is also possible to pass the `description` and the `isPublic` flag as optional parameters. If not present, the description will be empty and project visibility defaults to false.
```json
{
"vikunja_groups": [
{
"name": "team 3",
"oidcID": 33349,
"description": "My Team Description",
"isPublic": true
},
]
}
```
For each team, you need to define a team `name` and an `oidcID`, where the `oidcID` can be any string with a length of less than 250 characters.
The `oidcID` is used to uniquely identify the team, so please make sure to keep this unique.

View File

@ -17,7 +17,7 @@ However, you can still run it in a subdirectory but need to build the frontend y
First, make sure you're able to build the frontend from source.
Check [the guide about building from source]({{< ref "build-from-source.md">}}#frontend) about that.
### Dynamicly set with build command
### Dynamically set with build command
Run the build with the `VIKUNJA_FRONTEND_BASE` variable specified.

View File

@ -10,7 +10,7 @@ menu:
# Vikunja Versions
The Vikunja api and frontend are available in two different release flavors.
Vikunja api is available in two different release flavors.
{{< table_of_contents >}}
@ -30,6 +30,9 @@ There might be multiple new such builds a day.
Versions contain the last stable version, the number of commits since then and the commit the currently running binary was built from.
They look like this: `v0.18.1+269-5cc4927b9e`
Since a release is also cut from the main branch at some point, features from unstable will eventually become available in stable releases.
At the point in time of a new version release, the unstable build is the same exact thing.
The demo instance at [try.vikunja.io](https://try.vikunja.io) automatically updates and always runs the last unstable build.
## Switching between versions

View File

@ -72,6 +72,7 @@ Vikunja **currently does not** support these properties:
* [Evolution](https://wiki.gnome.org/Apps/Evolution/)
* [OpenTasks](https://opentasks.app/) & [DAVx⁵](https://www.davx5.com/)
* [Tasks (Android)](https://tasks.org/)
* [Korganizer](https://apps.kde.org/korganizer/)
### Not working

View File

@ -28,13 +28,21 @@ All commands use the same standard [config file]({{< ref "../setup/config.md">}}
## Using the cli in docker
When running Vikunja in docker, you'll need to execute all commands in the `api` container.
When running Vikunja in docker, you'll need to execute all commands in the `vikunja` container.
Instead of running the `vikunja` binary directly, run it like this:
```sh
docker exec <name of the vikunja api container> /app/vikunja/vikunja <subcommand>
docker exec <name of the vikunja container> /app/vikunja/vikunja <subcommand>
```
If you need to run a bunch of Vikunja commands, you can also create a shell alias for it:
```sh
alias vikunja-docker='docker exec <name of the vikunja container> /app/vikunja/vikunja'
```
Then use it as `vikunja-docker <subcommand>`.
### `dump`
Creates a zip file with all vikunja-related files.
@ -139,7 +147,7 @@ Flags:
#### `user delete`
Start the user deletion process.
If called without the `--now` flag, this command will only trigger an email to the user in order for them to confirm and start the deletion process (this is the same behavoir as if the user requested their deletion via the web interface).
If called without the `--now` flag, this command will only trigger an email to the user in order for them to confirm and start the deletion process (this is the same behavior as if the user requested their deletion via the web interface).
With the flag the user is deleted **immediately**.
**USE WITH CAUTION.**

View File

@ -51,51 +51,55 @@ This document describes the different errors Vikunja can return.
| ErrorCode | HTTP Status Code | Description |
|-----------|------------------|-------------|
| 2001 | 400 | ID cannot be empty or 0. |
| 2002 | 400 | Some of the request data was invalid. The response contains an aditional array with all invalid fields. |
| 2002 | 400 | Some of the request data was invalid. The response contains an additional array with all invalid fields. |
## Project
| ErrorCode | HTTP Status Code | Description |
|-----------|------------------|-------------------------------------------------------------------------------------------------------------------------------------|
| 3001 | 404 | The project does not exist. |
| 3004 | 403 | The user needs to have read permissions on that project to perform that action. |
| 3005 | 400 | The project title cannot be empty. |
| 3006 | 404 | The project share does not exist. |
| 3007 | 400 | A project with this identifier already exists. |
| ErrorCode | HTTP Status Code | Description |
|-----------|------------------|------------------------------------------------------------------------------------------------------------------------------------|
| 3001 | 404 | The project does not exist. |
| 3004 | 403 | The user needs to have read permissions on that project to perform that action. |
| 3005 | 400 | The project title cannot be empty. |
| 3006 | 404 | The project share does not exist. |
| 3007 | 400 | A project with this identifier already exists. |
| 3008 | 412 | The project is archived and can therefore only be accessed read only. This is also true for all tasks associated with this project. |
| 3009 | 412 | The project cannot belong to a dynamically generated parent project like "Favorites". |
| 3010 | 412 | This project cannot be a child of itself. |
| 3011 | 412 | This project cannot have a cyclic relationship to a parent project. |
| 3012 | 412 | This project cannot be deleted because a user has set it as their default project. |
| 3013 | 412 | This project cannot be archived because a user has set it as their default project. |
| 3009 | 412 | The project cannot belong to a dynamically generated parent project like "Favorites". |
| 3010 | 412 | This project cannot be a child of itself. |
| 3011 | 412 | This project cannot have a cyclic relationship to a parent project. |
| 3012 | 412 | This project cannot be deleted because a user has set it as their default project. |
| 3013 | 412 | This project cannot be archived because a user has set it as their default project. |
| 3014 | 404 | This project view does not exist. |
## Task
| ErrorCode | HTTP Status Code | Description |
|-----------|------------------|----------------------------------------------------------------------------|
| 4001 | 400 | The project task text cannot be empty. |
| 4002 | 404 | The project task does not exist. |
| 4003 | 403 | All bulk editing tasks must belong to the same project. |
| 4004 | 403 | Need at least one task when bulk editing tasks. |
| 4005 | 403 | The user does not have the right to see the task. |
| 4006 | 403 | The user tried to set a parent task as the task itself. |
| 4007 | 400 | The user tried to create a task relation with an invalid kind of relation. |
| 4008 | 409 | The user tried to create a task relation which already exists. |
| 4009 | 404 | The task relation does not exist. |
| 4010 | 400 | Cannot relate a task with itself. |
| 4011 | 404 | The task attachment does not exist. |
| 4012 | 400 | The task attachment is too large. |
| 4013 | 400 | The task sort param is invalid. |
| 4014 | 400 | The task sort order is invalid. |
| 4015 | 404 | The task comment does not exist. |
| 4016 | 400 | Invalid task field. |
| 4017 | 400 | Invalid task filter comparator. |
| 4018 | 400 | Invalid task filter concatinator. |
| 4019 | 400 | Invalid task filter value. |
| 4020 | 400 | The provided attachment does not belong to that task. |
| 4021 | 400 | This user is already assigned to that task. |
| 4022 | 400 | The task has a relative reminder which does not specify relative to what. |
| 4023 | 409 | Tried to create a task relation which would create a cycle. |
| 4001 | 400 | The project task text cannot be empty. |
| 4002 | 404 | The project task does not exist. |
| 4003 | 403 | All bulk editing tasks must belong to the same project. |
| 4004 | 403 | Need at least one task when bulk editing tasks. |
| 4005 | 403 | The user does not have the right to see the task. |
| 4006 | 403 | The user tried to set a parent task as the task itself. |
| 4007 | 400 | The user tried to create a task relation with an invalid kind of relation. |
| 4008 | 409 | The user tried to create a task relation which already exists. |
| 4009 | 404 | The task relation does not exist. |
| 4010 | 400 | Cannot relate a task with itself. |
| 4011 | 404 | The task attachment does not exist. |
| 4012 | 400 | The task attachment is too large. |
| 4013 | 400 | The task sort param is invalid. |
| 4014 | 400 | The task sort order is invalid. |
| 4015 | 404 | The task comment does not exist. |
| 4016 | 400 | Invalid task field. |
| 4017 | 400 | Invalid task filter comparator. |
| 4018 | 400 | Invalid task filter concatinator. |
| 4019 | 400 | Invalid task filter value. |
| 4020 | 400 | The provided attachment does not belong to that task. |
| 4021 | 400 | This user is already assigned to that task. |
| 4022 | 400 | The task has a relative reminder which does not specify relative to what. |
| 4023 | 409 | Tried to create a task relation which would create a cycle. |
| 4024 | 400 | The provided filter expression is invalid. |
| 4025 | 400 | The reaction kind is invalid. |
| 4026 | 400 | You must provide a project view ID when sorting by position. |
## Team

View File

@ -0,0 +1,67 @@
---
title: "Filters"
date: 2024-03-09T19:51:32+02:00
draft: false
type: doc
menu:
sidebar:
parent: "usage"
---
# Filter Syntax
To filter tasks via the api, you can use a query syntax similar to SQL.
This document is about filtering via the api. To filter in Vikunja's web ui, check out the help text below the filter query input.
{{< table_of_contents >}}
## Available fields
The available fields for filtering include:
* `done`: Whether the task is completed or not
* `priority`: The priority level of the task (1-5)
* `percentDone`: The percentage of completion for the task (0-100)
* `dueDate`: The due date of the task
* `startDate`: The start date of the task
* `endDate`: The end date of the task
* `doneAt`: The date and time when the task was completed
* `assignees`: The assignees of the task
* `labels`: The labels associated with the task
* `project`: The project the task belongs to (only available for saved filters, not on a project level)
You can date math to set relative dates. Click on the date value in a query to find out more.
All strings must be either single-word or enclosed in `"` or `'`. This extends to date values like `2024-03-11`.
## Operators
The available operators for filtering include:
* `!=`: Not equal to
* `=`: Equal to
* `>`: Greater than
* `>=`: Greater than or equal to
* `<`: Less than
* `<=`: Less than or equal to
* `like`: Matches a pattern (using wildcard `%`)
* `in`: Matches any value in a comma-seperated list of values
To combine multiple conditions, you can use the following logical operators:
* `&&`: AND operator, matches if all conditions are true
* `||`: OR operator, matches if any of the conditions are true
* `(` and `)`: Parentheses for grouping conditions
## Examples
Here are some examples of filter queries:
* `priority = 4`: Matches tasks with priority level 4
* `dueDate < now`: Matches tasks with a due date in the past
* `done = false && priority >= 3`: Matches undone tasks with priority level 3 or higher
* `assignees in [user1, user2]`: Matches tasks assigned to either "user1" or "user2
* `(priority = 1 || priority = 2) && dueDate <= now`: Matches tasks with priority level 1 or 2 and a due date in the past

View File

@ -18,7 +18,7 @@ Starting with version 0.22.0, Vikunja allows you to define webhooks to notify ot
To create a webhook, in the project options select "Webhooks". The form will allow you to create and modify webhooks.
Check out [the api docs](https://try.vikunja.io/api/v1/docs#tag/webhooks) for information about how to create webhooks programatically.
Check out [the api docs](https://try.vikunja.io/api/v1/docs#tag/webhooks) for information about how to create webhooks programmatically.
## Available events and their payload

View File

@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1701336116,
"narHash": "sha256-kEmpezCR/FpITc6yMbAh4WrOCiT2zg5pSjnKrq51h5Y=",
"lastModified": 1712449641,
"narHash": "sha256-U9DDWMexN6o5Td2DznEgguh8TRIUnIl9levmit43GcI=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "f5c27c6136db4d76c30e533c20517df6864c46ee",
"rev": "600b15aea1b36eeb43833a50b0e96579147099ff",
"type": "github"
},
"original": {

20
flake.nix Normal file
View File

@ -0,0 +1,20 @@
{
description = "Vikunja dev environment";
outputs = { self, nixpkgs }:
let pkgs = nixpkgs.legacyPackages.x86_64-linux;
in {
defaultPackage.x86_64-linux =
pkgs.mkShell { buildInputs = with pkgs; [
# General tools
git-cliff
# Frontend tools
nodePackages.pnpm cypress
# API tools
go golangci-lint mage
# Desktop
electron
];
};
};
}

1
frontend/.gitignore vendored
View File

@ -13,7 +13,6 @@ node_modules
/dist*
coverage
*.zip
.direnv/
# Test files
cypress/screenshots

View File

@ -1 +1 @@
20.11.0
20.13.0

View File

@ -1,15 +1,50 @@
import {ProjectFactory} from '../../factories/project'
import {TaskFactory} from '../../factories/task'
import {ProjectViewFactory} from "../../factories/project_view";
export function createDefaultViews(projectId) {
ProjectViewFactory.truncate()
const list = ProjectViewFactory.create(1, {
id: 1,
project_id: projectId,
view_kind: 0,
}, false)
const gantt = ProjectViewFactory.create(1, {
id: 2,
project_id: projectId,
view_kind: 1,
}, false)
const table = ProjectViewFactory.create(1, {
id: 3,
project_id: projectId,
view_kind: 2,
}, false)
const kanban = ProjectViewFactory.create(1, {
id: 4,
project_id: projectId,
view_kind: 3,
bucket_configuration_mode: 1,
}, false)
return [
list[0],
gantt[0],
table[0],
kanban[0],
]
}
export function createProjects() {
const projects = ProjectFactory.create(1, {
title: 'First Project'
})
TaskFactory.truncate()
projects.views = createDefaultViews(projects[0].id)
return projects
}
export function prepareProjects(setProjects = (...args: any[]) => {}) {
export function prepareProjects(setProjects = (...args: any[]) => {
}) {
beforeEach(() => {
const projects = createProjects()
setProjects(projects)

View File

@ -2,6 +2,7 @@ import {createFakeUserAndLogin} from '../../support/authenticateUser'
import {ProjectFactory} from '../../factories/project'
import {prepareProjects} from './prepareProjects'
import {ProjectViewFactory} from '../../factories/project_view'
describe('Project History', () => {
createFakeUserAndLogin()
@ -11,24 +12,31 @@ describe('Project History', () => {
cy.intercept(Cypress.env('API_URL') + '/projects*').as('loadProjectArray')
cy.intercept(Cypress.env('API_URL') + '/projects/*').as('loadProject')
const projects = ProjectFactory.create(6)
const projects = ProjectFactory.create(7)
ProjectViewFactory.truncate()
projects.forEach(p => ProjectViewFactory.create(1, {
id: p.id,
project_id: p.id,
}, false))
cy.visit('/')
cy.wait('@loadProjectArray')
cy.get('body')
.should('not.contain', 'Last viewed')
cy.visit(`/projects/${projects[0].id}`)
cy.visit(`/projects/${projects[0].id}/${projects[0].id}`)
cy.wait('@loadProject')
cy.visit(`/projects/${projects[1].id}`)
cy.visit(`/projects/${projects[1].id}/${projects[1].id}`)
cy.wait('@loadProject')
cy.visit(`/projects/${projects[2].id}`)
cy.visit(`/projects/${projects[2].id}/${projects[2].id}`)
cy.wait('@loadProject')
cy.visit(`/projects/${projects[3].id}`)
cy.visit(`/projects/${projects[3].id}/${projects[3].id}`)
cy.wait('@loadProject')
cy.visit(`/projects/${projects[4].id}`)
cy.visit(`/projects/${projects[4].id}/${projects[4].id}`)
cy.wait('@loadProject')
cy.visit(`/projects/${projects[5].id}`)
cy.visit(`/projects/${projects[5].id}/${projects[5].id}`)
cy.wait('@loadProject')
cy.visit(`/projects/${projects[6].id}/${projects[6].id}`)
cy.wait('@loadProject')
// cy.visit('/')
@ -46,5 +54,6 @@ describe('Project History', () => {
.should('contain', projects[3].title)
.should('contain', projects[4].title)
.should('contain', projects[5].title)
.should('contain', projects[6].title)
})
})

View File

@ -11,7 +11,7 @@ describe('Project View Gantt', () => {
it('Hides tasks with no dates', () => {
const tasks = TaskFactory.create(1)
cy.visit('/projects/1/gantt')
cy.visit('/projects/1/2')
cy.get('.g-gantt-rows-container')
.should('not.contain', tasks[0].title)
@ -25,7 +25,7 @@ describe('Project View Gantt', () => {
nextMonth.setDate(1)
nextMonth.setMonth(9)
cy.visit('/projects/1/gantt')
cy.visit('/projects/1/2')
cy.get('.g-timeunits-container')
.should('contain', format(now, 'MMMM'))
@ -38,7 +38,7 @@ describe('Project View Gantt', () => {
start_date: now.toISOString(),
end_date: new Date(new Date(now).setDate(now.getDate() + 4)).toISOString(),
})
cy.visit('/projects/1/gantt')
cy.visit('/projects/1/2')
cy.get('.g-gantt-rows-container')
.should('not.be.empty')
@ -50,7 +50,7 @@ describe('Project View Gantt', () => {
start_date: null,
end_date: null,
})
cy.visit('/projects/1/gantt')
cy.visit('/projects/1/2')
cy.get('.gantt-options .fancycheckbox')
.contains('Show tasks which don\'t have dates set')
@ -69,7 +69,7 @@ describe('Project View Gantt', () => {
start_date: now.toISOString(),
end_date: new Date(new Date(now).setDate(now.getDate() + 4)).toISOString(),
})
cy.visit('/projects/1/gantt')
cy.visit('/projects/1/2')
cy.get('.g-gantt-rows-container .g-gantt-row .g-gantt-row-bars-container div .g-gantt-bar')
.first()
@ -83,7 +83,7 @@ describe('Project View Gantt', () => {
const now = Date.UTC(2022, 10, 9)
cy.clock(now, ['Date'])
cy.visit('/projects/1/gantt')
cy.visit('/projects/1/2')
cy.get('.project-gantt .gantt-options .field .control input.input.form-control')
.click()
@ -99,7 +99,7 @@ describe('Project View Gantt', () => {
})
it('Should change the date range based on date query parameters', () => {
cy.visit('/projects/1/gantt?dateFrom=2022-09-25&dateTo=2022-11-05')
cy.visit('/projects/1/2?dateFrom=2022-09-25&dateTo=2022-11-05')
cy.get('.g-timeunits-container')
.should('contain', 'September 2022')
@ -115,7 +115,7 @@ describe('Project View Gantt', () => {
start_date: formatISO(now),
end_date: formatISO(now.setDate(now.getDate() + 4)),
})
cy.visit('/projects/1/gantt')
cy.visit('/projects/1/2')
cy.get('.gantt-container .g-gantt-chart .g-gantt-row-bars-container .g-gantt-bar')
.dblclick()

View File

@ -4,35 +4,64 @@ import {BucketFactory} from '../../factories/bucket'
import {ProjectFactory} from '../../factories/project'
import {TaskFactory} from '../../factories/task'
import {prepareProjects} from './prepareProjects'
import {ProjectViewFactory} from "../../factories/project_view";
import {TaskBucketFactory} from "../../factories/task_buckets";
function createSingleTaskInBucket(count = 1, attrs = {}) {
const projects = ProjectFactory.create(1)
const buckets = BucketFactory.create(2, {
const views = ProjectViewFactory.create(1, {
id: 1,
project_id: projects[0].id,
view_kind: 3,
bucket_configuration_mode: 1,
})
const buckets = BucketFactory.create(2, {
project_view_id: views[0].id,
})
const tasks = TaskFactory.create(count, {
project_id: projects[0].id,
bucket_id: buckets[0].id,
...attrs,
})
return tasks[0]
TaskBucketFactory.create(1, {
task_id: tasks[0].id,
bucket_id: buckets[0].id,
project_view_id: views[0].id,
})
return {
task: tasks[0],
view: views[0],
project: projects[0],
}
}
function createTaskWithBuckets(buckets, count = 1) {
const data = TaskFactory.create(10, {
project_id: 1,
})
TaskBucketFactory.truncate()
data.forEach(t => TaskBucketFactory.create(count, {
task_id: t.id,
bucket_id: buckets[0].id,
project_view_id: buckets[0].project_view_id,
}, false))
return data
}
describe('Project View Kanban', () => {
createFakeUserAndLogin()
prepareProjects()
let buckets
beforeEach(() => {
buckets = BucketFactory.create(2)
buckets = BucketFactory.create(2, {
project_view_id: 4,
})
})
it('Shows all buckets with their tasks', () => {
const data = TaskFactory.create(10, {
project_id: 1,
bucket_id: 1,
})
cy.visit('/projects/1/kanban')
const data = createTaskWithBuckets(buckets, 10)
cy.visit('/projects/1/4')
cy.get('.kanban .bucket .title')
.contains(buckets[0].title)
@ -46,11 +75,8 @@ describe('Project View Kanban', () => {
})
it('Can add a new task to a bucket', () => {
TaskFactory.create(2, {
project_id: 1,
bucket_id: 1,
})
cy.visit('/projects/1/kanban')
createTaskWithBuckets(buckets, 2)
cy.visit('/projects/1/4')
cy.get('.kanban .bucket')
.contains(buckets[0].title)
@ -68,7 +94,7 @@ describe('Project View Kanban', () => {
})
it('Can create a new bucket', () => {
cy.visit('/projects/1/kanban')
cy.visit('/projects/1/4')
cy.get('.kanban .bucket.new-bucket .button')
.click()
@ -82,7 +108,7 @@ describe('Project View Kanban', () => {
})
it('Can set a bucket limit', () => {
cy.visit('/projects/1/kanban')
cy.visit('/projects/1/4')
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-trigger')
.first()
@ -103,7 +129,7 @@ describe('Project View Kanban', () => {
})
it('Can rename a bucket', () => {
cy.visit('/projects/1/kanban')
cy.visit('/projects/1/4')
cy.get('.kanban .bucket .bucket-header .title')
.first()
@ -114,7 +140,7 @@ describe('Project View Kanban', () => {
})
it('Can delete a bucket', () => {
cy.visit('/projects/1/kanban')
cy.visit('/projects/1/4')
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-trigger')
.first()
@ -137,17 +163,14 @@ describe('Project View Kanban', () => {
})
it('Can drag tasks around', () => {
const tasks = TaskFactory.create(2, {
project_id: 1,
bucket_id: 1,
})
cy.visit('/projects/1/kanban')
const tasks = createTaskWithBuckets(buckets, 2)
cy.visit('/projects/1/4')
cy.get('.kanban .bucket .tasks .task')
.contains(tasks[0].title)
.first()
.drag('.kanban .bucket:nth-child(2) .tasks')
cy.get('.kanban .bucket:nth-child(2) .tasks')
.should('contain', tasks[0].title)
cy.get('.kanban .bucket:nth-child(1) .tasks')
@ -155,12 +178,8 @@ describe('Project View Kanban', () => {
})
it('Should navigate to the task when the task card is clicked', () => {
const tasks = TaskFactory.create(5, {
id: '{increment}',
project_id: 1,
bucket_id: 1,
})
cy.visit('/projects/1/kanban')
const tasks = createTaskWithBuckets(buckets, 5)
cy.visit('/projects/1/4')
cy.get('.kanban .bucket .tasks .task')
.contains(tasks[0].title)
@ -168,28 +187,33 @@ describe('Project View Kanban', () => {
.click()
cy.url()
.should('contain', `/tasks/${tasks[0].id}`, { timeout: 1000 })
.should('contain', `/tasks/${tasks[0].id}`, {timeout: 1000})
})
it('Should remove a task from the kanban board when moving it to another project', () => {
const projects = ProjectFactory.create(2)
BucketFactory.create(2, {
const views = ProjectViewFactory.create(2, {
project_id: '{increment}',
view_kind: 3,
bucket_configuration_mode: 1,
})
BucketFactory.create(2)
const tasks = TaskFactory.create(5, {
id: '{increment}',
project_id: 1,
bucket_id: 1,
})
TaskBucketFactory.create(5, {
project_view_id: 1,
})
const task = tasks[0]
cy.visit('/projects/1/kanban')
cy.visit('/projects/1/'+views[0].id)
cy.get('.kanban .bucket .tasks .task')
.contains(task.title)
.should('be.visible')
.click()
cy.get('.task-view .action-buttons .button', { timeout: 3000 })
cy.get('.task-view .action-buttons .button', {timeout: 3000})
.contains('Move')
.click()
cy.get('.task-view .content.details .field .multiselect.control .input-wrapper input')
@ -201,27 +225,23 @@ describe('Project View Kanban', () => {
.first()
.click()
cy.get('.global-notification', { timeout: 1000 })
cy.get('.global-notification', {timeout: 1000})
.should('contain', 'Success')
cy.go('back')
cy.get('.kanban .bucket')
.should('not.contain', task.title)
})
it('Shows a button to filter the kanban board', () => {
const data = TaskFactory.create(10, {
project_id: 1,
bucket_id: 1,
})
cy.visit('/projects/1/kanban')
cy.visit('/projects/1/4')
cy.get('.project-kanban .filter-container .base-button')
.should('exist')
})
it('Should remove a task from the board when deleting it', () => {
const task = createSingleTaskInBucket(5)
cy.visit('/projects/1/kanban')
const {task, view} = createSingleTaskInBucket(5)
cy.visit(`/projects/1/${view.id}`)
cy.get('.kanban .bucket .tasks .task')
.contains(task.title)
@ -239,18 +259,18 @@ describe('Project View Kanban', () => {
cy.get('.global-notification')
.should('contain', 'Success')
cy.get('.kanban .bucket .tasks')
.should('not.contain', task.title)
})
it('Should show a task description icon if the task has a description', () => {
cy.intercept(Cypress.env('API_URL') + '/projects/1/buckets**').as('loadTasks')
const task = createSingleTaskInBucket(1, {
cy.intercept(Cypress.env('API_URL') + '/projects/1/views/*/tasks**').as('loadTasks')
const {task, view} = createSingleTaskInBucket(1, {
description: 'Lorem Ipsum',
})
cy.visit(`/projects/${task.project_id}/kanban`)
cy.visit(`/projects/${task.project_id}/${view.id}`)
cy.wait('@loadTasks')
cy.get('.bucket .tasks .task .footer .icon svg')
@ -258,12 +278,12 @@ describe('Project View Kanban', () => {
})
it('Should not show a task description icon if the task has an empty description', () => {
cy.intercept(Cypress.env('API_URL') + '/projects/1/buckets**').as('loadTasks')
const task = createSingleTaskInBucket(1, {
cy.intercept(Cypress.env('API_URL') + '/projects/1/views/*/tasks**').as('loadTasks')
const {task, view} = createSingleTaskInBucket(1, {
description: '',
})
cy.visit(`/projects/${task.project_id}/kanban`)
cy.visit(`/projects/${task.project_id}/${view.id}`)
cy.wait('@loadTasks')
cy.get('.bucket .tasks .task .footer .icon svg')
@ -271,15 +291,15 @@ describe('Project View Kanban', () => {
})
it('Should not show a task description icon if the task has a description containing only an empty p tag', () => {
cy.intercept(Cypress.env('API_URL') + '/projects/1/buckets**').as('loadTasks')
const task = createSingleTaskInBucket(1, {
cy.intercept(Cypress.env('API_URL') + '/projects/1/views/*/tasks**').as('loadTasks')
const {task, view} = createSingleTaskInBucket(1, {
description: '<p></p>',
})
cy.visit(`/projects/${task.project_id}/kanban`)
cy.visit(`/projects/${task.project_id}/${view.id}`)
cy.wait('@loadTasks')
cy.get('.bucket .tasks .task .footer .icon svg')
.should('not.exist')
})
})
})

View File

@ -5,15 +5,16 @@ import {TaskFactory} from '../../factories/task'
import {UserFactory} from '../../factories/user'
import {ProjectFactory} from '../../factories/project'
import {prepareProjects} from './prepareProjects'
import {BucketFactory} from '../../factories/bucket'
describe('Project View Project', () => {
describe('Project View List', () => {
createFakeUserAndLogin()
prepareProjects()
it('Should be an empty project', () => {
cy.visit('/projects/1')
cy.url()
.should('contain', '/projects/1/list')
.should('contain', '/projects/1/1')
cy.get('.project-title')
.should('contain', 'First Project')
cy.get('.project-title-dropdown')
@ -24,6 +25,10 @@ describe('Project View Project', () => {
})
it('Should create a new task', () => {
BucketFactory.create(2, {
project_view_id: 4,
})
const newTaskTitle = 'New task'
cy.visit('/projects/1')
@ -38,7 +43,7 @@ describe('Project View Project', () => {
id: '{increment}',
project_id: 1,
})
cy.visit('/projects/1/list')
cy.visit('/projects/1/1')
cy.get('.tasks .task .tasktext')
.contains(tasks[0].title)
@ -88,10 +93,10 @@ describe('Project View Project', () => {
title: i => `task${i}`,
project_id: 1,
})
cy.visit('/projects/1/list')
cy.visit('/projects/1/1')
cy.get('.tasks')
.should('contain', tasks[1].title)
.should('contain', tasks[20].title)
cy.get('.tasks')
.should('not.contain', tasks[99].title)
@ -104,6 +109,6 @@ describe('Project View Project', () => {
cy.get('.tasks')
.should('contain', tasks[99].title)
cy.get('.tasks')
.should('not.contain', tasks[1].title)
.should('not.contain', tasks[20].title)
})
})

View File

@ -1,13 +1,15 @@
import {createFakeUserAndLogin} from '../../support/authenticateUser'
import {TaskFactory} from '../../factories/task'
import {prepareProjects} from './prepareProjects'
describe('Project View Table', () => {
createFakeUserAndLogin()
prepareProjects()
it('Should show a table with tasks', () => {
const tasks = TaskFactory.create(1)
cy.visit('/projects/1/table')
cy.visit('/projects/1/3')
cy.get('.project-table table.table')
.should('exist')
@ -17,9 +19,9 @@ describe('Project View Table', () => {
it('Should have working column switches', () => {
TaskFactory.create(1)
cy.visit('/projects/1/table')
cy.visit('/projects/1/3')
cy.get('.project-table .filter-container .items .button')
cy.get('.project-table .filter-container .button')
.contains('Columns')
.click()
cy.get('.project-table .filter-container .card.columns-filter .card-content .fancycheckbox')
@ -42,7 +44,7 @@ describe('Project View Table', () => {
id: '{increment}',
project_id: 1,
})
cy.visit('/projects/1/table')
cy.visit('/projects/1/3')
cy.get('.project-table table.table')
.contains(tasks[0].title)

View File

@ -33,14 +33,14 @@ describe('Projects', () => {
})
it('Should redirect to a specific project view after visited', () => {
cy.intercept(Cypress.env('API_URL') + '/projects/*/buckets*').as('loadBuckets')
cy.visit('/projects/1/kanban')
cy.intercept(Cypress.env('API_URL') + '/projects/*/views/*/tasks**').as('loadBuckets')
cy.visit('/projects/1/4')
cy.url()
.should('contain', '/projects/1/kanban')
.should('contain', '/projects/1/4')
cy.wait('@loadBuckets')
cy.visit('/projects/1')
cy.url()
.should('contain', '/projects/1/kanban')
.should('contain', '/projects/1/4')
})
it('Should rename the project in all places', () => {

View File

@ -1,9 +1,9 @@
import {LinkShareFactory} from '../../factories/link_sharing'
import {ProjectFactory} from '../../factories/project'
import {TaskFactory} from '../../factories/task'
import {createProjects} from '../project/prepareProjects'
function prepareLinkShare() {
const projects = ProjectFactory.create(1)
const projects = createProjects()
const tasks = TaskFactory.create(10, {
project_id: projects[0].id
})
@ -32,13 +32,13 @@ describe('Link shares', () => {
cy.get('.tasks')
.should('contain', tasks[0].title)
cy.url().should('contain', `/projects/${project.id}/list#share-auth-token=${share.hash}`)
cy.url().should('contain', `/projects/${project.id}/1#share-auth-token=${share.hash}`)
})
it('Should work when directly viewing a project with share hash present', () => {
const {share, project, tasks} = prepareLinkShare()
cy.visit(`/projects/${project.id}/list#share-auth-token=${share.hash}`)
cy.visit(`/projects/${project.id}/1#share-auth-token=${share.hash}`)
cy.get('h1.title')
.should('contain', project.title)

View File

@ -5,11 +5,13 @@ import {seed} from '../../support/seed'
import {TaskFactory} from '../../factories/task'
import {BucketFactory} from '../../factories/bucket'
import {updateUserSettings} from '../../support/updateUserSettings'
import {createDefaultViews} from "../project/prepareProjects";
function seedTasks(numberOfTasks = 50, startDueDate = new Date()) {
const project = ProjectFactory.create()[0]
const views = createDefaultViews(project.id)
BucketFactory.create(1, {
project_id: project.id,
project_view_id: views[3].id,
})
const tasks = []
let dueDate = startDueDate
@ -60,7 +62,7 @@ describe('Home Page Task Overview', () => {
})
it('Should show a new task with a very soon due date at the top', () => {
const {tasks} = seedTasks()
const {tasks} = seedTasks(49)
const newTaskTitle = 'New Task'
cy.visit('/')
@ -71,9 +73,8 @@ describe('Home Page Task Overview', () => {
due_date: new Date().toISOString(),
}, false)
cy.visit(`/projects/${tasks[0].project_id}/list`)
cy.visit(`/projects/${tasks[0].project_id}/1`)
cy.get('.tasks .task')
.first()
.should('contain.text', newTaskTitle)
cy.visit('/')
cy.get('[data-cy="showTasks"] .card .task')
@ -88,7 +89,7 @@ describe('Home Page Task Overview', () => {
cy.visit('/')
cy.visit(`/projects/${tasks[0].project_id}/list`)
cy.visit(`/projects/${tasks[0].project_id}/1`)
cy.get('.task-add textarea')
.type(newTaskTitle+'{enter}')
cy.visit('/')

View File

@ -12,6 +12,8 @@ import {BucketFactory} from '../../factories/bucket'
import {TaskAttachmentFactory} from '../../factories/task_attachments'
import {TaskReminderFactory} from '../../factories/task_reminders'
import {createDefaultViews} from "../project/prepareProjects";
import { TaskBucketFactory } from '../../factories/task_buckets'
function addLabelToTaskAndVerify(labelTitle: string) {
cy.get('.task-view .action-buttons .button')
@ -47,21 +49,22 @@ function uploadAttachmentAndVerify(taskId: number) {
describe('Task', () => {
createFakeUserAndLogin()
let projects
let projects: {}[]
let buckets
beforeEach(() => {
// UserFactory.create(1)
projects = ProjectFactory.create(1)
const views = createDefaultViews(projects[0].id)
buckets = BucketFactory.create(1, {
project_id: projects[0].id,
project_view_id: views[3].id,
})
TaskFactory.truncate()
UserProjectFactory.truncate()
})
it('Should be created new', () => {
cy.visit('/projects/1/list')
cy.visit('/projects/1/1')
cy.get('.input[placeholder="Add a new task…"')
.type('New Task')
cy.get('.button')
@ -75,7 +78,7 @@ describe('Task', () => {
it('Inserts new tasks at the top of the project', () => {
TaskFactory.create(1)
cy.visit('/projects/1/list')
cy.visit('/projects/1/1')
cy.get('.project-is-empty-notice')
.should('not.exist')
cy.get('.input[placeholder="Add a new task…"')
@ -93,7 +96,7 @@ describe('Task', () => {
it('Marks a task as done', () => {
TaskFactory.create(1)
cy.visit('/projects/1/list')
cy.visit('/projects/1/1')
cy.get('.tasks .task .fancycheckbox')
.first()
.click()
@ -104,7 +107,7 @@ describe('Task', () => {
it('Can add a task to favorites', () => {
TaskFactory.create(1)
cy.visit('/projects/1/list')
cy.visit('/projects/1/1')
cy.get('.tasks .task .favorite')
.first()
.click()
@ -113,12 +116,12 @@ describe('Task', () => {
})
it('Should show a task description icon if the task has a description', () => {
cy.intercept(Cypress.env('API_URL') + '/projects/1/tasks**').as('loadTasks')
cy.intercept(Cypress.env('API_URL') + '/projects/1/views/*/tasks**').as('loadTasks')
TaskFactory.create(1, {
description: 'Lorem Ipsum',
})
cy.visit('/projects/1/list')
cy.visit('/projects/1/1')
cy.wait('@loadTasks')
cy.get('.tasks .task .project-task-icon')
@ -126,12 +129,12 @@ describe('Task', () => {
})
it('Should not show a task description icon if the task has an empty description', () => {
cy.intercept(Cypress.env('API_URL') + '/projects/1/tasks**').as('loadTasks')
cy.intercept(Cypress.env('API_URL') + '/projects/1/views/*/tasks**').as('loadTasks')
TaskFactory.create(1, {
description: '',
})
cy.visit('/projects/1/list')
cy.visit('/projects/1/1')
cy.wait('@loadTasks')
cy.get('.tasks .task .project-task-icon')
@ -139,12 +142,12 @@ describe('Task', () => {
})
it('Should not show a task description icon if the task has a description containing only an empty p tag', () => {
cy.intercept(Cypress.env('API_URL') + '/projects/1/tasks**').as('loadTasks')
cy.intercept(Cypress.env('API_URL') + '/projects/1/views/*/tasks**').as('loadTasks')
TaskFactory.create(1, {
description: '<p></p>',
})
cy.visit('/projects/1/list')
cy.visit('/projects/1/1')
cy.wait('@loadTasks')
cy.get('.tasks .task .project-task-icon')
@ -314,8 +317,9 @@ describe('Task', () => {
it('Can move a task to another project', () => {
const projects = ProjectFactory.create(2)
const views = createDefaultViews(projects[0].id)
BucketFactory.create(2, {
project_id: '{increment}',
project_view_id: views[3].id,
})
const tasks = TaskFactory.create(1, {
id: 1,
@ -464,12 +468,15 @@ describe('Task', () => {
const tasks = TaskFactory.create(1, {
id: 1,
project_id: projects[0].id,
bucket_id: buckets[0].id,
})
const labels = LabelFactory.create(1)
LabelTaskFactory.truncate()
TaskBucketFactory.create(1, {
task_id: tasks[0].id,
bucket_id: buckets[0].id,
})
cy.visit(`/projects/${projects[0].id}/kanban`)
cy.visit(`/projects/${projects[0].id}/4`)
cy.get('.bucket .task')
.contains(tasks[0].title)
@ -831,12 +838,11 @@ describe('Task', () => {
const tasks = TaskFactory.create(1, {
id: 1,
project_id: projects[0].id,
bucket_id: buckets[0].id,
})
const labels = LabelFactory.create(1)
LabelTaskFactory.truncate()
cy.visit(`/projects/${projects[0].id}/kanban`)
cy.visit(`/projects/${projects[0].id}/4`)
cy.get('.bucket .task')
.contains(tasks[0].title)

View File

@ -10,7 +10,7 @@ export class BucketFactory extends Factory {
return {
id: '{increment}',
title: faker.lorem.words(3),
project_id: 1,
project_view_id: '{increment}',
created_by_id: 1,
created: now.toISOString(),
updated: now.toISOString(),

View File

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

View File

@ -14,7 +14,6 @@ export class TaskFactory extends Factory {
project_id: 1,
created_by_id: 1,
index: '{increment}',
position: '{increment}',
created: now.toISOString(),
updated: now.toISOString()
}

View File

@ -0,0 +1,13 @@
import {Factory} from '../support/factory'
export class TaskBucketFactory extends Factory {
static table = 'task_buckets'
static factory() {
return {
task_id: '{increment}',
bucket_id: '{increment}',
project_view_id: '{increment}',
}
}
}

View File

@ -1,10 +0,0 @@
{
description = "Vikunja frontend dev environment";
outputs = { self, nixpkgs }:
let pkgs = nixpkgs.legacyPackages.x86_64-linux;
in {
defaultPackage.x86_64-linux =
pkgs.mkShell { buildInputs = [ pkgs.nodePackages.pnpm pkgs.cypress pkgs.git-cliff ]; };
};
}

View File

@ -13,7 +13,7 @@
},
"homepage": "https://vikunja.io/",
"funding": "https://opencollective.com/vikunja",
"packageManager": "pnpm@8.15.4",
"packageManager": "pnpm@9.1.0",
"keywords": [
"todo",
"productivity",
@ -50,60 +50,60 @@
"story:preview": "histoire preview"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "6.5.1",
"@fortawesome/free-regular-svg-icons": "6.5.1",
"@fortawesome/free-solid-svg-icons": "6.5.1",
"@fortawesome/fontawesome-svg-core": "6.5.2",
"@fortawesome/free-regular-svg-icons": "6.5.2",
"@fortawesome/free-solid-svg-icons": "6.5.2",
"@fortawesome/vue-fontawesome": "3.0.6",
"@github/hotkey": "3.1.0",
"@infectoone/vue-ganttastic": "2.2.0",
"@intlify/unplugin-vue-i18n": "2.0.0",
"@kyvg/vue3-notification": "3.2.0",
"@sentry/tracing": "7.105.0",
"@sentry/vue": "7.105.0",
"@tiptap/core": "2.2.4",
"@tiptap/extension-blockquote": "2.2.4",
"@tiptap/extension-bold": "2.2.4",
"@tiptap/extension-bullet-list": "2.2.4",
"@tiptap/extension-code": "2.2.4",
"@tiptap/extension-code-block-lowlight": "2.2.4",
"@tiptap/extension-document": "2.2.4",
"@tiptap/extension-dropcursor": "2.2.4",
"@tiptap/extension-gapcursor": "2.2.4",
"@tiptap/extension-hard-break": "2.2.4",
"@tiptap/extension-heading": "2.2.4",
"@tiptap/extension-history": "2.2.4",
"@tiptap/extension-horizontal-rule": "2.2.4",
"@tiptap/extension-image": "2.2.4",
"@tiptap/extension-italic": "2.2.4",
"@tiptap/extension-link": "2.2.4",
"@tiptap/extension-list-item": "2.2.4",
"@tiptap/extension-ordered-list": "2.2.4",
"@tiptap/extension-paragraph": "2.2.4",
"@tiptap/extension-placeholder": "2.2.4",
"@tiptap/extension-strike": "2.2.4",
"@tiptap/extension-table": "2.2.4",
"@tiptap/extension-table-cell": "2.2.4",
"@tiptap/extension-table-header": "2.2.4",
"@tiptap/extension-table-row": "2.2.4",
"@tiptap/extension-task-item": "2.2.4",
"@tiptap/extension-task-list": "2.2.4",
"@tiptap/extension-text": "2.2.4",
"@tiptap/extension-typography": "2.2.4",
"@tiptap/extension-underline": "2.2.4",
"@tiptap/pm": "2.2.4",
"@tiptap/suggestion": "2.2.4",
"@tiptap/vue-3": "2.2.4",
"@infectoone/vue-ganttastic": "2.3.2",
"@intlify/unplugin-vue-i18n": "4.0.0",
"@kyvg/vue3-notification": "3.2.1",
"@sentry/tracing": "7.114.0",
"@sentry/vue": "7.114.0",
"@tiptap/core": "2.3.2",
"@tiptap/extension-blockquote": "2.3.2",
"@tiptap/extension-bold": "2.3.2",
"@tiptap/extension-bullet-list": "2.3.2",
"@tiptap/extension-code": "2.3.2",
"@tiptap/extension-code-block-lowlight": "2.3.2",
"@tiptap/extension-document": "2.3.2",
"@tiptap/extension-dropcursor": "2.3.2",
"@tiptap/extension-gapcursor": "2.3.2",
"@tiptap/extension-hard-break": "2.3.2",
"@tiptap/extension-heading": "2.3.2",
"@tiptap/extension-history": "2.3.2",
"@tiptap/extension-horizontal-rule": "2.3.2",
"@tiptap/extension-image": "2.3.2",
"@tiptap/extension-italic": "2.3.2",
"@tiptap/extension-link": "2.3.2",
"@tiptap/extension-list-item": "2.3.2",
"@tiptap/extension-ordered-list": "2.3.2",
"@tiptap/extension-paragraph": "2.3.2",
"@tiptap/extension-placeholder": "2.3.2",
"@tiptap/extension-strike": "2.3.2",
"@tiptap/extension-table": "2.3.2",
"@tiptap/extension-table-cell": "2.3.2",
"@tiptap/extension-table-header": "2.3.2",
"@tiptap/extension-table-row": "2.3.2",
"@tiptap/extension-task-item": "2.3.2",
"@tiptap/extension-task-list": "2.3.2",
"@tiptap/extension-text": "2.3.2",
"@tiptap/extension-typography": "2.3.2",
"@tiptap/extension-underline": "2.3.2",
"@tiptap/pm": "2.3.2",
"@tiptap/suggestion": "2.3.2",
"@tiptap/vue-3": "2.3.2",
"@types/is-touch-device": "1.0.2",
"@types/lodash.clonedeep": "4.5.9",
"@vueuse/core": "10.9.0",
"@vueuse/router": "10.9.0",
"axios": "1.6.7",
"axios": "1.6.8",
"blurhash": "2.0.5",
"bulma-css-variables": "0.9.33",
"camel-case": "4.1.2",
"date-fns": "3.3.1",
"dayjs": "1.11.10",
"dompurify": "3.0.9",
"date-fns": "3.6.0",
"dayjs": "1.11.11",
"dompurify": "3.1.2",
"fast-deep-equal": "3.1.3",
"flatpickr": "4.6.13",
"flexsearch": "0.7.31",
@ -117,13 +117,14 @@
"snake-case": "3.0.4",
"sortablejs": "1.15.2",
"tippy.js": "6.3.7",
"ufo": "1.4.0",
"vue": "3.4.21",
"ufo": "1.5.3",
"vue": "3.4.27",
"vue-advanced-cropper": "2.8.8",
"vue-flatpickr-component": "11.0.5",
"vue-i18n": "9.10.1",
"vue-router": "4.3.0",
"workbox-precaching": "7.0.0",
"vue-i18n": "9.13.1",
"vue-router": "4.3.2",
"vuemoji-picker": "0.2.1",
"workbox-precaching": "7.1.0",
"zhyswan-vuedraggable": "4.1.3"
},
"devDependencies": {
@ -131,60 +132,61 @@
"@cypress/vite-dev-server": "5.0.7",
"@cypress/vue": "6.0.0",
"@faker-js/faker": "8.4.1",
"@histoire/plugin-screenshot": "0.17.8",
"@histoire/plugin-vue": "0.17.12",
"@rushstack/eslint-patch": "1.7.2",
"@tsconfig/node18": "18.2.2",
"@histoire/plugin-screenshot": "0.17.17",
"@histoire/plugin-vue": "0.17.17",
"@rushstack/eslint-patch": "1.10.2",
"@tsconfig/node18": "18.2.4",
"@types/codemirror": "5.60.15",
"@types/dompurify": "3.0.5",
"@types/flexsearch": "0.7.6",
"@types/is-touch-device": "1.0.2",
"@types/lodash.debounce": "4.0.9",
"@types/marked": "5.0.2",
"@types/node": "20.11.24",
"@types/node": "20.12.11",
"@types/postcss-preset-env": "7.7.0",
"@types/sortablejs": "1.15.8",
"@typescript-eslint/eslint-plugin": "7.1.1",
"@typescript-eslint/parser": "7.1.1",
"@vitejs/plugin-legacy": "5.3.1",
"@typescript-eslint/eslint-plugin": "7.8.0",
"@typescript-eslint/parser": "7.8.0",
"@vitejs/plugin-legacy": "5.4.0",
"@vitejs/plugin-vue": "5.0.4",
"@vue/eslint-config-typescript": "12.0.0",
"@vue/test-utils": "2.4.4",
"@vue/eslint-config-typescript": "13.0.0",
"@vue/test-utils": "2.4.6",
"@vue/tsconfig": "0.5.1",
"autoprefixer": "10.4.18",
"autoprefixer": "10.4.19",
"browserslist": "4.23.0",
"caniuse-lite": "1.0.30001593",
"css-has-pseudo": "6.0.2",
"caniuse-lite": "1.0.30001617",
"css-has-pseudo": "6.0.3",
"csstype": "3.1.3",
"cypress": "13.6.6",
"esbuild": "0.20.1",
"cypress": "13.9.0",
"esbuild": "0.21.1",
"eslint": "8.57.0",
"eslint-plugin-vue": "9.22.0",
"happy-dom": "13.6.2",
"histoire": "0.17.9",
"postcss": "8.4.35",
"eslint-plugin-vue": "9.26.0",
"happy-dom": "14.10.1",
"histoire": "0.17.17",
"postcss": "8.4.38",
"postcss-easing-gradients": "3.0.1",
"postcss-easings": "4.0.0",
"postcss-focus-within": "8.0.1",
"postcss-preset-env": "9.4.0",
"rollup": "4.12.0",
"postcss-preset-env": "9.5.11",
"rollup": "4.17.2",
"rollup-plugin-visualizer": "5.12.0",
"sass": "1.71.1",
"sass": "1.77.0",
"start-server-and-test": "2.0.3",
"typescript": "5.3.3",
"vite": "5.1.5",
"typescript": "5.4.5",
"vite": "5.2.11",
"vite-plugin-inject-preload": "1.3.3",
"vite-plugin-pwa": "0.19.2",
"vite-plugin-pwa": "0.20.0",
"vite-plugin-sentry": "1.4.0",
"vite-svg-loader": "5.1.0",
"vitest": "1.3.1",
"vue-tsc": "2.0.4",
"vitest": "1.6.0",
"vue-tsc": "2.0.16",
"wait-on": "7.2.0",
"workbox-cli": "7.0.0"
"workbox-cli": "7.1.0"
},
"pnpm": {
"patchedDependencies": {
"flexsearch@0.7.31": "patches/flexsearch@0.7.31.patch"
"flexsearch@0.7.31": "patches/flexsearch@0.7.31.patch",
"@github/hotkey@3.1.0": "patches/@github__hotkey@3.1.0.patch"
}
}
}

View File

@ -0,0 +1,28 @@
diff --git a/dist/index.js b/dist/index.js
index b6e6e0a6864cb00bc085b8d4503a705cb3bc8404..0466ef46406b0df41c8d0bb9a5bac9eabf4a50de 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -368,10 +368,12 @@ const sequenceTracker = new SequenceTracker({
function keyDownHandler(event) {
if (event.defaultPrevented)
return;
- if (!(event.target instanceof Node))
+ const target = event.explicitOriginalTarget || event.target;
+ if (target.shadowRoot)
return;
- if (isFormField(event.target)) {
- const target = event.target;
+ if (!(target instanceof Node))
+ return;
+ if (isFormField(target)) {
if (!target.id)
return;
if (!target.ownerDocument.querySelector(`[data-hotkey-scope="${target.id}"]`))
@@ -385,7 +387,6 @@ function keyDownHandler(event) {
sequenceTracker.registerKeypress(event);
currentTriePosition = newTriePosition;
if (newTriePosition instanceof Leaf) {
- const target = event.target;
let shouldFire = false;
let elementToFire;
const formField = isFormField(target);

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -47,7 +47,7 @@ import AddToHomeScreen from '@/components/home/AddToHomeScreen.vue'
import DemoMode from '@/components/home/DemoMode.vue'
const importAccountDeleteService = () => import('@/services/accountDelete')
const importMessage = () => import('@/message')
import {success} from '@/message'
const baseStore = useBaseStore()
const authStore = useAuthStore()
@ -69,11 +69,9 @@ watch(accountDeletionConfirm, async (accountDeletionConfirm) => {
return
}
const messageP = importMessage()
const AccountDeleteService = (await importAccountDeleteService()).default
const accountDeletionService = new AccountDeleteService()
await accountDeletionService.confirm(accountDeletionConfirm)
const {success} = await messageP
success({message: t('user.deletion.confirmSuccess')})
authStore.refreshUserInfo()
}, { immediate: true })

Binary file not shown.

Before

Width:  |  Height:  |  Size: 313 KiB

After

Width:  |  Height:  |  Size: 218 KiB

View File

@ -19,3 +19,28 @@ export const DATE_RANGES = {
'thisYear': ['now/y', 'now/y+1y'],
'restOfThisYear': ['now', 'now/y+1y'],
}
export const DATE_VALUES = {
'now': 'now',
'startOfToday': 'now/d',
'endOfToday': 'now/d+1d',
'beginningOflastWeek': 'now/w-1w',
'endOfLastWeek': 'now/w-2w',
'beginningOfThisWeek': 'now/w',
'endOfThisWeek': 'now/w+1w',
'startOfNextWeek': 'now/w+1w',
'endOfNextWeek': 'now/w+2w',
'in7Days': 'now+7d',
'beginningOfLastMonth': 'now/M-1M',
'endOfLastMonth': 'now/M-2M',
'startOfThisMonth': 'now/M',
'endOfThisMonth': 'now/M+1M',
'startOfNextMonth': 'now/M+1M',
'endOfNextMonth': 'now/M+2M',
'in30Days': 'now+30d',
'startOfThisYear': 'now/y',
'endOfThisYear': 'now/y+1y',
}

View File

@ -75,14 +75,15 @@
<p>
{{ $t('input.datemathHelp.canuse') }}
<BaseButton
class="has-text-primary"
@click="showHowItWorks = true"
>
{{ $t('input.datemathHelp.learnhow') }}
</BaseButton>
</p>
<BaseButton
class="has-text-primary"
@click="showHowItWorks = true"
>
{{ $t('input.datemathHelp.learnhow') }}
</BaseButton>
<modal
:enabled="showHowItWorks"
transition-name="fade"
@ -111,7 +112,7 @@ import Popup from '@/components/misc/popup.vue'
import {DATE_RANGES} from '@/components/date/dateRanges'
import BaseButton from '@/components/base/BaseButton.vue'
import DatemathHelp from '@/components/date/datemathHelp.vue'
import { getFlatpickrLanguage } from '@/helpers/flatpickrLanguage'
import {getFlatpickrLanguage} from '@/helpers/flatpickrLanguage'
const props = defineProps({
modelValue: {

View File

@ -0,0 +1,256 @@
<template>
<div class="datepicker-with-range-container">
<Popup
:open="open"
@close="() => emit('close')"
>
<template #content="{isOpen}">
<div
class="datepicker-with-range"
:class="{'is-open': isOpen}"
>
<div class="selections">
<BaseButton
:class="{'is-active': customRangeActive}"
@click="setDate(null)"
>
{{ $t('misc.custom') }}
</BaseButton>
<BaseButton
v-for="(value, text) in DATE_VALUES"
:key="text"
:class="{'is-active': date === value}"
@click="setDate(value)"
>
{{ $t(`input.datepickerRange.values.${text}`) }}
</BaseButton>
</div>
<div class="flatpickr-container input-group">
<label class="label">
{{ $t('input.datepickerRange.date') }}
<div class="field has-addons">
<div class="control is-fullwidth">
<input
v-model="date"
class="input"
type="text"
>
</div>
<div class="control">
<x-button
icon="calendar"
variant="secondary"
data-toggle
/>
</div>
</div>
</label>
<flat-pickr
v-model="flatpickrDate"
:config="flatPickerConfig"
/>
<p>
{{ $t('input.datemathHelp.canuse') }}
</p>
<BaseButton
class="has-text-primary"
@click="showHowItWorks = true"
>
{{ $t('input.datemathHelp.learnhow') }}
</BaseButton>
<modal
:enabled="showHowItWorks"
transition-name="fade"
:overflow="true"
variant="hint-modal"
@close="() => showHowItWorks = false"
>
<DatemathHelp />
</modal>
</div>
</div>
</template>
</Popup>
</div>
</template>
<script lang="ts" setup>
import {computed, ref, watch} from 'vue'
import {useI18n} from 'vue-i18n'
import flatPickr from 'vue-flatpickr-component'
import 'flatpickr/dist/flatpickr.css'
import {parseDateOrString} from '@/helpers/time/parseDateOrString'
import Popup from '@/components/misc/popup.vue'
import {DATE_VALUES} from '@/components/date/dateRanges'
import BaseButton from '@/components/base/BaseButton.vue'
import DatemathHelp from '@/components/date/datemathHelp.vue'
import {getFlatpickrLanguage} from '@/helpers/flatpickrLanguage'
const props = defineProps({
modelValue: {
required: false,
default: null,
},
open: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['update:modelValue', 'close'])
const {t} = useI18n({useScope: 'global'})
const flatPickerConfig = computed(() => ({
altFormat: t('date.altFormatLong'),
altInput: true,
dateFormat: 'Y-m-d H:i',
enableTime: false,
wrap: true,
locale: getFlatpickrLanguage(),
}))
const showHowItWorks = ref(false)
const flatpickrDate = ref('')
const date = ref<string|Date>('')
watch(
() => props.modelValue,
newValue => {
date.value = newValue
// Only set the date back to flatpickr when it's an actual date.
// Otherwise flatpickr runs in an endless loop and slows down the browser.
const parsed = parseDateOrString(date.value, false)
if (parsed instanceof Date) {
flatpickrDate.value = date.value
}
},
)
function emitChanged() {
emit('update:modelValue', date.value === '' ? null : date.value)
}
watch(
() => flatpickrDate.value,
(newVal: string | null) => {
if (newVal === null) {
return
}
date.value = newVal
emitChanged()
},
)
watch(() => date.value, emitChanged)
function setDate(range: string | null) {
if (range === null) {
date.value = ''
return
}
date.value = range
}
const customRangeActive = computed<boolean>(() => {
return !Object.values(DATE_VALUES).some(d => date.value === d)
})
</script>
<style lang="scss" scoped>
.datepicker-with-range-container {
position: relative;
}
:deep(.popup) {
z-index: 10;
margin-top: 1rem;
border-radius: $radius;
border: 1px solid var(--grey-200);
background-color: var(--white);
box-shadow: $shadow;
&.is-open {
width: 500px;
height: 320px;
}
}
.datepicker-with-range {
display: flex;
width: 100%;
height: 100%;
position: absolute;
}
:deep(.flatpickr-calendar) {
margin: 0 auto 8px;
box-shadow: none;
}
.flatpickr-container {
width: 70%;
border-left: 1px solid var(--grey-200);
padding: 1rem;
font-size: .9rem;
// Flatpickr has no option to use it without an input field so we're hiding it instead
:deep(input.form-control.input) {
height: 0;
padding: 0;
border: 0;
}
.field .control :deep(.button) {
border: 1px solid var(--input-border-color);
height: 2.25rem;
&:hover {
border: 1px solid var(--input-hover-border-color);
}
}
.label, .input, :deep(.button) {
font-size: .9rem;
}
}
.selections {
width: 30%;
display: flex;
flex-direction: column;
padding-top: .5rem;
overflow-y: scroll;
button {
display: block;
width: 100%;
text-align: left;
padding: .5rem 1rem;
transition: $transition;
font-size: .9rem;
color: var(--text);
background: transparent;
border: 0;
cursor: pointer;
&.is-active {
color: var(--primary);
}
&:hover, &.is-active {
background-color: var(--grey-100);
}
}
}
</style>

View File

@ -26,7 +26,6 @@
:project="project"
:is-loading="projectUpdating[project.id]"
:can-collapse="canCollapse"
:level="level"
:data-project-id="project.id"
/>
</template>
@ -49,7 +48,6 @@ const props = defineProps<{
modelValue?: IProject[],
canEditOrder: boolean,
canCollapse?: boolean,
level?: number,
}>()
const emit = defineEmits<{
(e: 'update:modelValue', projects: IProject[]): void

View File

@ -58,7 +58,6 @@
<ProjectSettingsDropdown
class="menu-list-dropdown"
:project="project"
:level="level"
>
<template #trigger="{toggleOpen}">
<BaseButton
@ -78,7 +77,6 @@
:model-value="childProjects"
:can-edit-order="true"
:can-collapse="canCollapse"
:level="level + 1"
/>
</li>
</template>
@ -101,12 +99,10 @@ const {
project,
isLoading,
canCollapse,
level = 0,
} = defineProps<{
project: IProject,
isLoading?: boolean,
canCollapse?: boolean,
level?: number,
}>()
const projectStore = useProjectStore()

View File

@ -37,7 +37,7 @@
v-slot="{ Component }"
:route="routeWithModal"
>
<keep-alive :include="['project.list', 'project.gantt', 'project.table', 'project.kanban']">
<keep-alive :include="['project.view']">
<component :is="Component" />
</keep-alive>
</router-view>
@ -162,7 +162,7 @@ projectStore.loadAllProjects()
.app-content {
z-index: 10;
position: relative;
padding: 1.5rem 0.5rem 1rem;
padding: 1.5rem 0.5rem 0;
// TODO refactor: DRY `transition-timing-function` with `./navigation.vue`.
transition: margin-left $transition-duration;
@ -172,7 +172,7 @@ projectStore.loadAllProjects()
}
@media screen and (min-width: $tablet) {
padding: $navbar-height + 1.5rem 1.5rem 1rem 1.5rem;
padding: $navbar-height + 1.5rem 1.5rem 0 1.5rem;
}
&.is-menu-enabled {

View File

@ -1,26 +1,27 @@
<template>
<div
:class="[background ? 'has-background' : '', $route.name as string +'-view']"
:class="{
'has-background': background,
'link-share-is-fullwidth': isFullWidth,
}"
:style="{'background-image': `url(${background})`}"
class="link-share-container"
>
<div class="container has-text-centered link-share-view">
<div class="column is-10 is-offset-1">
<Logo
v-if="logoVisible"
class="logo"
/>
<h1
:class="{'m-0': !logoVisible}"
:style="{ 'opacity': currentProject?.title === '' ? '0': '1' }"
class="title"
>
{{ currentProject?.title === '' ? $t('misc.loading') : currentProject?.title }}
</h1>
<div class="box has-text-left view">
<router-view />
<PoweredByLink />
</div>
<div class="has-text-centered link-share-view">
<Logo
v-if="logoVisible"
class="logo"
/>
<h1
:class="{'m-0': !logoVisible}"
:style="{ 'opacity': currentProject?.title === '' ? '0': '1' }"
class="title"
>
{{ currentProject?.title === '' ? $t('misc.loading') : currentProject?.title }}
</h1>
<div class="box has-text-left view">
<router-view />
<PoweredByLink />
</div>
</div>
</div>
@ -30,14 +31,38 @@
import {computed} from 'vue'
import {useBaseStore} from '@/stores/base'
import {useRoute} from 'vue-router'
import Logo from '@/components/home/Logo.vue'
import PoweredByLink from './PoweredByLink.vue'
import {useProjectStore} from '@/stores/projects'
import {useLabelStore} from '@/stores/labels'
import {PROJECT_VIEW_KINDS} from '@/modelTypes/IProjectView'
const baseStore = useBaseStore()
const currentProject = computed(() => baseStore.currentProject)
const background = computed(() => baseStore.background)
const logoVisible = computed(() => baseStore.logoVisible)
const projectStore = useProjectStore()
projectStore.loadAllProjects()
const labelStore = useLabelStore()
labelStore.loadAllLabels()
const route = useRoute()
const isFullWidth = computed(() => {
const viewId = route.params?.viewId ?? null
const projectId = route.params?.projectId ?? null
if (!viewId || !projectId) {
return false
}
const view = projectStore.projects[Number(projectId)]?.views.find(v => v.id === Number(viewId))
return view?.viewKind === PROJECT_VIEW_KINDS.KANBAN ||
view?.viewKind === PROJECT_VIEW_KINDS.GANTT
})
</script>
<style lang="scss" scoped>
@ -49,20 +74,34 @@ const logoVisible = computed(() => baseStore.logoVisible)
.logo {
max-width: 300px;
width: 90%;
margin: 2rem 0 1.5rem;
margin: 1rem auto 2rem;
height: 100px;
}
.column {
max-width: 100%;
}
.title {
text-shadow: 0 0 1rem var(--white);
}
// FIXME: this should be defined somewhere deep
.link-share-view .card {
background-color: var(--white);
.link-share-view {
width: 100%;
max-width: $desktop;
margin: 0 auto;
}
.link-share-container.link-share-is-fullwidth {
.link-share-view {
max-width: 100vw;
}
}
.link-share-container:not(.has-background) {
:deep(.loader-container, .gantt-chart-container > .card) {
box-shadow: none !important;
border: none;
.task-add {
padding: 1rem 0 0;
}
}
}
</style>

View File

@ -155,8 +155,8 @@ const savedFilterProjects = computed(() => projectStore.savedFilterProjects)
bottom: 0;
left: 0;
transform: translateX(-100%);
overflow-x: auto;
width: $navbar-width;
overflow-y: auto;
@media screen and (max-width: $tablet) {
top: 0;

View File

@ -0,0 +1,232 @@
<script setup lang="ts">
import {type ComponentPublicInstance, nextTick, ref, watch} from 'vue'
const props = withDefaults(defineProps<{
// eslint-disable-next-line @typescript-eslint/no-explicit-any
options: any[],
suggestion?: string,
maxHeight?: number,
}>(), {
maxHeight: 200,
suggestion: '',
})
const emit = defineEmits(['blur'])
const ESCAPE = 27,
ARROW_UP = 38,
ARROW_DOWN = 40
type StateType = 'unfocused' | 'focused'
const selectedIndex = ref(-1)
const state = ref<StateType>('unfocused')
const val = ref<string>('')
const model = defineModel<string>()
const suggestionScrollerRef = ref<HTMLInputElement | null>(null)
const containerRef = ref<HTMLInputElement | null>(null)
const editorRef = ref<HTMLInputElement | null>(null)
watch(
() => model.value,
newValue => {
val.value = newValue
},
)
function updateSuggestionScroll() {
nextTick(() => {
const scroller = suggestionScrollerRef.value
const selectedItem = scroller?.querySelector('.selected')
scroller.scrollTop = selectedItem ? selectedItem.offsetTop : 0
})
}
function setState(stateName: StateType) {
state.value = stateName
if (stateName === 'unfocused') {
emit('blur')
}
}
function onFocusField() {
setState('focused')
}
function onKeydown(e) {
switch (e.keyCode || e.which) {
case ESCAPE:
e.preventDefault()
setState('unfocused')
break
case ARROW_UP:
e.preventDefault()
select(-1)
break
case ARROW_DOWN:
e.preventDefault()
select(1)
break
}
}
const resultRefs = ref<(HTMLElement | null)[]>([])
function setResultRefs(el: Element | ComponentPublicInstance | null, index: number) {
resultRefs.value[index] = el as (HTMLElement | null)
}
function select(offset: number) {
let index = selectedIndex.value + offset
if (!isFinite(index)) {
index = 0
}
if (index >= props.options.length) {
// At the last index, now moving back to the top
index = 0
}
if (index < 0) {
// Arrow up but we're already at the top
index = props.options.length - 1
}
const elems = resultRefs.value[index]
if (
typeof elems === 'undefined'
) {
return
}
selectedIndex.value = index
updateSuggestionScroll()
if (Array.isArray(elems)) {
elems[0].focus()
return
}
elems?.focus()
}
function onSelectValue(value) {
model.value = value
selectedIndex.value = 0
setState('unfocused')
}
function onUpdateField(e) {
setState('focused')
model.value = e.currentTarget.value
}
</script>
<template>
<div
ref="containerRef"
class="autocomplete"
>
<div class="entry-box">
<slot
name="input"
:on-update-field
:on-focus-field
:on-keydown
>
<textarea
ref="editorRef"
class="field"
:class="state"
:value="val"
@input="onUpdateField"
@focus="onFocusField"
@keydown="onKeydown"
/>
</slot>
</div>
<div
v-if="state === 'focused' && options.length"
class="suggestion-list"
>
<div
v-if="options && options.length"
class="scroll-list"
>
<div
ref="suggestionScrollerRef"
class="items"
@keydown="onKeydown"
>
<button
v-for="(item, index) in options"
:key="item"
:ref="(el: Element | ComponentPublicInstance | null) => setResultRefs(el, index)"
class="item"
:class="{ selected: index === selectedIndex }"
@click="onSelectValue(item)"
>
<slot
name="result"
:item
:selected="index === selectedIndex"
>
{{ item }}
</slot>
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.autocomplete {
position: relative;
.suggestion-list {
position: absolute;
background: var(--white);
border-radius: 0 0 var(--input-radius) var(--input-radius);
border: 1px solid var(--primary);
border-top: none;
max-height: 50vh;
overflow-x: auto;
z-index: 100;
max-width: 100%;
min-width: 100%;
margin-top: -2px;
button {
width: 100%;
background: transparent;
border: 0;
font-size: .9rem;
width: 100%;
color: var(--grey-800);
text-align: left;
box-shadow: none;
text-transform: none;
font-family: $family-sans-serif;
font-weight: normal;
padding: .5rem .75rem;
border: none;
cursor: pointer;
&:focus,
&:hover {
background: var(--grey-100);
box-shadow: none !important;
}
&:active {
background: var(--grey-100);
}
}
}
}
</style>

View File

@ -0,0 +1,197 @@
<script setup lang="ts">
import type {IReactionPerEntity, ReactionKind} from '@/modelTypes/IReaction'
import {VuemojiPicker} from 'vuemoji-picker'
import ReactionService from '@/services/reactions'
import ReactionModel from '@/models/reaction'
import BaseButton from '@/components/base/BaseButton.vue'
import type {IUser} from '@/modelTypes/IUser'
import {getDisplayName} from '@/models/user'
import {useI18n} from 'vue-i18n'
import {nextTick, onBeforeUnmount, onMounted, ref} from 'vue'
import CustomTransition from '@/components/misc/CustomTransition.vue'
import {closeWhenClickedOutside} from '@/helpers/closeWhenClickedOutside'
import {useAuthStore} from '@/stores/auth'
import {useColorScheme} from '@/composables/useColorScheme'
const {
entityKind,
entityId,
disabled = false,
} = defineProps<{
entityKind: ReactionKind,
entityId: number,
disabled?: boolean,
}>()
const authStore = useAuthStore()
const {t} = useI18n()
const reactionService = new ReactionService()
const {isDark} = useColorScheme()
const model = defineModel<IReactionPerEntity>()
async function addReaction(value: string) {
const reaction = new ReactionModel({
id: entityId,
kind: entityKind,
value,
})
await reactionService.create(reaction)
showEmojiPicker.value = false
if (typeof model.value === 'undefined') {
model.value = {}
}
if (typeof model.value[reaction.value] === 'undefined') {
model.value[reaction.value] = [authStore.info]
} else {
model.value[reaction.value].push(authStore.info)
}
}
async function removeReaction(value: string) {
const reaction = new ReactionModel({
id: entityId,
kind: entityKind,
value,
})
await reactionService.delete(reaction)
showEmojiPicker.value = false
const userIndex = model.value[reaction.value].findIndex(u => u.id === authStore.info?.id)
if (userIndex !== -1) {
model.value[reaction.value].splice(userIndex, 1)
}
if(model.value[reaction.value].length === 0) {
delete model.value[reaction.value]
}
}
function getReactionTooltip(users: IUser[], value: string) {
const names = users.map(u => getDisplayName(u))
if (names.length === 1) {
return t('reaction.reactedWith', {user: names[0], value})
}
if (names.length > 1 && names.length < 10) {
return t('reaction.reactedWithAnd', {
users: names.slice(0, names.length - 1).join(', '),
lastUser: names[names.length - 1],
value,
})
}
return t('reaction.reactedWithAndMany', {
users: names.slice(0, 10).join(', '),
num: names.length - 10,
value,
})
}
const showEmojiPicker = ref(false)
const emojiPickerRef = ref<HTMLElement | null>(null)
function hideEmojiPicker(e: MouseEvent) {
if (showEmojiPicker.value) {
closeWhenClickedOutside(e, emojiPickerRef.value.$el, () => showEmojiPicker.value = false)
}
}
onMounted(() => document.addEventListener('click', hideEmojiPicker))
onBeforeUnmount(() => document.removeEventListener('click', hideEmojiPicker))
const emojiPickerButtonRef = ref<HTMLElement | null>(null)
const reactionContainerRef = ref<HTMLElement | null>(null)
const emojiPickerPosition = ref()
function toggleEmojiPicker() {
if (!showEmojiPicker.value) {
const rect = emojiPickerButtonRef.value?.$el.getBoundingClientRect()
const container = reactionContainerRef.value?.getBoundingClientRect()
const left = rect.left - container.left + rect.width
emojiPickerPosition.value = {
left: left === 0 ? undefined : left,
}
}
nextTick(() => showEmojiPicker.value = !showEmojiPicker.value)
}
function hasCurrentUserReactedWithEmoji(value: string): boolean {
const user = model.value[value].find(u => u.id === authStore.info.id)
return typeof user !== 'undefined'
}
async function toggleReaction(value: string) {
if (hasCurrentUserReactedWithEmoji(value)) {
return removeReaction(value)
}
return addReaction(value)
}
</script>
<template>
<div
ref="reactionContainerRef"
class="reactions"
>
<BaseButton
v-for="(users, value) in (model as IReactionPerEntity)"
:key="'button' + value"
v-tooltip="getReactionTooltip(users, value)"
class="reaction-button"
:class="{'current-user-has-reacted': hasCurrentUserReactedWithEmoji(value)}"
:disabled
@click="toggleReaction(value)"
>
{{ value }} {{ users.length }}
</BaseButton>
<BaseButton
v-if="!disabled"
ref="emojiPickerButtonRef"
v-tooltip="$t('reaction.add')"
class="reaction-button"
@click.stop="toggleEmojiPicker"
>
<icon :icon="['far', 'face-laugh']" />
</BaseButton>
<CustomTransition name="fade">
<VuemojiPicker
v-if="showEmojiPicker"
ref="emojiPickerRef"
class="emoji-picker"
:style="{left: emojiPickerPosition?.left + 'px'}"
data-source="/emojis.json"
:is-dark="isDark"
@emojiClick="detail => addReaction(detail.unicode)"
/>
</CustomTransition>
</div>
</template>
<style scoped lang="scss">
.reaction-button {
margin-right: .25rem;
margin-bottom: .25rem;
padding: .175rem .5rem .15rem;
border: 1px solid var(--grey-400);
background: var(--grey-100);
border-radius: 100px;
font-size: .75rem;
&.current-user-has-reacted {
border-color: var(--primary);
background-color: hsla(var(--primary-h), var(--primary-s), var(--primary-light-l), 0.5);
}
}
.emoji-picker {
position: absolute;
z-index: 99;
margin-top: .5rem;
}
</style>

View File

@ -13,7 +13,7 @@
}"
>
<template v-if="icon">
<icon
<icon
v-if="showIconOnly"
:icon="icon"
:style="{'color': iconColor !== '' ? iconColor : undefined}"
@ -22,7 +22,7 @@
v-else
class="icon is-small"
>
<icon
<icon
:icon="icon"
:style="{'color': iconColor !== '' ? iconColor : undefined}"
/>
@ -34,20 +34,20 @@
<script lang="ts">
const BUTTON_TYPES_MAP = {
primary: 'is-primary',
secondary: 'is-outlined',
tertiary: 'is-text is-inverted underline-none',
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: 'XButton' }
export default {name: 'XButton'}
</script>
<script setup lang="ts">
import {computed, useSlots} from 'vue'
import BaseButton, {type BaseButtonProps} from '@/components/base/BaseButton.vue'
import type { IconProp } from '@fortawesome/fontawesome-svg-core'
import type {IconProp} from '@fortawesome/fontawesome-svg-core'
// extending the props of the BaseButton
export interface ButtonProps extends /* @vue-ignore */ BaseButtonProps {
@ -76,37 +76,38 @@ const showIconOnly = computed(() => icon !== '' && typeof slots.default === 'und
<style lang="scss" scoped>
.button {
transition: all $transition;
border: 0;
text-transform: uppercase;
font-size: 0.85rem;
font-weight: bold;
height: auto;
min-height: $button-height;
box-shadow: var(--shadow-sm);
display: inline-flex;
white-space: var(--button-white-space);
transition: all $transition;
border: 0;
text-transform: uppercase;
font-size: 0.85rem;
font-weight: bold;
height: auto;
min-height: $button-height;
box-shadow: var(--shadow-sm);
display: inline-flex;
white-space: var(--button-white-space);
line-height: 1;
&:hover {
box-shadow: var(--shadow-md);
}
&:hover {
box-shadow: var(--shadow-md);
}
&.fullheight {
padding-right: 7px;
height: 100%;
}
&.fullheight {
padding-right: 7px;
height: 100%;
}
&.is-active,
&.is-focused,
&:active,
&:focus,
&:focus:not(:active) {
box-shadow: var(--shadow-xs) !important;
}
&.is-active,
&.is-focused,
&:active,
&:focus,
&:focus:not(:active) {
box-shadow: var(--shadow-xs) !important;
}
&.is-primary.is-outlined:hover {
color: var(--white);
}
&.is-primary.is-outlined:hover {
color: var(--white);
}
}
.is-small {
@ -114,6 +115,6 @@ const showIconOnly = computed(() => icon !== '' && typeof slots.default === 'und
}
.underline-none {
text-decoration: none !important;
text-decoration: none !important;
}
</style>

View File

@ -63,6 +63,7 @@
<div class="flatpickr-container">
<flat-pickr
ref="flatPickrRef"
v-model="flatPickrDate"
:config="flatPickerConfig"
/>
@ -70,7 +71,7 @@
</template>
<script lang="ts" setup>
import {ref, toRef, watch, computed, type PropType} from 'vue'
import {computed, onBeforeUnmount, onMounted, type PropType, ref, toRef, watch} from 'vue'
import flatPickr from 'vue-flatpickr-component'
import 'flatpickr/dist/flatpickr.css'
@ -81,7 +82,7 @@ import {calculateDayInterval} from '@/helpers/time/calculateDayInterval'
import {calculateNearestHours} from '@/helpers/time/calculateNearestHours'
import {createDateFromString} from '@/helpers/time/createDateFromString'
import {useI18n} from 'vue-i18n'
import { getFlatpickrLanguage } from '@/helpers/flatpickrLanguage'
import {getFlatpickrLanguage} from '@/helpers/flatpickrLanguage'
const props = defineProps({
modelValue: {
@ -105,6 +106,7 @@ watch(
{immediate: true},
)
const flatPickrRef = ref<HTMLElement | null>(null)
const flatPickerConfig = computed(() => ({
altFormat: t('date.altFormatLong'),
altInput: true,
@ -142,6 +144,41 @@ const flatPickrDate = computed({
},
})
onMounted(() => {
const inputs = flatPickrRef.value?.$el.parentNode.querySelectorAll('.numInputWrapper > input.numInput')
inputs.forEach(i => {
i.addEventListener('input', handleFlatpickrInput)
})
})
onBeforeUnmount(() => {
const inputs = flatPickrRef.value?.$el.parentNode.querySelectorAll('.numInputWrapper > input.numInput')
inputs.forEach(i => {
i.removeEventListener('input', handleFlatpickrInput)
})
})
// Flatpickr only returns a change event when the value in the input it's referring to changes.
// That means it will usually only trigger when the focus is moved out of the input field.
// This is fine most of the time. However, since we're displaying flatpickr in a popup,
// the whole html dom instance might get destroyed, before the change event had a
// chance to fire. In that case, it would not update the date value. To fix
// this, we're now listening on every change and bubble them up as soon
// as they happen.
function handleFlatpickrInput(e) {
const newDate = new Date(date?.value || 'now')
if (e.target.classList.contains('flatpickr-minute')) {
newDate.setMinutes(e.target.value)
}
if (e.target.classList.contains('flatpickr-hour')) {
newDate.setHours(e.target.value)
}
if (e.target.classList.contains('cur-year')) {
newDate.setFullYear(e.target.value)
}
flatPickrDate.value = newDate
}
function setDateValue(dateString: string | Date | null) {
if (dateString === null) {

View File

@ -139,7 +139,7 @@
<BaseButton
v-tooltip="$t('input.editor.image')"
class="editor-toolbar__button"
@click="openImagePicker"
@click="e => emit('imageUploadClicked', e)"
>
<span class="icon">
<icon icon="fa-image" />
@ -347,16 +347,14 @@ const {
editor: Editor,
}>()
const emit = defineEmits(['imageUploadClicked'])
const tableMode = ref(false)
function toggleTableMode() {
tableMode.value = !tableMode.value
}
function openImagePicker() {
document.getElementById('tiptap__image-upload').click()
}
function setLink(event) {
setLinkInEditor(event.target.getBoundingClientRect(), editor)
}

View File

@ -6,7 +6,7 @@
<EditorToolbar
v-if="editor && isEditing"
:editor="editor"
:upload-callback="uploadCallback"
@imageUploadClicked="triggerImageInput"
/>
<BubbleMenu
v-if="editor && isEditing"
@ -338,10 +338,12 @@ const editor = useEditor({
HardBreak.extend({
addKeyboardShortcuts() {
return {
'Shift-Enter': () => this.editor.commands.setHardBreak(),
'Mod-Enter': () => {
if (contentHasChanged.value) {
bubbleSave()
}
return true
},
}
},
@ -374,7 +376,7 @@ const editor = useEditor({
Typography,
Underline,
Link.configure({
openOnClick: true,
openOnClick: false,
validate: (href: string) => /^https?:\/\//.test(href),
}),
Table.configure({
@ -489,6 +491,15 @@ function uploadAndInsertFiles(files: File[] | FileList) {
})
}
function triggerImageInput(event) {
if (typeof uploadCallback !== 'undefined') {
uploadInputRef.value?.click()
return
}
addImage(event)
}
async function addImage(event) {
if (typeof uploadCallback !== 'undefined') {
@ -522,16 +533,20 @@ onMounted(async () => {
await nextTick()
const input = tiptapInstanceRef.value?.querySelectorAll('.tiptap__editor')[0]?.children[0]
input?.addEventListener('paste', handleImagePaste)
if (typeof uploadCallback !== 'undefined') {
const input = tiptapInstanceRef.value?.querySelectorAll('.tiptap__editor')[0]?.children[0]
input?.addEventListener('paste', handleImagePaste)
}
setModeAndValue(modelValue)
})
onBeforeUnmount(() => {
nextTick(() => {
const input = tiptapInstanceRef.value?.querySelectorAll('.tiptap__editor')[0]?.children[0]
input?.removeEventListener('paste', handleImagePaste)
if (typeof uploadCallback !== 'undefined') {
const input = tiptapInstanceRef.value?.querySelectorAll('.tiptap__editor')[0]?.children[0]
input?.removeEventListener('paste', handleImagePaste)
}
})
if (editShortcut !== '') {
document.removeEventListener('keydown', setFocusToEditor)
@ -558,6 +573,10 @@ function handleImagePaste(event) {
// See https://github.com/github/hotkey/discussions/85#discussioncomment-5214660
function setFocusToEditor(event) {
if (event.target.shadowRoot) {
return
}
const hotkeyString = eventToHotkeyString(event)
if (!hotkeyString) return
if (hotkeyString !== editShortcut ||
@ -596,7 +615,7 @@ watch(
() => isEditing.value,
async editing => {
await nextTick()
let checkboxes = tiptapInstanceRef.value?.querySelectorAll('[data-checked]')
if (typeof checkboxes === 'undefined' || checkboxes.length === 0) {
// For some reason, this works when we check a second time.
@ -799,7 +818,7 @@ watch(
td,
th {
min-width: 1em;
border: 2px solid #ced4da;
border: 2px solid var(--grey-300) !important;
padding: 3px 5px;
vertical-align: top;
box-sizing: border-box;
@ -813,7 +832,7 @@ watch(
th {
font-weight: bold;
text-align: left;
background-color: #f1f3f5;
background-color: var(--grey-200);
}
.selectedCell:after {
@ -874,8 +893,14 @@ ul[data-type='taskList'] {
padding: 0;
margin-left: 0;
li[data-checked='true'] {
color: var(--grey-500);
text-decoration: line-through;
}
li {
display: flex;
margin-top: 0.25rem;
> label {
flex: 0 0 auto;

View File

@ -87,7 +87,7 @@ import {
faStar,
faSun,
faTimesCircle,
faCircleQuestion,
faCircleQuestion, faFaceLaugh,
} from '@fortawesome/free-regular-svg-icons'
import {FontAwesomeIcon} from '@fortawesome/vue-fontawesome'
@ -186,6 +186,7 @@ library.add(faXmarksLines)
library.add(faFont)
library.add(faRulerHorizontal)
library.add(faUnderline)
library.add(faFaceLaugh)
// overwriting the wrong types
export default FontAwesomeIcon as unknown as FontAwesomeIconFixedTypes

View File

@ -62,7 +62,7 @@ export const KEYBOARD_SHORTCUTS : ShortcutGroup[] = [
},
{
title: 'project.kanban.title',
available: (route) => route.name === 'project.kanban',
available: (route) => route.name === 'project.view',
shortcuts: [
{
title: 'keyboardShortcuts.task.done',

View File

@ -188,12 +188,6 @@ $modal-width: 1024px;
.info {
font-style: italic;
}
p {
display: flex;
justify-content: space-between;
align-items: center;
}
}
}

View File

@ -1,7 +1,7 @@
<template>
<slot
name="trigger"
:is-open="open"
:is-open="openValue"
:toggle="toggle"
:close="close"
/>
@ -9,13 +9,13 @@
ref="popup"
class="popup"
:class="{
'is-open': open,
'has-overflow': props.hasOverflow && open
'is-open': openValue,
'has-overflow': props.hasOverflow && openValue
}"
>
<slot
name="content"
:is-open="open"
:is-open="openValue"
:toggle="toggle"
:close="close"
/>
@ -23,7 +23,7 @@
</template>
<script setup lang="ts">
import {ref} from 'vue'
import {ref, watch} from 'vue'
import {onClickOutside} from '@vueuse/core'
const props = defineProps({
@ -31,24 +31,35 @@ const props = defineProps({
type: Boolean,
default: false,
},
open: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['close'])
const open = ref(false)
watch(
() => props.open,
nowOpen => {
openValue.value = nowOpen
},
)
const openValue = ref(false)
const popup = ref<HTMLElement | null>(null)
function close() {
open.value = false
openValue.value = false
emit('close')
}
function toggle() {
open.value = !open.value
openValue.value = !openValue.value
}
onClickOutside(popup, () => {
if (!open.value) {
if (!openValue.value) {
return
}
close()

View File

@ -6,44 +6,23 @@
<h1 class="project-title-print">
{{ getProjectTitle(currentProject) }}
</h1>
<div class="switch-view-container d-print-none">
<div class="switch-view">
<div
class="switch-view-container d-print-none"
:class="{'is-justify-content-flex-end': views.length === 1}"
>
<div
v-if="views.length > 1"
class="switch-view"
>
<BaseButton
v-shortcut="'g l'"
:title="$t('keyboardShortcuts.project.switchToListView')"
v-for="v in views"
:key="v.id"
class="switch-view-button"
:class="{'is-active': viewName === 'project'}"
:to="{ name: 'project.list', params: { projectId } }"
:class="{'is-active': v.id === viewId}"
:to="{ name: 'project.view', params: { projectId, viewId: v.id } }"
>
{{ $t('project.list.title') }}
</BaseButton>
<BaseButton
v-shortcut="'g g'"
:title="$t('keyboardShortcuts.project.switchToGanttView')"
class="switch-view-button"
:class="{'is-active': viewName === 'gantt'}"
:to="{ name: 'project.gantt', params: { projectId } }"
>
{{ $t('project.gantt.title') }}
</BaseButton>
<BaseButton
v-shortcut="'g t'"
:title="$t('keyboardShortcuts.project.switchToTableView')"
class="switch-view-button"
:class="{'is-active': viewName === 'table'}"
:to="{ name: 'project.table', params: { projectId } }"
>
{{ $t('project.table.title') }}
</BaseButton>
<BaseButton
v-shortcut="'g k'"
:title="$t('keyboardShortcuts.project.switchToKanbanView')"
class="switch-view-button"
:class="{'is-active': viewName === 'kanban'}"
:to="{ name: 'project.kanban', params: { projectId } }"
>
{{ $t('project.kanban.title') }}
{{ getViewTitle(v) }}
</BaseButton>
</div>
<slot name="header" />
@ -63,7 +42,7 @@
</template>
<script setup lang="ts">
import {ref, computed, watch} from 'vue'
import {computed, ref, watch} from 'vue'
import {useRoute} from 'vue-router'
import BaseButton from '@/components/base/BaseButton.vue'
@ -79,26 +58,27 @@ import {useTitle} from '@/composables/useTitle'
import {useBaseStore} from '@/stores/base'
import {useProjectStore} from '@/stores/projects'
import type {IProject} from '@/modelTypes/IProject'
import type {IProjectView} from '@/modelTypes/IProjectView'
import {useI18n} from 'vue-i18n'
const props = defineProps({
projectId: {
type: Number,
required: true,
},
viewName: {
type: String,
required: true,
},
})
const {
projectId,
viewId,
} = defineProps<{
projectId: IProject['id'],
viewId: IProjectView['id'],
}>()
const route = useRoute()
const {t} = useI18n()
const baseStore = useBaseStore()
const projectStore = useProjectStore()
const projectService = ref(new ProjectService())
const loadedProjectId = ref(0)
const currentProject = computed(() => {
const currentProject = computed<IProject>(() => {
return typeof baseStore.currentProject === 'undefined' ? {
id: 0,
title: '',
@ -108,13 +88,15 @@ const currentProject = computed(() => {
})
useTitle(() => currentProject.value?.id ? getProjectTitle(currentProject.value) : '')
const views = computed(() => projectStore.projects[projectId]?.views)
// 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 project multiple times, even when navigating away from it.
// This caused wired bugs where the project background would be set on the home page but only right after setting a new
// project background and then navigating to home. It also highlighted the project in the menu and didn't allow changing any
// of it, most likely due to the rights not being properly populated.
watch(
() => props.projectId,
() => projectId,
// loadProject
async (projectIdToLoad: number) => {
const projectData = {id: projectIdToLoad}
@ -130,11 +112,11 @@ watch(
)
&& typeof currentProject.value !== 'undefined' && currentProject.value.maxRight !== null
) {
loadedProjectId.value = props.projectId
loadedProjectId.value = projectId
return
}
console.debug(`Loading project, props.viewName = ${props.viewName}, $route.params =`, route.params, `, loadedProjectId = ${loadedProjectId.value}, currentProject = `, currentProject.value)
console.debug('Loading project, $route.params =', route.params, `, loadedProjectId = ${loadedProjectId.value}, currentProject = `, currentProject.value)
// Set the current project to the one we're about to load so that the title is already shown at the top
loadedProjectId.value = 0
@ -149,31 +131,51 @@ watch(
const loadedProject = await projectService.value.get(project)
baseStore.handleSetCurrentProject({project: loadedProject})
} finally {
loadedProjectId.value = props.projectId
loadedProjectId.value = projectId
}
},
{immediate: true},
)
function getViewTitle(view: IProjectView) {
switch (view.title) {
case 'List':
return t('project.list.title')
case 'Gantt':
return t('project.gantt.title')
case 'Table':
return t('project.table.title')
case 'Kanban':
return t('project.kanban.title')
}
return view.title
}
</script>
<style lang="scss" scoped>
.switch-view-container {
@media screen and (max-width: $tablet) {
display: flex;
justify-content: center;
flex-direction: column;
}
min-height: $switch-view-height;
margin-bottom: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
@media screen and (max-width: $tablet) {
justify-content: center;
flex-direction: column;
}
}
.switch-view {
background: var(--white);
display: inline-flex;
border-radius: $radius;
font-size: .75rem;
box-shadow: var(--shadow-sm);
height: $switch-view-height;
margin: 0 auto 1rem;
padding: .5rem;
background: var(--white);
display: inline-flex;
border-radius: $radius;
font-size: .75rem;
box-shadow: var(--shadow-sm);
padding: .5rem;
}
.switch-view-button {
@ -201,7 +203,7 @@ watch(
// FIXME: this should be in notification and set via a prop
.is-archived .notification.is-warning {
margin-bottom: 1rem;
margin-bottom: 1rem;
}
.project-title-print {
@ -209,7 +211,7 @@ watch(
font-size: 1.75rem;
text-align: center;
margin-bottom: .5rem;
@media print {
display: block;
}

View File

@ -0,0 +1,22 @@
<script setup lang="ts">
import FilterInput from '@/components/project/partials/FilterInput.vue'
function initState(value: string) {
return {
value,
}
}
</script>
<template>
<Story title="Filter Input">
<Variant
title="With date values"
:init-state="initState('dueDate < now && done = false && dueDate > now/w+1w')"
>
<template #default="{state}">
<FilterInput v-model="state.value" />
</template>
</Variant>
</Story>
</template>

View File

@ -0,0 +1,402 @@
<script setup lang="ts">
import {computed, nextTick, ref, watch} from 'vue'
import {useAutoHeightTextarea} from '@/composables/useAutoHeightTextarea'
import DatepickerWithValues from '@/components/date/datepickerWithValues.vue'
import UserService from '@/services/user'
import AutocompleteDropdown from '@/components/input/AutocompleteDropdown.vue'
import {useLabelStore} from '@/stores/labels'
import XLabel from '@/components/tasks/partials/label.vue'
import User from '@/components/misc/user.vue'
import ProjectUserService from '@/services/projectUsers'
import {useProjectStore} from '@/stores/projects'
import {
ASSIGNEE_FIELDS,
AUTOCOMPLETE_FIELDS,
AVAILABLE_FILTER_FIELDS,
DATE_FIELDS,
FILTER_JOIN_OPERATOR,
FILTER_OPERATORS,
FILTER_OPERATORS_REGEX,
getFilterFieldRegexPattern,
LABEL_FIELDS,
} from '@/helpers/filters'
import {useDebounceFn} from '@vueuse/core'
import {createRandomID} from '@/helpers/randomId'
const {
modelValue,
projectId,
inputLabel = undefined,
} = defineProps<{
modelValue: string,
projectId?: number,
inputLabel?: string,
}>()
const emit = defineEmits(['update:modelValue', 'blur'])
const filterQuery = ref<string>('')
const {
textarea: filterInput,
height,
} = useAutoHeightTextarea(filterQuery)
const id = ref(createRandomID())
watch(
() => modelValue,
() => {
filterQuery.value = modelValue
},
{immediate: true},
)
watch(
() => filterQuery.value,
() => {
if (filterQuery.value !== modelValue) {
emit('update:modelValue', filterQuery.value)
}
},
)
const userService = new UserService()
const projectUserService = new ProjectUserService()
function escapeHtml(unsafe: string): string {
return unsafe
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}
function unEscapeHtml(unsafe: string): string {
return unsafe
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot/g, '"')
.replace(/&#039;/g, '\'')
}
const highlightedFilterQuery = computed(() => {
let highlighted = escapeHtml(filterQuery.value)
DATE_FIELDS
.forEach(o => {
const pattern = new RegExp(o + '(\\s*)' + FILTER_OPERATORS_REGEX + '(\\s*)([\'"]?)([^\'"\\s]+\\1?)?', 'ig')
highlighted = highlighted.replaceAll(pattern, (match, spacesBefore, token, spacesAfter, start, value, position) => {
if (typeof value === 'undefined') {
value = ''
}
let endPadding = ''
if(value.endsWith(' ')) {
const fullLength = value.length
value = value.trimEnd()
const numberOfRemovedSpaces = fullLength - value.length
endPadding = endPadding.padEnd(numberOfRemovedSpaces, ' ')
}
return `${o}${spacesBefore}${token}${spacesAfter}<button class="is-primary filter-query__date_value" data-position="${position}">${value}</button><span class="filter-query__date_value_placeholder">${value}</span>${endPadding}`
})
})
ASSIGNEE_FIELDS
.forEach(f => {
const pattern = new RegExp(f + '\\s*' + FILTER_OPERATORS_REGEX + '\\s*([\'"]?)([^\'"\\s]+\\1?)?', 'ig')
highlighted = highlighted.replaceAll(pattern, (match, token, start, value) => {
if (typeof value === 'undefined') {
value = ''
}
return `${f} ${token} <span class="filter-query__assignee_value">${value}<span>`
})
})
FILTER_JOIN_OPERATOR
.map(o => escapeHtml(o))
.forEach(o => {
highlighted = highlighted.replaceAll(o, `<span class="filter-query__join-operator">${o}</span>`)
})
LABEL_FIELDS
.forEach(f => {
const pattern = getFilterFieldRegexPattern(f)
highlighted = highlighted.replaceAll(pattern, (match, prefix, operator, space, value) => {
if (typeof value === 'undefined') {
value = ''
}
let labelTitles = [value.trim()]
if (operator === 'in' || operator === '?=') {
labelTitles = value.split(',').map(v => v.trim())
}
const labelsHtml: string[] = []
labelTitles.forEach(t => {
const label = labelStore.getLabelByExactTitle(t) || undefined
labelsHtml.push(`<span class="filter-query__label_value" style="background-color: ${label?.hexColor}; color: ${label?.textColor}">${label?.title ?? t}</span>`)
})
const endSpace = value.endsWith(' ') ? ' ' : ''
return `${f} ${operator} ${labelsHtml.join(', ')}${endSpace}`
})
})
FILTER_OPERATORS
.map(o => ` ${escapeHtml(o)} `)
.forEach(o => {
highlighted = highlighted.replaceAll(o, `<span class="filter-query__operator">${o}</span>`)
})
AVAILABLE_FILTER_FIELDS.forEach(f => {
highlighted = highlighted.replaceAll(f, `<span class="filter-query__field">${f}</span>`)
})
return highlighted
})
const currentOldDatepickerValue = ref('')
const currentDatepickerValue = ref('')
const currentDatepickerPos = ref()
const datePickerPopupOpen = ref(false)
watch(
() => highlightedFilterQuery.value,
async () => {
await nextTick()
document.querySelectorAll('button.filter-query__date_value')
.forEach(b => {
b.addEventListener('click', event => {
event.preventDefault()
event.stopPropagation()
const button = event.target
currentOldDatepickerValue.value = button?.innerText
currentDatepickerValue.value = button?.innerText
currentDatepickerPos.value = parseInt(button?.dataset.position)
datePickerPopupOpen.value = true
})
})
},
{immediate: true},
)
function updateDateInQuery(newDate: string) {
// Need to escape and unescape the query because the positions are based on the escaped query
let escaped = escapeHtml(filterQuery.value)
escaped = escaped
.substring(0, currentDatepickerPos.value)
+ escaped
.substring(currentDatepickerPos.value)
.replace(currentOldDatepickerValue.value, newDate)
currentOldDatepickerValue.value = newDate
filterQuery.value = unEscapeHtml(escaped)
}
const autocompleteMatchPosition = ref(0)
const autocompleteMatchText = ref('')
const autocompleteResultType = ref<'labels' | 'assignees' | 'projects' | null>(null)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const autocompleteResults = ref<any[]>([])
const labelStore = useLabelStore()
const projectStore = useProjectStore()
function handleFieldInput() {
const cursorPosition = filterInput.value.selectionStart
const textUpToCursor = filterQuery.value.substring(0, cursorPosition)
autocompleteResults.value = []
AUTOCOMPLETE_FIELDS.forEach(field => {
const pattern = new RegExp('(' + field + '\\s*' + FILTER_OPERATORS_REGEX + '\\s*)([\'"]?)([^\'"&|()]+\\1?)?$', 'ig')
const match = pattern.exec(textUpToCursor)
if (match !== null) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [matched, prefix, operator, space, keyword] = match
if (keyword) {
let search = keyword
if (operator === 'in' || operator === '?=') {
const keywords = keyword.split(',')
search = keywords[keywords.length - 1].trim()
}
if (matched.startsWith('label')) {
autocompleteResultType.value = 'labels'
autocompleteResults.value = labelStore.filterLabelsByQuery([], search)
}
if (matched.startsWith('assignee')) {
autocompleteResultType.value = 'assignees'
if (projectId) {
projectUserService.getAll({projectId}, {s: search})
.then(users => autocompleteResults.value = users.length > 1 ? users : [])
} else {
userService.getAll({}, {s: search})
.then(users => autocompleteResults.value = users.length > 1 ? users : [])
}
}
if (!projectId && matched.startsWith('project')) {
autocompleteResultType.value = 'projects'
autocompleteResults.value = projectStore.searchProject(search)
}
autocompleteMatchText.value = keyword
autocompleteMatchPosition.value = match.index + prefix.length - 1 + keyword.replace(search, '').length
}
}
})
}
function autocompleteSelect(value) {
filterQuery.value = filterQuery.value.substring(0, autocompleteMatchPosition.value + 1) +
(autocompleteResultType.value === 'assignees'
? value.username
: value.title) +
filterQuery.value.substring(autocompleteMatchPosition.value + autocompleteMatchText.value.length + 1)
autocompleteResults.value = []
}
// The blur from the textarea might happen before the replacement after autocomplete select was done.
// That caused listeners to try and replace values earlier, resulting in broken queries.
const blurDebounced = useDebounceFn(() => emit('blur'), 500)
</script>
<template>
<div class="field">
<label
class="label"
:for="id"
>
{{ inputLabel ?? $t('filters.query.title') }}
</label>
<AutocompleteDropdown
:options="autocompleteResults"
@blur="filterInput?.blur()"
@update:modelValue="autocompleteSelect"
>
<template
#input="{ onKeydown, onFocusField }"
>
<div class="control filter-input">
<textarea
:id
ref="filterInput"
v-model="filterQuery"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
class="input"
:class="{'has-autocomplete-results': autocompleteResults.length > 0}"
:placeholder="$t('filters.query.placeholder')"
@input="handleFieldInput"
@focus="onFocusField"
@keydown="onKeydown"
@blur="blurDebounced"
/>
<div
class="filter-input-highlight"
:style="{'height': height}"
v-html="highlightedFilterQuery"
/>
<DatepickerWithValues
v-model="currentDatepickerValue"
:open="datePickerPopupOpen"
@close="() => datePickerPopupOpen = false"
@update:modelValue="updateDateInQuery"
/>
</div>
</template>
<template
#result="{ item }"
>
<XLabel
v-if="autocompleteResultType === 'labels'"
:label="item"
/>
<User
v-else-if="autocompleteResultType === 'assignees'"
:user="item"
:avatar-size="25"
/>
<template v-else>
{{ item.title }}
</template>
</template>
</AutocompleteDropdown>
</div>
</template>
<style lang="scss">
.filter-input-highlight {
&, button.filter-query__date_value {
color: var(--card-color);
}
span {
&.filter-query__field {
color: var(--code-literal);
}
&.filter-query__operator {
color: var(--code-keyword);
}
&.filter-query__join-operator {
color: var(--code-section);
}
&.filter-query__date_value_placeholder {
display: inline-block;
color: transparent;
}
&.filter-query__assignee_value, &.filter-query__label_value {
border-radius: $radius;
background-color: var(--grey-200);
color: var(--grey-700);
}
}
button.filter-query__date_value {
border-radius: $radius;
position: absolute;
margin-top: calc((0.25em - 0.125rem) * -1);
height: 1.75rem;
padding: 0;
border: 0;
background: transparent;
font-size: 1rem;
cursor: pointer;
line-height: 1.5;
}
}
</style>
<style lang="scss" scoped>
.filter-input {
position: relative;
textarea {
position: absolute;
background: transparent !important;
resize: none;
text-fill-color: transparent;
-webkit-text-fill-color: transparent;
&::placeholder {
text-fill-color: var(--input-placeholder-color);
-webkit-text-fill-color: var(--input-placeholder-color);
}
&.has-autocomplete-results {
border-radius: var(--input-radius) var(--input-radius) 0 0;
}
}
.filter-input-highlight {
background: var(--white);
height: 2.5em;
line-height: 1.5;
padding: .5em .75em;
word-break: break-word;
}
}
</style>

View File

@ -0,0 +1,80 @@
<script setup lang="ts">
import {ref} from 'vue'
import BaseButton from '@/components/base/BaseButton.vue'
const showDocs = ref(false)
</script>
<template>
<BaseButton
class="has-text-primary"
@click="showDocs = !showDocs"
>
{{ $t('filters.query.help.link') }}
</BaseButton>
<Transition>
<div
v-if="showDocs"
class="content"
>
<p>{{ $t('filters.query.help.intro') }}</p>
<ul>
<li><code>done</code>: {{ $t('filters.query.help.fields.done') }}</li>
<li><code>priority</code>: {{ $t('filters.query.help.fields.priority') }}</li>
<li><code>percentDone</code>: {{ $t('filters.query.help.fields.percentDone') }}</li>
<li><code>dueDate</code>: {{ $t('filters.query.help.fields.dueDate') }}</li>
<li><code>startDate</code>: {{ $t('filters.query.help.fields.startDate') }}</li>
<li><code>endDate</code>: {{ $t('filters.query.help.fields.endDate') }}</li>
<li><code>doneAt</code>: {{ $t('filters.query.help.fields.doneAt') }}</li>
<li><code>assignees</code>: {{ $t('filters.query.help.fields.assignees') }}</li>
<li><code>labels</code>: {{ $t('filters.query.help.fields.labels') }}</li>
<li><code>project</code>: {{ $t('filters.query.help.fields.project') }}</li>
</ul>
<p>{{ $t('filters.query.help.canUseDatemath') }}</p>
<p>{{ $t('filters.query.help.operators.intro') }}</p>
<ul>
<li><code>!=</code>: {{ $t('filters.query.help.operators.notEqual') }}</li>
<li><code>=</code>: {{ $t('filters.query.help.operators.equal') }}</li>
<li><code>&gt;</code>: {{ $t('filters.query.help.operators.greaterThan') }}</li>
<li><code>&gt;=</code>: {{ $t('filters.query.help.operators.greaterThanOrEqual') }}</li>
<li><code>&lt;</code>: {{ $t('filters.query.help.operators.lessThan') }}</li>
<li><code>&lt;=</code>: {{ $t('filters.query.help.operators.lessThanOrEqual') }}</li>
<li><code>like</code>: {{ $t('filters.query.help.operators.like') }}</li>
<li><code>in</code>: {{ $t('filters.query.help.operators.in') }}</li>
</ul>
<p>{{ $t('filters.query.help.logicalOperators.intro') }}</p>
<ul>
<li><code>&amp;&amp;</code>: {{ $t('filters.query.help.logicalOperators.and') }}</li>
<li><code>||</code>: {{ $t('filters.query.help.logicalOperators.or') }}</li>
<li><code>(</code> and <code>)</code>: {{ $t('filters.query.help.logicalOperators.parentheses') }}</li>
</ul>
<p>{{ $t('filters.query.help.examples.intro') }}</p>
<ul>
<li><code>priority = 4</code>: {{ $t('filters.query.help.examples.priorityEqual') }}</li>
<li><code>dueDate &lt; now</code>: {{ $t('filters.query.help.examples.dueDatePast') }}</li>
<li>
<code>done = false &amp;&amp; priority &gt;= 3</code>:
{{ $t('filters.query.help.examples.undoneHighPriority') }}
</li>
<li><code>assignees in [user1, user2]</code>: {{ $t('filters.query.help.examples.assigneesIn') }}</li>
<li>
<code>(priority = 1 || priority = 2) &amp;&amp; dueDate &lt;= now</code>:
{{ $t('filters.query.help.examples.priorityOneOrTwoPastDue') }}
</li>
</ul>
</div>
</Transition>
</template>
<style scoped lang="scss">
.v-enter-active,
.v-leave-active {
transition: all $transition-duration ease;
}
.v-enter-from,
.v-leave-to {
transform: scaleY(0);
}
</style>

View File

@ -30,7 +30,7 @@
>
<icon icon="filter" />
</span>
{{ project.title }}
{{ getProjectTitle(project) }}
</div>
<BaseButton
class="project-button"
@ -59,6 +59,7 @@ import BaseButton from '@/components/base/BaseButton.vue'
import {useProjectBackground} from './useProjectBackground'
import {useProjectStore} from '@/stores/projects'
import {getProjectTitle} from '@/helpers/getProjectTitle'
const {
project,

View File

@ -63,6 +63,10 @@ const filteredProjects = computed(() => {
@media screen and (min-width: $widescreen) {
--project-grid-columns: 5;
.project-grid-item:nth-child(6) {
display: none;
}
}
}

View File

@ -1,14 +1,8 @@
<template>
<x-button
v-if="hasFilters"
variant="secondary"
@click="clearFilters"
>
{{ $t('filters.clear') }}
</x-button>
<x-button
variant="secondary"
icon="filter"
:class="{'has-filters': hasFilters}"
@click="() => modalOpen = true"
>
{{ $t('filters.title') }}
@ -25,6 +19,8 @@
v-model="value"
:has-title="true"
class="filter-popup"
@update:modelValue="emitChanges"
@showResultsButtonClicked="() => modalOpen = false"
/>
</modal>
</template>
@ -34,58 +30,35 @@ import {computed, ref, watch} from 'vue'
import Filters from '@/components/project/partials/filters.vue'
import {getDefaultParams} from '@/composables/useTaskList'
import {type TaskFilterParams} from '@/services/taskCollection'
const props = defineProps({
modelValue: {
required: true,
},
})
const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])
const value = computed({
get() {
return props.modelValue
},
set(value) {
if(props.modelValue === value) {
return
}
emit('update:modelValue', value)
},
})
const value = ref<TaskFilterParams>({})
watch(
() => props.modelValue,
(modelValue) => {
(modelValue: TaskFilterParams) => {
value.value = modelValue
},
{immediate: true},
)
function emitChanges(newValue: TaskFilterParams) {
emit('update:modelValue', {
...value.value,
filter: newValue.filter,
s: newValue.s,
})
}
const hasFilters = computed(() => {
// this.value also contains the page parameter which we don't want to include in filters
// eslint-disable-next-line no-unused-vars
const {filter_by, filter_value, filter_comparator, filter_concat, s} = value.value
const def = {...getDefaultParams()}
const params = {filter_by, filter_value, filter_comparator, filter_concat, s}
const defaultParams = {
filter_by: def.filter_by,
filter_value: def.filter_value,
filter_comparator: def.filter_comparator,
filter_concat: def.filter_concat,
s: s ? def.s : undefined,
}
return JSON.stringify(params) !== JSON.stringify(defaultParams)
return value.value.filter !== '' ||
value.value.s !== ''
})
const modalOpen = ref(false)
function clearFilters() {
value.value = {...getDefaultParams()}
}
</script>
<style scoped lang="scss">
@ -96,4 +69,21 @@ function clearFilters() {
margin: 2rem 0 1rem;
}
}
$filter-bubble-size: .75rem;
.has-filters {
position: relative;
&::after {
content: '';
position: absolute;
top: math.div($filter-bubble-size, -2);
right: math.div($filter-bubble-size, -2);
width: $filter-bubble-size;
height: $filter-bubble-size;
border-radius: 100%;
background: var(--primary);
}
}
</style>

View File

@ -2,195 +2,43 @@
<card
class="filters has-overflow"
:title="hasTitle ? $t('filters.title') : ''"
role="search"
>
<FilterInput
v-model="filterQuery"
:project-id="projectId"
@blur="change()"
/>
<div class="field is-flex is-flex-direction-column">
<Fancycheckbox
v-model="params.filter_include_nulls"
@update:modelValue="change()"
@blur="change()"
>
{{ $t('filters.attributes.includeNulls') }}
</Fancycheckbox>
<Fancycheckbox
v-model="filters.requireAllFilters"
@update:modelValue="setFilterConcat()"
>
{{ $t('filters.attributes.requireAll') }}
</Fancycheckbox>
<Fancycheckbox
v-model="filters.done"
@update:modelValue="setDoneFilter"
>
{{ $t('filters.attributes.showDoneTasks') }}
</Fancycheckbox>
<Fancycheckbox
v-if="!['project.kanban', 'project.table'].includes($route.name as string)"
v-model="sortAlphabetically"
@update:modelValue="change()"
>
{{ $t('filters.attributes.sortAlphabetically') }}
</Fancycheckbox>
</div>
<div class="field">
<label class="label">{{ $t('misc.search') }}</label>
<div class="control">
<input
v-model="params.s"
class="input"
:placeholder="$t('misc.search')"
@blur="change()"
@keyup.enter="change()"
>
</div>
</div>
<div class="field">
<label class="label">{{ $t('task.attributes.priority') }}</label>
<div class="control single-value-control">
<PrioritySelect
v-model.number="filters.priority"
:disabled="!filters.usePriority || undefined"
@update:modelValue="setPriority"
/>
<Fancycheckbox
v-model="filters.usePriority"
@update:modelValue="setPriority"
>
{{ $t('filters.attributes.enablePriority') }}
</Fancycheckbox>
</div>
</div>
<div class="field">
<label class="label">{{ $t('task.attributes.percentDone') }}</label>
<div class="control single-value-control">
<PercentDoneSelect
v-model.number="filters.percentDone"
:disabled="!filters.usePercentDone || undefined"
@update:modelValue="setPercentDoneFilter"
/>
<Fancycheckbox
v-model="filters.usePercentDone"
@update:modelValue="setPercentDoneFilter"
>
{{ $t('filters.attributes.enablePercentDone') }}
</Fancycheckbox>
</div>
</div>
<div class="field">
<label class="label">{{ $t('task.attributes.dueDate') }}</label>
<div class="control">
<DatepickerWithRange
v-model="filters.dueDate"
@update:modelValue="values => setDateFilter('due_date', values)"
>
<template #trigger="{toggle, buttonText}">
<x-button
variant="secondary"
:shadow="false"
class="mb-2"
@click.prevent.stop="toggle()"
>
{{ buttonText }}
</x-button>
</template>
</DatepickerWithRange>
</div>
</div>
<div class="field">
<label class="label">{{ $t('task.attributes.startDate') }}</label>
<div class="control">
<DatepickerWithRange
v-model="filters.startDate"
@update:modelValue="values => setDateFilter('start_date', values)"
>
<template #trigger="{toggle, buttonText}">
<x-button
variant="secondary"
:shadow="false"
class="mb-2"
@click.prevent.stop="toggle()"
>
{{ buttonText }}
</x-button>
</template>
</DatepickerWithRange>
</div>
</div>
<div class="field">
<label class="label">{{ $t('task.attributes.endDate') }}</label>
<div class="control">
<DatepickerWithRange
v-model="filters.endDate"
@update:modelValue="values => setDateFilter('end_date', values)"
>
<template #trigger="{toggle, buttonText}">
<x-button
variant="secondary"
:shadow="false"
class="mb-2"
@click.prevent.stop="toggle()"
>
{{ buttonText }}
</x-button>
</template>
</DatepickerWithRange>
</div>
</div>
<div class="field">
<label class="label">{{ $t('task.attributes.reminders') }}</label>
<div class="control">
<DatepickerWithRange
v-model="filters.reminders"
@update:modelValue="values => setDateFilter('reminders', values)"
>
<template #trigger="{toggle, buttonText}">
<x-button
variant="secondary"
:shadow="false"
class="mb-2"
@click.prevent.stop="toggle()"
>
{{ buttonText }}
</x-button>
</template>
</DatepickerWithRange>
</div>
</div>
<div class="field">
<label class="label">{{ $t('task.attributes.assignees') }}</label>
<div class="control">
<SelectUser
v-model="entities.users"
@select="changeMultiselectFilter('users', 'assignees')"
@remove="changeMultiselectFilter('users', 'assignees')"
/>
</div>
</div>
<div class="field">
<label class="label">{{ $t('task.attributes.labels') }}</label>
<div class="control labels-list">
<EditLabels
v-model="entities.labels"
:creatable="false"
@update:modelValue="changeLabelFilter"
/>
</div>
</div>
<FilterInputDocs />
<template
v-if="['filters.create', 'project.edit', 'filter.settings.edit'].includes($route.name as string)"
v-if="hasFooter"
#footer
>
<div class="field">
<label class="label">{{ $t('project.projects') }}</label>
<div class="control">
<SelectProject
v-model="entities.projects"
:project-filter="p => p.id > 0"
@select="changeMultiselectFilter('projects', 'project_id')"
@remove="changeMultiselectFilter('projects', 'project_id')"
/>
</div>
</div>
<x-button
variant="secondary"
class="mr-2"
:disabled="filterQuery === ''"
@click.prevent.stop="clearFiltersAndEmit"
>
{{ $t('filters.clear') }}
</x-button>
<x-button
variant="primary"
@click.prevent.stop="changeAndEmitButton"
>
{{ $t('filters.showResults') }}
</x-button>
</template>
</card>
</template>
@ -200,419 +48,111 @@ export const ALPHABETICAL_SORT = 'title'
</script>
<script setup lang="ts">
import {computed, nextTick, onMounted, reactive, ref, shallowReactive, toRefs} from 'vue'
import {camelCase} from 'camel-case'
import {watchDebounced} from '@vueuse/core'
import type {ILabel} from '@/modelTypes/ILabel'
import type {IUser} from '@/modelTypes/IUser'
import type {IProject} from '@/modelTypes/IProject'
import {useLabelStore} from '@/stores/labels'
import DatepickerWithRange from '@/components/date/datepickerWithRange.vue'
import PrioritySelect from '@/components/tasks/partials/prioritySelect.vue'
import PercentDoneSelect from '@/components/tasks/partials/percentDoneSelect.vue'
import EditLabels from '@/components/tasks/partials/editLabels.vue'
import {computed, ref, watch} from 'vue'
import Fancycheckbox from '@/components/input/fancycheckbox.vue'
import SelectUser from '@/components/input/SelectUser.vue'
import SelectProject from '@/components/input/SelectProject.vue'
import FilterInput from '@/components/project/partials/FilterInput.vue'
import {useRoute} from 'vue-router'
import type {TaskFilterParams} from '@/services/taskCollection'
import {useLabelStore} from '@/stores/labels'
import {useProjectStore} from '@/stores/projects'
import {FILTER_OPERATORS, transformFilterStringForApi, transformFilterStringFromApi} from '@/helpers/filters'
import FilterInputDocs from '@/components/project/partials/FilterInputDocs.vue'
import {parseDateOrString} from '@/helpers/time/parseDateOrString'
import {dateIsValid, formatISO} from '@/helpers/time/formatDate'
import {objectToSnakeCase} from '@/helpers/case'
const {
hasTitle = false,
hasFooter = true,
modelValue,
} = defineProps<{
hasTitle?: boolean,
hasFooter?: boolean,
modelValue: TaskFilterParams,
}>()
import UserService from '@/services/user'
import ProjectService from '@/services/project'
const emit = defineEmits(['update:modelValue', 'showResultsButtonClicked'])
// FIXME: do not use this here for now. instead create new version from DEFAULT_PARAMS
import {getDefaultParams} from '@/composables/useTaskList'
const route = useRoute()
const projectId = computed(() => {
if (route.name?.startsWith('project.')) {
return Number(route.params.projectId)
}
const props = defineProps({
modelValue: {
required: true,
},
hasTitle: {
type: Boolean,
default: false,
},
return undefined
})
const emit = defineEmits(['update:modelValue'])
// FIXME: merge with DEFAULT_PARAMS in taskProject.js
const DEFAULT_PARAMS = {
const params = ref<TaskFilterParams>({
sort_by: [],
order_by: [],
filter_by: [],
filter_value: [],
filter_comparator: [],
filter_include_nulls: true,
filter_concat: 'or',
filter: '',
filter_include_nulls: false,
s: '',
} as const
const DEFAULT_FILTERS = {
done: false,
dueDate: '',
requireAllFilters: false,
priority: 0,
usePriority: false,
startDate: '',
endDate: '',
percentDone: 0,
usePercentDone: false,
reminders: '',
assignees: '',
labels: '',
project_id: '',
} as const
const {modelValue} = toRefs(props)
const labelStore = useLabelStore()
const params = ref({...DEFAULT_PARAMS})
const filters = ref({...DEFAULT_FILTERS})
const services = {
users: shallowReactive(new UserService()),
projects: shallowReactive(new ProjectService()),
}
interface Entities {
users: IUser[]
labels: ILabel[]
projects: IProject[]
}
type EntityType = 'users' | 'labels' | 'projects'
const entities: Entities = reactive({
users: [],
labels: [],
projects: [],
})
onMounted(() => {
filters.value.requireAllFilters = params.value.filter_concat === 'and'
})
// Using watchDebounced to prevent the filter re-triggering itself.
// FIXME: Only here until this whole component changes a lot with the new filter syntax.
watchDebounced(
modelValue,
(value) => {
// FIXME: filters should only be converted to snake case in the last moment
params.value = objectToSnakeCase(value)
prepareFilters()
const filterQuery = ref('')
watch(
() => [params.value.filter, params.value.s],
() => {
const filter = params.value.filter || ''
const s = params.value.s || ''
filterQuery.value = filter || s
},
{immediate: true, debounce: 500, maxWait: 1000},
)
const sortAlphabetically = computed({
get() {
return params.value?.sort_by?.find(sortBy => sortBy === ALPHABETICAL_SORT) !== undefined
// Using watchDebounced to prevent the filter re-triggering itself.
watch(
() => modelValue,
(value: TaskFilterParams) => {
const val = {...value}
val.filter = transformFilterStringFromApi(
val?.filter || '',
labelId => labelStore.getLabelById(labelId)?.title,
projectId => projectStore.projects[projectId]?.title || null,
)
params.value = val
},
set(sortAlphabetically) {
params.value.sort_by = sortAlphabetically
? [ALPHABETICAL_SORT]
: getDefaultParams().sort_by
{immediate: true},
)
change()
},
})
const labelStore = useLabelStore()
const projectStore = useProjectStore()
function change() {
const newParams = {...params.value}
newParams.filter_value = newParams.filter_value.map(v => v instanceof Date ? v.toISOString() : v)
const filter = transformFilterStringForApi(
filterQuery.value,
labelTitle => labelStore.filterLabelsByQuery([], labelTitle)[0]?.id || null,
projectTitle => {
const found = projectStore.findProjectByExactname(projectTitle)
return found?.id || null
},
)
let s = ''
// When the filter does not contain any filter tokens, assume a simple search and redirect the input
const hasFilterQueries = FILTER_OPERATORS.find(o => filter.includes(o)) || false
if (!hasFilterQueries) {
s = filter
}
const newParams = {
...params.value,
filter: s === '' ? filter : '',
s,
}
if (JSON.stringify(modelValue) === JSON.stringify(newParams)) {
return
}
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', 'reminders')
prepareRelatedObjectFilter('users', 'assignees')
prepareProjectsFilter()
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)
function changeAndEmitButton() {
change()
emit('showResultsButtonClicked')
}
function prepareDate(filterName: string, variableName: 'dueDate' | 'startDate' | 'endDate' | 'reminders') {
if (typeof params.value.filter_by === 'undefined') {
return
}
let foundDateStart: boolean | string = false
let foundDateEnd: boolean | string = 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.getUTCFullYear()}-${startDate.getUTCMonth() + 1}-${startDate.getUTCDate()}`
: params.value.filter_value[foundDateStart],
dateTo: !isNaN(endDate)
? `${endDate.getUTCFullYear()}-${endDate.getUTCMonth() + 1}-${endDate.getUTCDate()}`
: 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]})
}
async function prepareProjectsFilter() {
await prepareRelatedObjectFilter('projects', 'project_id')
entities.projects = entities.projects.filter(p => p.id > 0)
}
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')
function clearFiltersAndEmit() {
filterQuery.value = ''
changeAndEmitButton()
}
</script>
<style lang="scss" scoped>
.single-value-control {
display: flex;
align-items: center;
.fancycheckbox {
margin-left: .5rem;
}
}
:deep(.datepicker-with-range-container .popup) {
right: 0;
}
</style>

View File

@ -47,6 +47,12 @@
>
{{ $t('menu.edit') }}
</DropdownItem>
<DropdownItem
:to="{ name: 'project.settings.views', params: { projectId: project.id } }"
icon="eye"
>
{{ $t('menu.views') }}
</DropdownItem>
<DropdownItem
v-if="backgroundsEnabled"
:to="{ name: 'project.settings.background', params: { projectId: project.id } }"
@ -90,7 +96,6 @@
{{ $t('project.webhooks.title') }}
</DropdownItem>
<DropdownItem
v-if="level < 2"
:to="{ name: 'project.createFromParent', params: { parentProjectId: project.id } }"
icon="layer-group"
>
@ -129,9 +134,6 @@ const props = defineProps({
type: Object as PropType<IProject>,
required: true,
},
level: {
type: Number,
},
})
const projectStore = useProjectStore()

View File

@ -2,9 +2,9 @@
<ProjectWrapper
class="project-gantt"
:project-id="filters.projectId"
view-name="gantt"
:view-id
>
<template #header>
<template #default>
<card :has-content="false">
<div class="gantt-options">
<div class="field">
@ -45,9 +45,7 @@
</Fancycheckbox>
</div>
</card>
</template>
<template #default>
<div class="gantt-chart-container">
<card
:has-content="false"
@ -79,7 +77,7 @@ import {useI18n} from 'vue-i18n'
import type {RouteLocationNormalized} from 'vue-router'
import {useBaseStore} from '@/stores/base'
import { getFlatpickrLanguage } from '@/helpers/flatpickrLanguage'
import {getFlatpickrLanguage} from '@/helpers/flatpickrLanguage'
import Foo from '@/components/misc/flatpickr/Flatpickr.vue'
import ProjectWrapper from '@/components/project/ProjectWrapper.vue'
@ -87,15 +85,19 @@ import Fancycheckbox from '@/components/input/fancycheckbox.vue'
import TaskForm from '@/components/tasks/TaskForm.vue'
import {createAsyncComponent} from '@/helpers/createAsyncComponent'
import {useGanttFilters} from './helpers/useGanttFilters'
import {useGanttFilters} from '../../../views/project/helpers/useGanttFilters'
import {RIGHTS} from '@/constants/rights'
import type {DateISO} from '@/types/DateISO'
import type {ITask} from '@/modelTypes/ITask'
import type {IProjectView} from '@/modelTypes/IProjectView'
type Options = Flatpickr.Options.Options
const props = defineProps<{route: RouteLocationNormalized}>()
const props = defineProps<{
route: RouteLocationNormalized
viewId: IProjectView['id']
}>()
const GanttChart = createAsyncComponent(() => import('@/components/tasks/GanttChart.vue'))
@ -111,7 +113,7 @@ const {
isLoading,
addTask,
updateTask,
} = useGanttFilters(route)
} = useGanttFilters(route, props.viewId)
const DEFAULT_DATE_RANGE_DAYS = 7

View File

@ -2,16 +2,14 @@
<ProjectWrapper
class="project-kanban"
:project-id="projectId"
view-name="kanban"
:view-id
>
<template #header>
<div
v-if="!isSavedFilter(project)"
class="filter-container"
>
<div class="items">
<FilterPopup v-model="params" />
</div>
<div class="filter-container">
<FilterPopup
v-if="!isSavedFilter(project)"
v-model="params"
/>
</div>
</template>
@ -43,7 +41,7 @@
@click="() => unCollapseBucket(bucket)"
>
<span
v-if="project?.doneBucketId === bucket.id"
v-if="view?.doneBucketId === bucket.id"
v-tooltip="$t('project.kanban.doneBucketHint')"
class="icon is-small has-text-success mr-2"
>
@ -111,7 +109,7 @@
</DropdownItem>
<DropdownItem
v-tooltip="$t('project.kanban.doneBucketHintExtended')"
:icon-class="{'has-text-success': bucket.id === project?.doneBucketId}"
:icon-class="{'has-text-success': bucket.id === view?.doneBucketId}"
icon="check-double"
@click.stop="toggleDoneBucket(bucket)"
>
@ -119,7 +117,7 @@
</DropdownItem>
<DropdownItem
v-tooltip="$t('project.kanban.defaultBucketHint')"
:icon-class="{'has-text-primary': bucket.id === project.defaultBucketId}"
:icon-class="{'has-text-primary': bucket.id === view?.defaultBucketId}"
icon="th"
@click.stop="toggleDefaultBucket(bucket)"
>
@ -197,7 +195,9 @@
variant="secondary"
@click="toggleShowNewTaskInput(bucket.id)"
>
{{ bucket.tasks.length === 0 ? $t('project.kanban.addTask') : $t('project.kanban.addAnotherTask') }}
{{
bucket.tasks.length === 0 ? $t('project.kanban.addTask') : $t('project.kanban.addAnotherTask')
}}
</x-button>
</div>
</template>
@ -277,7 +277,6 @@ import {RIGHTS as Rights} from '@/constants/rights'
import BucketModel from '@/models/bucket'
import type {IBucket} from '@/modelTypes/IBucket'
import type {IProject} from '@/modelTypes/IProject'
import type {ITask} from '@/modelTypes/ITask'
import {useBaseStore} from '@/stores/base'
@ -290,17 +289,30 @@ import KanbanCard from '@/components/tasks/partials/kanban-card.vue'
import Dropdown from '@/components/misc/dropdown.vue'
import DropdownItem from '@/components/misc/dropdown-item.vue'
import {getCollapsedBucketState, saveCollapsedBucketState, type CollapsedBuckets} from '@/helpers/saveCollapsedBucketState'
import {
type CollapsedBuckets,
getCollapsedBucketState,
saveCollapsedBucketState,
} from '@/helpers/saveCollapsedBucketState'
import {calculateItemPosition} from '@/helpers/calculateItemPosition'
import {isSavedFilter} from '@/services/savedFilter'
import {success} from '@/message'
import {useProjectStore} from '@/stores/projects'
import type {TaskFilterParams} from '@/services/taskCollection'
import type {IProjectView} from '@/modelTypes/IProjectView'
import TaskPositionService from '@/services/taskPosition'
import TaskPositionModel from '@/models/taskPosition'
import {i18n} from '@/i18n'
import ProjectViewService from '@/services/projectViews'
import ProjectViewModel from '@/models/projectView'
const {
projectId = undefined,
projectId,
viewId,
} = defineProps<{
projectId: number,
viewId: IProjectView['id'],
}>()
const DRAG_OPTIONS = {
@ -320,8 +332,9 @@ const baseStore = useBaseStore()
const kanbanStore = useKanbanStore()
const taskStore = useTaskStore()
const projectStore = useProjectStore()
const taskPositionService = ref(new TaskPositionService())
const taskContainerRefs = ref<{[id: IBucket['id']]: HTMLElement}>({})
const taskContainerRefs = ref<{ [id: IBucket['id']]: HTMLElement }>({})
const bucketLimitInputRef = ref<HTMLInputElement | null>(null)
const drag = ref(false)
@ -333,31 +346,32 @@ const bucketToDelete = ref(0)
const bucketTitleEditable = ref(false)
const newTaskText = ref('')
const showNewTaskInput = ref<{[id: IBucket['id']]: boolean}>({})
const showNewTaskInput = ref<{ [id: IBucket['id']]: boolean }>({})
const newBucketTitle = ref('')
const showNewBucketInput = ref(false)
const newTaskError = ref<{[id: IBucket['id']]: boolean}>({})
const newTaskError = ref<{ [id: IBucket['id']]: boolean }>({})
const newTaskInputFocused = ref(false)
const showSetLimitInput = ref(false)
const collapsedBuckets = ref<CollapsedBuckets>({})
// We're using this to show the loading animation only at the task when updating it
const taskUpdating = ref<{[id: ITask['id']]: boolean}>({})
const taskUpdating = ref<{ [id: ITask['id']]: boolean }>({})
const oneTaskUpdating = ref(false)
const params = ref({
filter_by: [],
filter_value: [],
filter_comparator: [],
filter_concat: 'and',
const params = ref<TaskFilterParams>({
sort_by: [],
order_by: [],
filter: '',
filter_include_nulls: false,
s: '',
})
const getTaskDraggableTaskComponentData = computed(() => (bucket: IBucket) => {
return {
ref: (el: HTMLElement) => setTaskContainerRef(bucket.id, el),
onScroll: (event: Event) => handleTaskContainerScroll(bucket.id, bucket.projectId, event.target as HTMLElement),
onScroll: (event: Event) => handleTaskContainerScroll(bucket.id, event.target as HTMLElement),
type: 'transition-group',
name: !drag.value ? 'move-card' : null,
class: [
@ -376,24 +390,27 @@ const bucketDraggableComponentData = computed(() => ({
],
}))
const canWrite = computed(() => baseStore.currentProject?.maxRight > Rights.READ)
const project = computed(() => projectId ? projectStore.projects[projectId]: null)
const project = computed(() => projectId ? projectStore.projects[projectId] : null)
const buckets = computed(() => kanbanStore.buckets)
const loading = computed(() => kanbanStore.isLoading)
const taskLoading = computed(() => taskStore.isLoading)
const view = computed<IProjectView | null>(() => project.value?.views.find(v => v.id === viewId) || null)
const taskLoading = computed(() => taskStore.isLoading || taskPositionService.value.loading)
watch(
() => ({
params: params.value,
projectId,
viewId,
}),
({params}) => {
if (projectId === undefined || Number(projectId) === 0) {
return
}
collapsedBuckets.value = getCollapsedBucketState(projectId)
kanbanStore.loadBucketsForProject({projectId, params})
kanbanStore.loadBucketsForProject(projectId, viewId, params)
},
{
immediate: true,
@ -406,7 +423,7 @@ function setTaskContainerRef(id: IBucket['id'], el: HTMLElement) {
taskContainerRefs.value[id] = el
}
function handleTaskContainerScroll(id: IBucket['id'], projectId: IProject['id'], el: HTMLElement) {
function handleTaskContainerScroll(id: IBucket['id'], el: HTMLElement) {
if (!el) {
return
}
@ -418,6 +435,7 @@ function handleTaskContainerScroll(id: IBucket['id'], projectId: IProject['id'],
kanbanStore.loadNextTasksForBucket(
projectId,
viewId,
params.value,
id,
)
@ -467,7 +485,7 @@ async function updateTaskPosition(e) {
const newTask = klona(task) // cloning the task to avoid pinia store manipulation
newTask.bucketId = newBucket.id
newTask.kanbanPosition = calculateItemPosition(
const position = calculateItemPosition(
taskBefore !== null ? taskBefore.kanbanPosition : null,
taskAfter !== null ? taskAfter.kanbanPosition : null,
)
@ -477,6 +495,8 @@ async function updateTaskPosition(e) {
) {
newTask.done = project.value?.doneBucketId === newBucket.id
}
let bucketHasChanged = false
if (
oldBucket !== undefined && // This shouldn't actually be `undefined`, but let's play it safe.
newBucket.id !== oldBucket.id
@ -489,13 +509,23 @@ async function updateTaskPosition(e) {
...newBucket,
count: newBucket.count + 1,
})
bucketHasChanged = true
}
try {
await taskStore.update(newTask)
const newPosition = new TaskPositionModel({
position,
projectViewId: viewId,
taskId: newTask.id,
})
await taskPositionService.value.update(newPosition)
if(bucketHasChanged) {
await taskStore.update(newTask)
}
// Make sure the first and second task don't both get position 0 assigned
if(newTaskIndex === 0 && taskAfter !== null && taskAfter.kanbanPosition === 0) {
if (newTaskIndex === 0 && taskAfter !== null && taskAfter.kanbanPosition === 0) {
const taskAfterAfter = newBucket.tasks[newTaskIndex + 2] ?? null
const newTaskAfter = klona(taskAfter) // cloning the task to avoid pinia store manipulation
newTaskAfter.bucketId = newBucket.id
@ -550,9 +580,9 @@ async function createNewBucket() {
await kanbanStore.createBucket(new BucketModel({
title: newBucketTitle.value,
projectId: project.value.id,
projectViewId: viewId,
}))
newBucketTitle.value = ''
showNewBucketInput.value = false
}
function deleteBucketModal(bucketId: IBucket['id']) {
@ -570,6 +600,7 @@ async function deleteBucket() {
bucket: new BucketModel({
id: bucketToDelete.value,
projectId: project.value.id,
projectViewId: viewId,
}),
params: params.value,
})
@ -588,10 +619,19 @@ async function focusBucketTitle(e: Event) {
}
async function saveBucketTitle(bucketId: IBucket['id'], bucketTitle: string) {
await kanbanStore.updateBucketTitle({
const bucket = kanbanStore.getBucketById(bucketId)
if (bucket?.title === bucketTitle) {
bucketTitleEditable.value = false
return
}
await kanbanStore.updateBucket({
id: bucketId,
title: bucketTitle,
projectId,
})
success({message: i18n.global.t('project.kanban.bucketTitleSavedSuccess')})
bucketTitleEditable.value = false
}
@ -601,7 +641,7 @@ function updateBuckets(value: IBucket[]) {
}
// TODO: fix type
function updateBucketPosition(e: {newIndex: number}) {
function updateBucketPosition(e: { newIndex: number }) {
// (2) bucket positon is changed
dragBucket.value = false
@ -611,6 +651,7 @@ function updateBucketPosition(e: {newIndex: number}) {
kanbanStore.updateBucket({
id: bucket.id,
projectId,
position: calculateItemPosition(
bucketBefore !== null ? bucketBefore.position : null,
bucketAfter !== null ? bucketAfter.position : null,
@ -625,24 +666,25 @@ async function saveBucketLimit(bucketId: IBucket['id'], limit: number) {
await kanbanStore.updateBucket({
...kanbanStore.getBucketById(bucketId),
projectId,
limit,
})
success({message: t('project.kanban.bucketLimitSavedSuccess')})
}
const setBucketLimitCancel = ref<number|null>(null)
const setBucketLimitCancel = ref<number | null>(null)
async function setBucketLimit(bucketId: IBucket['id'], now: boolean = false) {
const limit = parseInt(bucketLimitInputRef.value?.value || '')
if (setBucketLimitCancel.value !== null) {
clearTimeout(setBucketLimitCancel.value)
}
if (now) {
return saveBucketLimit(bucketId, limit)
}
setBucketLimitCancel.value = setTimeout(saveBucketLimit, 2500, bucketId, limit)
}
@ -663,26 +705,46 @@ function dragstart(bucket: IBucket) {
}
async function toggleDefaultBucket(bucket: IBucket) {
const defaultBucketId = project.value.defaultBucketId === bucket.id
const defaultBucketId = view.value?.defaultBucketId === bucket.id
? 0
: bucket.id
await projectStore.updateProject({
...project.value,
const projectViewService = new ProjectViewService()
const updatedView = await projectViewService.update(new ProjectViewModel({
...view.value,
defaultBucketId,
})
}))
const views = project.value.views.map(v => v.id === view.value?.id ? updatedView : v)
const updatedProject = {
...project.value,
views,
}
projectStore.setProject(updatedProject)
success({message: t('project.kanban.defaultBucketSavedSuccess')})
}
async function toggleDoneBucket(bucket: IBucket) {
const doneBucketId = project.value?.doneBucketId === bucket.id
const doneBucketId = view.value?.doneBucketId === bucket.id
? 0
: bucket.id
await projectStore.updateProject({
...project.value,
const projectViewService = new ProjectViewService()
const updatedView = await projectViewService.update(new ProjectViewModel({
...view.value,
doneBucketId,
})
}))
const views = project.value.views.map(v => v.id === view.value?.id ? updatedView : v)
const updatedProject = {
...project.value,
views,
}
projectStore.setProject(updatedProject)
success({message: t('project.kanban.doneBucketSavedSuccess')})
}
@ -711,11 +773,6 @@ $crazy-height-calculation: '100vh - 4.5rem - 1.5rem - 1rem - 1.5rem - 11px';
$crazy-height-calculation-tasks: '#{$crazy-height-calculation} - 1rem - 2.5rem - 2rem - #{$button-height} - 1rem';
$filter-container-height: '1rem - #{$switch-view-height}';
// FIXME:
.app-content.project\.kanban, .app-content.task\.detail {
padding-bottom: 0 !important;
}
.kanban {
overflow-x: auto;
overflow-y: hidden;
@ -723,6 +780,10 @@ $filter-container-height: '1rem - #{$switch-view-height}';
margin: 0 -1.5rem;
padding: 0 1.5rem;
&:focus, .bucket .tasks:focus {
box-shadow: none;
}
@media screen and (max-width: $tablet) {
height: calc(#{$crazy-height-calculation} - #{$filter-container-height});
scroll-snap-type: x mandatory;
@ -738,6 +799,7 @@ $filter-container-height: '1rem - #{$switch-view-height}';
* {
opacity: 0;
}
&::after {
content: '';
position: absolute;
@ -779,6 +841,7 @@ $filter-container-height: '1rem - #{$switch-view-height}';
&:first-of-type {
padding-top: .5rem;
}
&:last-of-type {
padding-bottom: .5rem;
}

View File

@ -2,55 +2,15 @@
<ProjectWrapper
class="project-list"
:project-id="projectId"
view-name="project"
:view-id
>
<template #header>
<div
v-if="!isSavedFilter(project)"
class="filter-container"
>
<div class="items">
<div class="search">
<div
:class="{ hidden: !showTaskSearch }"
class="field has-addons"
>
<div class="control has-icons-left has-icons-right">
<input
v-model="searchTerm"
v-focus
class="input"
:placeholder="$t('misc.search')"
type="text"
@blur="hideSearchBar()"
@keyup.enter="searchTasks"
>
<span class="icon is-left">
<icon icon="search" />
</span>
</div>
<div class="control">
<x-button
:loading="loading"
:shadow="false"
@click="searchTasks"
>
{{ $t('misc.search') }}
</x-button>
</div>
</div>
<x-button
v-if="!showTaskSearch"
icon="search"
variant="secondary"
@click="showTaskSearch = !showTaskSearch"
/>
</div>
<FilterPopup
v-model="params"
@update:modelValue="prepareFiltersAndLoadTasks()"
/>
</div>
<div class="filter-container">
<FilterPopup
v-if="!isSavedFilter(project)"
v-model="params"
@update:modelValue="prepareFiltersAndLoadTasks()"
/>
</div>
</template>
@ -120,7 +80,7 @@
</template>
</draggable>
<Pagination
<Pagination
:total-pages="totalPages"
:current-page="currentPage"
/>
@ -131,13 +91,12 @@
</template>
<script lang="ts">
export default { name: 'List' }
export default {name: 'List'}
</script>
<script setup lang="ts">
import {ref, computed, nextTick, onMounted, watch} from 'vue'
import draggable from 'zhyswan-vuedraggable'
import {useRoute, useRouter} from 'vue-router'
import ProjectWrapper from '@/components/project/ProjectWrapper.vue'
import ButtonLink from '@/components/misc/ButtonLink.vue'
@ -155,18 +114,21 @@ import type {ITask} from '@/modelTypes/ITask'
import {isSavedFilter} from '@/services/savedFilter'
import {useBaseStore} from '@/stores/base'
import {useTaskStore} from '@/stores/tasks'
import type {IProject} from '@/modelTypes/IProject'
import type {IProjectView} from '@/modelTypes/IProjectView'
import TaskPositionService from '@/services/taskPosition'
import TaskPositionModel from '@/models/taskPosition'
const {
projectId,
viewId,
} = defineProps<{
projectId: IProject['id'],
viewId: IProjectView['id'],
}>()
const ctaVisible = ref(false)
const showTaskSearch = ref(false)
const drag = ref(false)
const DRAG_OPTIONS = {
@ -180,10 +142,11 @@ const {
totalPages,
currentPage,
loadTasks,
searchTerm,
params,
sortByParam,
} = useTaskList(() => projectId, {position: 'asc' })
} = useTaskList(() => projectId, () => viewId, {position: 'asc'})
const taskPositionService = ref(new TaskPositionService())
const tasks = ref<ITask[]>([])
watch(
@ -203,7 +166,7 @@ watch(
// If the task is a subtask, make sure the parent task is available in the current view as well
for (const pt of t.relatedTasks.parenttask) {
if(typeof tasksById[pt.id] === 'undefined') {
if (typeof tasksById[pt.id] === 'undefined') {
return true
}
}
@ -225,7 +188,6 @@ const firstNewPosition = computed(() => {
return calculateItemPosition(null, tasks.value[0].position)
})
const taskStore = useTaskStore()
const baseStore = useBaseStore()
const project = computed(() => baseStore.currentProject)
@ -238,43 +200,17 @@ onMounted(async () => {
ctaVisible.value = true
})
const route = useRoute()
const router = useRouter()
function searchTasks() {
// Only search if the search term changed
if (route.query as unknown as string === searchTerm.value) {
return
}
router.push({
name: 'project.list',
query: {search: searchTerm.value},
})
}
function hideSearchBar() {
// This is a workaround.
// When clicking on the search button, @blur from the input is fired. If we
// would then directly hide the whole search bar directly, no click event
// from the button gets fired. To prevent this, we wait 200ms until we hide
// everything so the button has a chance of firing the search event.
setTimeout(() => {
showTaskSearch.value = false
}, 200)
}
const addTaskRef = ref<typeof AddTask | null>(null)
function focusNewTaskInput() {
addTaskRef.value?.focusTaskInput()
}
function updateTaskList(task: ITask) {
if (isAlphabeticalSorting.value ) {
if (isAlphabeticalSorting.value) {
// reload tasks with current filter and sorting
loadTasks()
}
else {
} else {
allTasks.value = [
task,
...allTasks.value,
@ -300,18 +236,22 @@ async function saveTaskPosition(e) {
const taskBefore = tasks.value[e.newIndex - 1] ?? null
const taskAfter = tasks.value[e.newIndex + 1] ?? null
const newTask = {
...task,
position: calculateItemPosition(taskBefore !== null ? taskBefore.position : null, taskAfter !== null ? taskAfter.position : null),
}
const position = calculateItemPosition(taskBefore !== null ? taskBefore.position : null, taskAfter !== null ? taskAfter.position : null)
const updatedTask = await taskStore.update(newTask)
tasks.value[e.newIndex] = updatedTask
await taskPositionService.value.update(new TaskPositionModel({
position,
projectViewId: viewId,
taskId: task.id,
}))
tasks.value[e.newIndex] = {
...task,
position,
}
}
function prepareFiltersAndLoadTasks() {
if(isAlphabeticalSorting.value) {
sortByParam.value = {}
if (isAlphabeticalSorting.value) {
sortByParam.value = {}
sortByParam.value[ALPHABETICAL_SORT] = 'asc'
}
@ -328,7 +268,7 @@ function prepareFiltersAndLoadTasks() {
border-radius: $radius;
background: var(--grey-100);
border: 2px dashed var(--grey-300);
* {
opacity: 0;
}
@ -339,8 +279,8 @@ function prepareFiltersAndLoadTasks() {
}
.link-share-view .card {
border: none;
box-shadow: none;
border: none;
box-shadow: none;
}
.control.has-icons-left .icon,
@ -366,4 +306,12 @@ function prepareFiltersAndLoadTasks() {
}
}
}
.list-view {
padding-bottom: 1rem;
:deep(.card) {
margin-bottom: 0;
}
}
</style>

View File

@ -2,73 +2,72 @@
<ProjectWrapper
class="project-table"
:project-id="projectId"
view-name="table"
:view-id
>
<template #header>
<div class="filter-container">
<div class="items">
<Popup>
<template #trigger="{toggle}">
<x-button
icon="th"
variant="secondary"
@click.prevent.stop="toggle()"
>
{{ $t('project.table.columns') }}
</x-button>
</template>
<template #content="{isOpen}">
<card
class="columns-filter"
:class="{'is-open': isOpen}"
>
<Fancycheckbox v-model="activeColumns.index">
#
</Fancycheckbox>
<Fancycheckbox v-model="activeColumns.done">
{{ $t('task.attributes.done') }}
</Fancycheckbox>
<Fancycheckbox v-model="activeColumns.title">
{{ $t('task.attributes.title') }}
</Fancycheckbox>
<Fancycheckbox v-model="activeColumns.priority">
{{ $t('task.attributes.priority') }}
</Fancycheckbox>
<Fancycheckbox v-model="activeColumns.labels">
{{ $t('task.attributes.labels') }}
</Fancycheckbox>
<Fancycheckbox v-model="activeColumns.assignees">
{{ $t('task.attributes.assignees') }}
</Fancycheckbox>
<Fancycheckbox v-model="activeColumns.dueDate">
{{ $t('task.attributes.dueDate') }}
</Fancycheckbox>
<Fancycheckbox v-model="activeColumns.startDate">
{{ $t('task.attributes.startDate') }}
</Fancycheckbox>
<Fancycheckbox v-model="activeColumns.endDate">
{{ $t('task.attributes.endDate') }}
</Fancycheckbox>
<Fancycheckbox v-model="activeColumns.percentDone">
{{ $t('task.attributes.percentDone') }}
</Fancycheckbox>
<Fancycheckbox v-model="activeColumns.doneAt">
{{ $t('task.attributes.doneAt') }}
</Fancycheckbox>
<Fancycheckbox v-model="activeColumns.created">
{{ $t('task.attributes.created') }}
</Fancycheckbox>
<Fancycheckbox v-model="activeColumns.updated">
{{ $t('task.attributes.updated') }}
</Fancycheckbox>
<Fancycheckbox v-model="activeColumns.createdBy">
{{ $t('task.attributes.createdBy') }}
</Fancycheckbox>
</card>
</template>
</Popup>
<FilterPopup v-model="params" />
</div>
<Popup>
<template #trigger="{toggle}">
<x-button
icon="th"
variant="secondary"
class="mr-2"
@click.prevent.stop="toggle()"
>
{{ $t('project.table.columns') }}
</x-button>
</template>
<template #content="{isOpen}">
<card
class="columns-filter"
:class="{'is-open': isOpen}"
>
<Fancycheckbox v-model="activeColumns.index">
#
</Fancycheckbox>
<Fancycheckbox v-model="activeColumns.done">
{{ $t('task.attributes.done') }}
</Fancycheckbox>
<Fancycheckbox v-model="activeColumns.title">
{{ $t('task.attributes.title') }}
</Fancycheckbox>
<Fancycheckbox v-model="activeColumns.priority">
{{ $t('task.attributes.priority') }}
</Fancycheckbox>
<Fancycheckbox v-model="activeColumns.labels">
{{ $t('task.attributes.labels') }}
</Fancycheckbox>
<Fancycheckbox v-model="activeColumns.assignees">
{{ $t('task.attributes.assignees') }}
</Fancycheckbox>
<Fancycheckbox v-model="activeColumns.dueDate">
{{ $t('task.attributes.dueDate') }}
</Fancycheckbox>
<Fancycheckbox v-model="activeColumns.startDate">
{{ $t('task.attributes.startDate') }}
</Fancycheckbox>
<Fancycheckbox v-model="activeColumns.endDate">
{{ $t('task.attributes.endDate') }}
</Fancycheckbox>
<Fancycheckbox v-model="activeColumns.percentDone">
{{ $t('task.attributes.percentDone') }}
</Fancycheckbox>
<Fancycheckbox v-model="activeColumns.doneAt">
{{ $t('task.attributes.doneAt') }}
</Fancycheckbox>
<Fancycheckbox v-model="activeColumns.created">
{{ $t('task.attributes.created') }}
</Fancycheckbox>
<Fancycheckbox v-model="activeColumns.updated">
{{ $t('task.attributes.updated') }}
</Fancycheckbox>
<Fancycheckbox v-model="activeColumns.createdBy">
{{ $t('task.attributes.createdBy') }}
</Fancycheckbox>
</card>
</template>
</Popup>
<FilterPopup v-model="params" />
</div>
</template>
@ -268,7 +267,7 @@
</template>
<script setup lang="ts">
import {computed, type Ref} from 'vue'
import {computed, type Ref, watch} from 'vue'
import {useStorage} from '@vueuse/core'
@ -284,17 +283,19 @@ import FilterPopup from '@/components/project/partials/filter-popup.vue'
import Pagination from '@/components/misc/pagination.vue'
import Popup from '@/components/misc/popup.vue'
import {useTaskList} from '@/composables/useTaskList'
import type {SortBy} from '@/composables/useTaskList'
import {useTaskList} from '@/composables/useTaskList'
import type {ITask} from '@/modelTypes/ITask'
import type {IProject} from '@/modelTypes/IProject'
import AssigneeList from '@/components/tasks/partials/assigneeList.vue'
import type {IProjectView} from '@/modelTypes/IProjectView'
const {
projectId,
viewId,
} = defineProps<{
projectId: IProject['id'],
viewId: IProjectView['id'],
}>()
const ACTIVE_COLUMNS_DEFAULT = {
@ -321,7 +322,7 @@ const SORT_BY_DEFAULT: SortBy = {
const activeColumns = useStorage('tableViewColumns', {...ACTIVE_COLUMNS_DEFAULT})
const sortBy = useStorage<SortBy>('tableViewSortBy', {...SORT_BY_DEFAULT})
const taskList = useTaskList(() => projectId, sortBy.value)
const taskList = useTaskList(() => projectId, () => viewId, sortBy.value)
const {
loading,
@ -333,11 +334,15 @@ const {
const tasks: Ref<ITask[]> = taskList.tasks
Object.assign(params.value, {
filter_by: [],
filter_value: [],
filter_comparator: [],
filter: '',
})
watch(
() => activeColumns.value,
() => setActiveColumnsSortParam(),
{deep: true},
)
// FIXME: by doing this we can have multiple sort orders
function sort(property: keyof SortBy) {
const order = sortBy.value[property]
@ -348,7 +353,16 @@ function sort(property: keyof SortBy) {
} else {
delete sortBy.value[property]
}
sortByParam.value = sortBy.value
setActiveColumnsSortParam()
}
function setActiveColumnsSortParam() {
sortByParam.value = Object.keys(sortBy.value)
.filter(prop => activeColumns.value[prop])
.reduce((obj, key) => {
obj[key] = sortBy.value[key]
return obj
}, {})
}
// TODO: re-enable opening task detail in modal
@ -383,6 +397,11 @@ const taskDetailRoutes = computed(() => Object.fromEntries(
.columns-filter {
margin: 0;
:deep(.card-content .content) {
display: flex;
flex-direction: column;
}
&.is-open {
margin: 2rem 0 1rem;
}
@ -392,4 +411,8 @@ const taskDetailRoutes = computed(() => Object.fromEntries(
border: none;
box-shadow: none;
}
.filter-container :deep(.popup) {
top: 7rem;
}
</style>

View File

@ -0,0 +1,233 @@
<script setup lang="ts">
import type {IProjectView} from '@/modelTypes/IProjectView'
import XButton from '@/components/input/button.vue'
import FilterInput from '@/components/project/partials/FilterInput.vue'
import {ref, watch} from 'vue'
import {transformFilterStringForApi, transformFilterStringFromApi} from '@/helpers/filters'
import {useLabelStore} from '@/stores/labels'
import {useProjectStore} from '@/stores/projects'
const {
modelValue,
} = defineProps<{
modelValue: IProjectView,
}>()
const emit = defineEmits(['update:modelValue'])
const view = ref<IProjectView>()
const labelStore = useLabelStore()
const projectStore = useProjectStore()
watch(
() => modelValue,
newValue => {
const transformed = {
...newValue,
filter: transformFilterStringFromApi(
newValue.filter,
labelId => labelStore.getLabelById(labelId)?.title,
projectId => projectStore.projects[projectId]?.title || null,
),
}
if (JSON.stringify(view.value) !== JSON.stringify(transformed)) {
view.value = transformed
}
},
{immediate: true, deep: true},
)
watch(
() => view.value,
newView => {
emit('update:modelValue', {
...newView,
filter: transformFilterStringForApi(
newView.filter,
labelTitle => labelStore.filterLabelsByQuery([], labelTitle)[0]?.id || null,
projectTitle => {
const found = projectStore.findProjectByExactname(projectTitle)
return found?.id || null
},
),
})
},
{deep: true},
)
const titleValid = ref(true)
function validateTitle() {
titleValid.value = view.value?.title !== ''
}
</script>
<template>
<form>
<div class="field">
<label
class="label"
for="title"
>
{{ $t('project.views.title') }}
</label>
<div class="control">
<input
id="title"
v-model="view.title"
v-focus
class="input"
:placeholder="$t('project.share.links.namePlaceholder')"
@blur="validateTitle"
>
</div>
<p
v-if="!titleValid"
class="help is-danger"
>
{{ $t('project.views.titleRequired') }}
</p>
</div>
<div class="field">
<label
class="label"
for="kind"
>
{{ $t('project.views.kind') }}
</label>
<div class="control">
<div class="select">
<select
id="kind"
v-model="view.viewKind"
>
<option value="list">
{{ $t('project.list.title') }}
</option>
<option value="gantt">
{{ $t('project.gantt.title') }}
</option>
<option value="table">
{{ $t('project.table.title') }}
</option>
<option value="kanban">
{{ $t('project.kanban.title') }}
</option>
</select>
</div>
</div>
</div>
<FilterInput
v-model="view.filter"
:input-label="$t('project.views.filter')"
/>
<div
v-if="view.viewKind === 'kanban'"
class="field"
>
<label
class="label"
for="configMode"
>
{{ $t('project.views.bucketConfigMode') }}
</label>
<div class="control">
<div class="select">
<select
id="configMode"
v-model="view.bucketConfigurationMode"
>
<option value="manual">
{{ $t('project.views.bucketConfigManual') }}
</option>
<option value="filter">
{{ $t('project.views.filter') }}
</option>
</select>
</div>
</div>
</div>
<div
v-if="view.viewKind === 'kanban' && view.bucketConfigurationMode === 'filter'"
class="field"
>
<label class="label">
{{ $t('project.views.bucketConfig') }}
</label>
<div class="control">
<div
v-for="(b, index) in view.bucketConfiguration"
:key="'bucket_'+index"
class="filter-bucket"
>
<button
class="is-danger"
@click.prevent="() => view.bucketConfiguration.splice(index, 1)"
>
<icon icon="trash-alt" />
</button>
<div class="filter-bucket-form">
<div class="field">
<label
class="label"
:for="'bucket_'+index+'_title'"
>
{{ $t('project.views.title') }}
</label>
<div class="control">
<input
:id="'bucket_'+index+'_title'"
v-model="view.bucketConfiguration[index].title"
class="input"
:placeholder="$t('project.share.links.namePlaceholder')"
>
</div>
</div>
<FilterInput
v-model="view.bucketConfiguration[index].filter"
:input-label="$t('project.views.filter')"
/>
</div>
</div>
<div class="is-flex is-justify-content-end">
<XButton
variant="secondary"
icon="plus"
@click="() => view.bucketConfiguration.push({title: '', filter: ''})"
>
{{ $t('project.kanban.addBucket') }}
</XButton>
</div>
</div>
</div>
</form>
</template>
<style scoped lang="scss">
.filter-bucket {
display: flex;
button {
background: transparent;
border: none;
color: var(--danger);
padding-right: .75rem;
cursor: pointer;
}
&-form {
margin-bottom: .5rem;
padding: .5rem;
border: 1px solid var(--grey-200);
border-radius: $radius;
width: 100%;
}
}
</style>

View File

@ -85,7 +85,7 @@
</template>
<script setup lang="ts">
import {ref, computed, watchEffect, shallowReactive, type ComponentPublicInstance} from 'vue'
import {type ComponentPublicInstance, computed, ref, shallowReactive, watchEffect} from 'vue'
import {useI18n} from 'vue-i18n'
import {useRouter} from 'vue-router'
@ -107,13 +107,14 @@ import {useTaskStore} from '@/stores/tasks'
import {useAuthStore} from '@/stores/auth'
import {getHistory} from '@/modules/projectHistory'
import {parseTaskText, PrefixMode, PREFIXES} from '@/modules/parseTaskText'
import {parseTaskText, PREFIXES, PrefixMode} from '@/modules/parseTaskText'
import {success} from '@/message'
import type {ITeam} from '@/modelTypes/ITeam'
import type {ITask} from '@/modelTypes/ITask'
import type {IProject} from '@/modelTypes/IProject'
import type {IAbstract} from '@/modelTypes/IAbstract'
import {isSavedFilter} from '@/services/savedFilter'
const {t} = useI18n({useScope: 'global'})
const router = useRouter()
@ -280,10 +281,13 @@ const commands = computed<{ [key in COMMAND_TYPE]: Command }>(() => ({
const placeholder = computed(() => selectedCmd.value?.placeholder || t('quickActions.placeholder'))
const currentProject = computed(() => Object.keys(baseStore.currentProject).length === 0
? null
: baseStore.currentProject,
)
const currentProject = computed(() => {
if (Object.keys(baseStore.currentProject).length === 0 || isSavedFilter(baseStore.currentProject)) {
return null
}
return baseStore.currentProject
})
const hintText = computed(() => {
if (selectedCmd.value !== null && currentProject.value !== null) {
@ -350,26 +354,6 @@ const isNewTaskCommand = computed(() => (
const taskSearchTimeout = ref<ReturnType<typeof setTimeout> | null>(null)
type Filter = { by: string, value: string | number, comparator: string }
function filtersToParams(filters: Filter[]) {
const filter_by: Filter['by'][] = []
const filter_value: Filter['value'][] = []
const filter_comparator: Filter['comparator'][] = []
filters.forEach(({by, value, comparator}) => {
filter_by.push(by)
filter_value.push(value)
filter_comparator.push(comparator)
})
return {
filter_by,
filter_value,
filter_comparator,
}
}
function searchTasks() {
if (
searchMode.value !== SEARCH_MODE.ALL &&
@ -391,40 +375,27 @@ function searchTasks() {
const {text, project: projectName, labels} = parsedQuery.value
const filters: Filter[] = []
// FIXME: improve types
function addFilter(
by: Filter['by'],
value: Filter['value'],
comparator: Filter['comparator'],
) {
filters.push({
by,
value,
comparator,
})
}
let filter = ''
if (projectName !== null) {
const project = projectStore.findProjectByExactname(projectName)
console.log({project})
if (project !== null) {
addFilter('project_id', project.id, 'equals')
filter += ' project = ' + project.id
}
}
if (labels.length > 0) {
const labelIds = labelStore.getLabelsByExactTitles(labels).map((l) => l.id)
if (labelIds.length > 0) {
addFilter('labels', labelIds.join(), 'in')
filter += 'labels in ' + labelIds.join(', ')
}
}
const params = {
s: text,
sort_by: 'done',
...filtersToParams(filters),
filter,
}
taskSearchTimeout.value = setTimeout(async () => {

View File

@ -173,11 +173,11 @@
<div class="select">
<select v-model="selectedView[s.id]">
<option
v-for="(title, key) in availableViews"
:key="key"
:value="key"
v-for="(view) in availableViews"
:key="view.id"
:value="view.id"
>
{{ title }}
{{ view.title }}
</option>
</select>
</div>
@ -230,9 +230,9 @@ import LinkShareService from '@/services/linkShare'
import {useCopyToClipboard} from '@/composables/useCopyToClipboard'
import {success} from '@/message'
import {getDisplayName} from '@/models/user'
import type {ProjectView} from '@/types/ProjectView'
import {PROJECT_VIEWS} from '@/types/ProjectView'
import {useConfigStore} from '@/stores/config'
import {useProjectStore} from '@/stores/projects'
import type {IProjectView} from '@/modelTypes/IProjectView'
const props = defineProps({
projectId: {
@ -252,17 +252,13 @@ const showDeleteModal = ref(false)
const linkIdToDelete = ref(0)
const showNewForm = ref(false)
type SelectedViewMapper = Record<IProject['id'], ProjectView>
type SelectedViewMapper = Record<IProject['id'], IProjectView['id']>
const selectedView = ref<SelectedViewMapper>({})
const availableViews = computed<Record<ProjectView, string>>(() => ({
list: t('project.list.title'),
gantt: t('project.gantt.title'),
table: t('project.table.title'),
kanban: t('project.kanban.title'),
}))
const projectStore = useProjectStore()
const availableViews = computed<IProjectView[]>(() => projectStore.projects[props.projectId]?.views || [])
const copy = useCopyToClipboard()
watch(
() => props.projectId,
@ -281,7 +277,7 @@ async function load(projectId: IProject['id']) {
const links = await linkShareService.getAll({projectId})
links.forEach((l: ILinkShare) => {
selectedView.value[l.id] = 'list'
selectedView.value[l.id] = availableViews.value[0].id
})
linkShares.value = links
}
@ -315,8 +311,8 @@ async function remove(projectId: IProject['id']) {
}
}
function getShareLink(hash: string, view: ProjectView = PROJECT_VIEWS.LIST) {
return frontendUrl.value + 'share/' + hash + '/auth?view=' + view
function getShareLink(hash: string, viewId: IProjectView['id']) {
return frontendUrl.value + 'share/' + hash + '/auth?view=' + viewId
}
</script>

View File

@ -16,7 +16,22 @@
:search-results="found"
:label="searchLabel"
@search="find"
/>
>
<template #searchResult="{option: result}">
<User
v-if="shareType === 'user'"
:avatar-size="24"
:show-username="true"
:user="result"
/>
<span
v-else
class="search-result"
>
{{ result.name }}
</span>
</template>
</Multiselect>
</p>
<p class="control">
<x-button @click="add()">
@ -172,6 +187,8 @@ import Multiselect from '@/components/input/multiselect.vue'
import Nothing from '@/components/misc/nothing.vue'
import {success} from '@/message'
import {useAuthStore} from '@/stores/auth'
import {useConfigStore} from '@/stores/config'
import User from '@/components/misc/user.vue'
// FIXME: I think this whole thing can now only manage user/team sharing for projects? Maybe remove a little generalization?
@ -210,8 +227,8 @@ const selectedRight = ref({})
const sharables = ref([])
const showDeleteModal = ref(false)
const authStore = useAuthStore()
const configStore = useConfigStore()
const userInfo = computed(() => authStore.info)
function createShareTypeNameComputed(count: number) {
@ -360,7 +377,15 @@ async function find(query: string) {
found.value = []
return
}
const results = await searchService.getAll({}, {s: query})
// Include public teams here if we are sharing with teams and its enabled in the config
let results = []
if (props.shareType === 'team' && configStore.publicTeamsEnabled) {
results = await searchService.getAll({}, {s: query, includePublic: true})
} else {
results = await searchService.getAll({}, {s: query})
}
found.value = results
.filter(m => {
if(props.shareType === 'user' && m.id === currentUserId.value) {

View File

@ -21,12 +21,12 @@
@dragendBar="updateGanttTask"
@dblclickBar="openTask"
>
<template #timeunit="{value, date}">
<template #timeunit="{date}">
<div
class="timeunit-wrapper"
:class="{'today': dateIsToday(date)}"
>
<span>{{ value }}</span>
<span>{{ date.getDate() }}</span>
<span class="weekday">
{{ weekDayFromDate(date) }}
</span>

View File

@ -77,7 +77,7 @@ const props = defineProps({
const emit = defineEmits(['taskAdded'])
const newTaskTitle = ref('')
const newTaskInput = useAutoHeightTextarea(newTaskTitle)
const {textarea: newTaskInput} = useAutoHeightTextarea(newTaskTitle)
const {t} = useI18n({useScope: 'global'})
const authStore = useAuthStore()

View File

@ -323,9 +323,8 @@ async function setCoverImage(attachment: IAttachment | null) {
margin-bottom: 0;
display: flex;
> span:not(:last-child):after,
> span,
> button:not(:last-child):after {
content: '·';
padding: 0 .25rem;
}
}

View File

@ -97,6 +97,13 @@
editComment()
}"
/>
<Reactions
v-model="c.reactions"
class="mt-2"
entity-kind="comments"
:entity-id="c.id"
:disabled="!canWrite"
/>
</div>
</div>
<div
@ -190,6 +197,7 @@ import {formatDateLong, formatDateSince} from '@/helpers/time/formatDate'
import {getAvatarUrl, getDisplayName} from '@/models/user'
import {useConfigStore} from '@/stores/config'
import {useAuthStore} from '@/stores/auth'
import Reactions from '@/components/input/Reactions.vue'
const props = defineProps({
taskId: {

View File

@ -37,7 +37,7 @@
</template>
<script setup lang="ts">
import {ref, computed, watch} from 'vue'
import {ref, computed, watch, onBeforeUnmount} from 'vue'
import CustomTransition from '@/components/misc/CustomTransition.vue'
import Editor from '@/components/input/AsyncEditor'
@ -88,6 +88,12 @@ async function saveWithDelay() {
}, 5000)
}
onBeforeUnmount(() => {
if (changeTimeout.value !== null) {
clearTimeout(changeTimeout.value)
}
})
async function save() {
if (changeTimeout.value !== null) {
clearTimeout(changeTimeout.value)

View File

@ -47,7 +47,6 @@
<ReminderPeriod
v-if="activeForm === 'relative'"
v-model="reminder"
@update:modelValue="updateDataAndMaybeClose(close)"
/>
<DatepickerInline
@ -60,7 +59,7 @@
v-if="showFormSwitch !== null"
class="reminder__close-button"
:shadow="false"
@click="updateDataAndMaybeClose(close)"
@click="updateDataAndMaybeCloseNow(close)"
>
{{ $t('misc.confirm') }}
</x-button>
@ -73,10 +72,10 @@
<script setup lang="ts">
import {computed, ref, watch} from 'vue'
import {SECONDS_A_DAY, SECONDS_A_HOUR} from '@/constants/date'
import {IReminderPeriodRelativeTo, REMINDER_PERIOD_RELATIVE_TO_TYPES} from '@/types/IReminderPeriodRelativeTo'
import {type IReminderPeriodRelativeTo, REMINDER_PERIOD_RELATIVE_TO_TYPES} from '@/types/IReminderPeriodRelativeTo'
import {useI18n} from 'vue-i18n'
import {PeriodUnit, secondsToPeriod} from '@/helpers/time/period'
import {type PeriodUnit, secondsToPeriod} from '@/helpers/time/period'
import type {ITaskReminder} from '@/modelTypes/ITaskReminder'
import {formatDateShort} from '@/helpers/time/formatDate'
@ -87,6 +86,7 @@ import Popup from '@/components/misc/popup.vue'
import TaskReminderModel from '@/models/taskReminder'
import Card from '@/components/misc/card.vue'
import SimpleButton from '@/components/input/SimpleButton.vue'
import {useDebounceFn} from '@vueuse/core'
const {
modelValue,
@ -112,7 +112,7 @@ const presets = computed<TaskReminderModel[]>(() => [
{reminder: null, relativePeriod: -1 * SECONDS_A_DAY * 7, relativeTo: defaultRelativeTo},
{reminder: null, relativePeriod: -1 * SECONDS_A_DAY * 30, relativeTo: defaultRelativeTo},
])
const reminderDate = ref<Date|null>(null)
const reminderDate = ref<Date | null>(null)
type availableForms = null | 'relative' | 'absolute'
@ -142,16 +142,16 @@ const reminderText = computed(() => {
watch(
() => modelValue,
(newReminder) => {
if(newReminder) {
if (newReminder) {
reminder.value = newReminder
if(newReminder.relativeTo === null) {
if (newReminder.relativeTo === null) {
reminderDate.value = new Date(newReminder.reminder)
}
return
}
reminder.value = new TaskReminderModel()
},
{immediate: true},
@ -181,7 +181,9 @@ function setReminderFromPreset(preset, close) {
close()
}
function updateDataAndMaybeClose(close) {
const updateDataAndMaybeClose = useDebounceFn(updateDataAndMaybeCloseNow, 500)
function updateDataAndMaybeCloseNow(close) {
updateData()
if (clearAfterUpdate) {
close()

View File

@ -71,8 +71,7 @@ import {periodToSeconds, PeriodUnit, secondsToPeriod} from '@/helpers/time/perio
import TaskReminderModel from '@/models/taskReminder'
import type {ITaskReminder} from '@/modelTypes/ITaskReminder'
import {REMINDER_PERIOD_RELATIVE_TO_TYPES, type IReminderPeriodRelativeTo} from '@/types/IReminderPeriodRelativeTo'
import {useDebounceFn} from '@vueuse/core'
import {type IReminderPeriodRelativeTo, REMINDER_PERIOD_RELATIVE_TO_TYPES} from '@/types/IReminderPeriodRelativeTo'
const {
modelValue,
@ -123,7 +122,7 @@ function updateData() {
reminder.value.relativeTo = period.value.relativeTo
reminder.value.reminder = null
useDebounceFn(() => emit('update:modelValue', reminder.value), 1000)
emit('update:modelValue', reminder.value)
}
</script>

View File

@ -30,7 +30,7 @@
<router-link
v-if="showProject && typeof project !== 'undefined'"
v-tooltip="$t('task.detail.belongsToProject', {project: project.title})"
:to="{ name: 'project.list', params: { projectId: task.projectId } }"
:to="{ name: 'project.index', params: { projectId: task.projectId } }"
class="task-project mr-1"
:class="{'mr-2': task.hexColor !== ''}"
>
@ -111,7 +111,7 @@
<icon icon="align-left" />
</span>
<span
v-if="task.repeatAfter.amount > 0"
v-if="task.repeatAfter.amount > 0 || (task.repeatAfter.amount === 0 && task.repeatMode === TASK_REPEAT_MODES.REPEAT_MODE_MONTH)"
class="project-task-icon"
>
<icon icon="history" />
@ -136,7 +136,7 @@
<router-link
v-if="showProjectSeparately"
v-tooltip="$t('task.detail.belongsToProject', {project: project.title})"
:to="{ name: 'project.list', params: { projectId: task.projectId } }"
:to="{ name: 'project.index', params: { projectId: task.projectId } }"
class="task-project"
>
{{ project.title }}
@ -207,6 +207,7 @@ import {useIntervalFn} from '@vueuse/core'
import {playPopSound} from '@/helpers/playPop'
import {useAuthStore} from '@/stores/auth'
import {isEditorContentEmpty} from '@/helpers/editorContentEmpty'
import {TASK_REPEAT_MODES} from '@/types/IRepeatMode'
const {
theTask,

View File

@ -6,6 +6,7 @@ import {debouncedWatch, tryOnMounted, useWindowSize, type MaybeRef} from '@vueus
export function useAutoHeightTextarea(value: MaybeRef<string>) {
const textarea = ref<HTMLTextAreaElement | null>(null)
const minHeight = ref(0)
const height = ref('')
// adapted from https://github.com/LeaVerou/stretchy/blob/47f5f065c733029acccb755cae793009645809e2/src/stretchy.js#L34
function resize(textareaEl: HTMLTextAreaElement | null) {
@ -19,18 +20,17 @@ export function useAutoHeightTextarea(value: MaybeRef<string>) {
textareaEl.value = textareaEl.placeholder
}
const cs = getComputedStyle(textareaEl)
// 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'
height.value = textareaEl.scrollHeight + 'px'
textareaEl.style.height = height
textareaEl.style.height = height.value
// calculate min-height for the first time
if (!minHeight.value) {
minHeight.value = parseFloat(height)
minHeight.value = parseFloat(height.value)
}
textareaEl.style.minHeight = minHeight.value.toString()
@ -68,5 +68,8 @@ export function useAutoHeightTextarea(value: MaybeRef<string>) {
},
)
return textarea
return {
textarea,
height,
}
}

View File

@ -1,12 +1,14 @@
import {computed, shallowRef, watchEffect, h, type VNode} from 'vue'
import {computed, h, shallowRef, type VNode, watchEffect} from 'vue'
import {useRoute, useRouter} from 'vue-router'
import {useBaseStore} from '@/stores/base'
import {useProjectStore} from '@/stores/projects'
export function useRouteWithModal() {
const router = useRouter()
const route = useRoute()
const backdropView = computed(() => route.fullPath && window.history.state.backdropView)
const baseStore = useBaseStore()
const projectStore = useProjectStore()
const routeWithModal = computed(() => {
return backdropView.value
@ -29,7 +31,7 @@ export function useRouteWithModal() {
if (routePropsOption === true) {
routeProps = route.params
} else {
if(typeof routePropsOption === 'function') {
if (typeof routePropsOption === 'function') {
routeProps = routePropsOption(route)
} else {
routeProps = routePropsOption
@ -52,7 +54,7 @@ export function useRouteWithModal() {
}
currentModal.value = h(component, routeProps)
})
const historyState = computed(() => route.fullPath && window.history.state)
function closeModal() {
@ -60,12 +62,23 @@ export function useRouteWithModal() {
// If the current project was changed because the user moved the currently opened task while coming from kanban,
// we need to reflect that change in the route when they close the task modal.
// The last route is only available as resolved string, therefore we need to use a regex for matching here
const kanbanRouteMatch = new RegExp('\\/projects\\/\\d+\\/kanban', 'g')
const kanbanRouter = {name: 'project.kanban', params: {projectId: baseStore.currentProject?.id}}
if (kanbanRouteMatch.test(historyState.value.back)
&& baseStore.currentProject
&& historyState.value.back !== router.resolve(kanbanRouter).fullPath) {
router.push(kanbanRouter)
const routeMatch = new RegExp('\\/projects\\/\\d+\\/(\\d+)', 'g')
const match = routeMatch.exec(historyState.value.back)
if (match !== null && baseStore.currentProject) {
let viewId: string | number = match[1]
if (!viewId) {
viewId = projectStore.projects[baseStore.currentProject?.id].views[0]?.id
}
const newRoute = {
name: 'project.view',
params: {
projectId: baseStore.currentProject?.id,
viewId,
},
}
router.push(newRoute)
return
}

View File

@ -1,11 +1,13 @@
import {ref, shallowReactive, watch, computed, type ComputedGetter} from 'vue'
import {useRoute} from 'vue-router'
import {useRoute, useRouter} from 'vue-router'
import {useRouteQuery} from '@vueuse/router'
import TaskCollectionService from '@/services/taskCollection'
import TaskCollectionService, {getDefaultTaskFilterParams, type TaskFilterParams} from '@/services/taskCollection'
import type {ITask} from '@/modelTypes/ITask'
import {error} from '@/message'
import type {IProject} from '@/modelTypes/IProject'
import {useAuthStore} from '@/stores/auth'
import type {IProjectView} from '@/modelTypes/IProjectView'
export type Order = 'asc' | 'desc' | 'none'
@ -24,16 +26,6 @@ export interface SortBy {
done_at?: Order,
}
// FIXME: merge with DEFAULT_PARAMS in filters.vue
export const getDefaultParams = () => ({
sort_by: ['position', 'id'],
order_by: ['asc', 'desc'],
filter_by: ['done'],
filter_value: ['false'],
filter_comparator: ['equals'],
filter_concat: 'and',
})
const SORT_BY_DEFAULT: SortBy = {
id: 'desc',
}
@ -63,13 +55,17 @@ const SORT_BY_DEFAULT: SortBy = {
/**
* This mixin provides a base set of methods and properties to get tasks.
*/
export function useTaskList(projectIdGetter: ComputedGetter<IProject['id']>, sortByDefault: SortBy = SORT_BY_DEFAULT) {
export function useTaskList(
projectIdGetter: ComputedGetter<IProject['id']>,
projectViewIdGetter: ComputedGetter<IProjectView['id']>,
sortByDefault: SortBy = SORT_BY_DEFAULT,
) {
const projectId = computed(() => projectIdGetter())
const projectViewId = computed(() => projectViewIdGetter())
const params = ref({...getDefaultParams()})
const params = ref<TaskFilterParams>({...getDefaultTaskFilterParams()})
const search = ref('')
const page = useRouteQuery('page', '1', { transform: Number })
const sortBy = ref({ ...sortByDefault })
@ -77,10 +73,6 @@ export function useTaskList(projectIdGetter: ComputedGetter<IProject['id']>, sor
const allParams = computed(() => {
const loadParams = {...params.value}
if (search.value !== '') {
loadParams.s = search.value
}
return formatSortOrder(sortBy.value, loadParams)
})
@ -91,11 +83,19 @@ export function useTaskList(projectIdGetter: ComputedGetter<IProject['id']>, sor
page.value = 1
},
)
const authStore = useAuthStore()
const getAllTasksParams = computed(() => {
return [
{projectId: projectId.value},
allParams.value,
{
projectId: projectId.value,
viewId: projectViewId.value,
},
{
...allParams.value,
filter_timezone: authStore.settings.timezone,
},
page.value,
]
})
@ -117,16 +117,38 @@ export function useTaskList(projectIdGetter: ComputedGetter<IProject['id']>, sor
const route = useRoute()
watch(() => route.query, (query) => {
const { page: pageQueryValue, search: searchQuery } = query
if (searchQuery !== undefined) {
search.value = searchQuery as string
const {
page: pageQueryValue,
s,
filter,
} = query
if (s !== undefined) {
params.value.s = s as string
}
if (pageQueryValue !== undefined) {
page.value = Number(pageQueryValue)
}
if (filter !== undefined) {
params.value.filter = filter
}
}, { immediate: true })
const router = useRouter()
watch(
() => [page.value, params.value.filter, params.value.s],
() => {
router.replace({
name: route.name,
params: route.params,
query: {
page: page.value,
filter: params.value.filter || undefined,
s: params.value.s || undefined,
},
})
},
{ deep: true },
)
// Only listen for query path changes
watch(() => JSON.stringify(getAllTasksParams.value), (newParams, oldParams) => {
@ -143,7 +165,6 @@ export function useTaskList(projectIdGetter: ComputedGetter<IProject['id']>, sor
totalPages,
currentPage: page,
loadTasks,
searchTerm: search,
params,
sortByParam: sortBy,
}

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