Compare commits

..

342 Commits

Author SHA1 Message Date
renovate ee25105224 fix(deps): update module github.com/asaskevich/govalidator to v11
renovate/artifacts Artifact file update failure
continuous-integration/drone/pr Build is failing Details
2024-04-11 19:09:19 +00: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
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
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
269 changed files with 17590 additions and 6088 deletions

View File

@ -139,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'
@ -364,7 +364,7 @@ steps:
- api-build
- name: frontend-dependencies
image: node:20.11.0-alpine
image: node:20.11.1-alpine
pull: always
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
@ -378,7 +378,7 @@ steps:
# - restore-cache
- name: frontend-lint
image: node:20.11.0-alpine
image: node:20.11.1-alpine
pull: always
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
@ -390,7 +390,7 @@ steps:
- frontend-dependencies
- name: frontend-build-prod
image: node:20.11.0-alpine
image: node:20.11.1-alpine
pull: always
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
@ -402,7 +402,7 @@ steps:
- frontend-dependencies
- name: frontend-test-unit
image: node:20.11.0-alpine
image: node:20.11.1-alpine
pull: always
commands:
- cd frontend
@ -413,7 +413,7 @@ steps:
- name: frontend-typecheck
failure: ignore
image: node:20.11.0-alpine
image: node:20.11.1-alpine
pull: always
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
@ -461,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
@ -545,7 +544,7 @@ steps:
- git fetch --tags
- name: frontend-dependencies
image: node:20.11.0-alpine
image: node:20.11.1-alpine
pull: always
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
@ -557,7 +556,7 @@ steps:
- pnpm install --fetch-timeout 100000
- name: frontend-build
image: node:20.11.0-alpine
image: node:20.11.1-alpine
pull: always
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
@ -717,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
@ -733,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
@ -902,7 +901,7 @@ steps:
- git fetch --tags
- name: build
image: node:20.11.0-alpine
image: node:20.11.1-alpine
pull: always
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
@ -963,7 +962,7 @@ steps:
- git fetch --tags
- name: build
image: node:20.11.0-alpine
image: node:20.11.1-alpine
pull: always
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
@ -1401,6 +1400,6 @@ steps:
- failure
---
kind: signature
hmac: a569410ea13ad83c15c7606ed44b17b6bac0eb66d668344dfbf008c9448b4af5
hmac: 2c9cb0483fb346988188515f6423929f46eefb9e14eb26b0f312a0b694d5fe8c
...

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

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

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

View File

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

View File

@ -581,10 +581,10 @@ cookie-signature@1.0.6:
resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c"
integrity sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==
cookie@0.5.0:
version "0.5.0"
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.5.0.tgz#d1f5d71adec6558c58f389987c366aa47e994f8b"
integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==
cookie@0.6.0:
version "0.6.0"
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.6.0.tgz#2798b04b071b0ecbff0dbb62a505a8efa4e19051"
integrity sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==
core-util-is@1.0.2:
version "1.0.2"
@ -769,10 +769,10 @@ electron-publish@24.13.1:
lazy-val "^1.0.5"
mime "^2.5.2"
electron@29.1.1:
version "29.1.1"
resolved "https://registry.yarnpkg.com/electron/-/electron-29.1.1.tgz#e9cb11311324e4b43a3e73667cd2b65a30e8fa34"
integrity sha512-cXN15NgCi7MkzGo5/23ZQbii+0UfhmUiDjACunmzcUofYCjF42XhFbL7JZnwgI0qtBCCeJU8qZNZt9lU91gUFw==
electron@29.3.0:
version "29.3.0"
resolved "https://registry.yarnpkg.com/electron/-/electron-29.3.0.tgz#8e65cb08e9c0952c66d3196e1b5c811c43b8c5b0"
integrity sha512-ZxFKm0/v48GSoBuO3DdnMlCYXefEUKUHLMsKxyXY4nZGgzbBKpF/X8haZa2paNj23CLfsCKBOtfc2vsEQiOOsA==
dependencies:
"@electron/get" "^2.0.0"
"@types/node" "^20.9.0"
@ -830,17 +830,17 @@ etag@~1.8.1:
resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887"
integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==
express@4.18.3:
version "4.18.3"
resolved "https://registry.yarnpkg.com/express/-/express-4.18.3.tgz#6870746f3ff904dee1819b82e4b51509afffb0d4"
integrity sha512-6VyCijWQ+9O7WuVMTRBTl+cjNNIzD5cY5mQ1WM8r/LEkI2u8EYpOotESNwzNlyCn3g+dmjKYI6BmNneSr/FSRw==
express@4.19.2:
version "4.19.2"
resolved "https://registry.yarnpkg.com/express/-/express-4.19.2.tgz#e25437827a3aa7f2a827bc8171bbbb664a356465"
integrity sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==
dependencies:
accepts "~1.3.8"
array-flatten "1.1.1"
body-parser "1.20.2"
content-disposition "0.5.4"
content-type "~1.0.4"
cookie "0.5.0"
cookie "0.6.0"
cookie-signature "1.0.6"
debug "2.6.9"
depd "2.0.0"

View File

@ -35,7 +35,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,17 @@ Full path: `service.customlogourl`
Environment path: `VIKUNJA_SERVICE_CUSTOMLOGOURL`
### enablepublicteams
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

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

@ -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.
@ -64,7 +80,7 @@ auth:
redirecturl: https://vikunja.mydomain.com/auth/openid/ <---- slash at the end is important
providers:
- name: <provider-name>
authurl: <auth-url> <----- Used for OIDC Discovery, usually the issuer
authurl: <auth-url> <----- Used for OIDC Discovery, usually the issuer
clientid: <vikunja client-id>
clientsecret: <vikunja client-secret>
scope: openid profile email
@ -87,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.
@ -99,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
{
@ -116,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

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

View File

@ -55,47 +55,51 @@ This document describes the different errors Vikunja can return.
## 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

@ -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": {

18
flake.nix Normal file
View File

@ -0,0 +1,18 @@
{
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
];
};
};
}

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

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,7 @@ import {BucketFactory} from '../../factories/bucket'
import {TaskAttachmentFactory} from '../../factories/task_attachments'
import {TaskReminderFactory} from '../../factories/task_reminders'
import {createDefaultViews} from "../project/prepareProjects";
function addLabelToTaskAndVerify(labelTitle: string) {
cy.get('.task-view .action-buttons .button')
@ -53,15 +54,16 @@ describe('Task', () => {
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 +77,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 +95,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 +106,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 +115,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 +128,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 +141,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 +316,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 +467,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)
@ -831,12 +833,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@8.15.6",
"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",
"@infectoone/vue-ganttastic": "2.3.2",
"@intlify/unplugin-vue-i18n": "3.0.1",
"@kyvg/vue3-notification": "3.2.0",
"@sentry/tracing": "7.106.0",
"@sentry/vue": "7.106.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",
"@kyvg/vue3-notification": "3.2.1",
"@sentry/tracing": "7.109.0",
"@sentry/vue": "7.109.0",
"@tiptap/core": "2.3.0",
"@tiptap/extension-blockquote": "2.3.0",
"@tiptap/extension-bold": "2.3.0",
"@tiptap/extension-bullet-list": "2.3.0",
"@tiptap/extension-code": "2.3.0",
"@tiptap/extension-code-block-lowlight": "2.3.0",
"@tiptap/extension-document": "2.3.0",
"@tiptap/extension-dropcursor": "2.3.0",
"@tiptap/extension-gapcursor": "2.3.0",
"@tiptap/extension-hard-break": "2.3.0",
"@tiptap/extension-heading": "2.3.0",
"@tiptap/extension-history": "2.3.0",
"@tiptap/extension-horizontal-rule": "2.3.0",
"@tiptap/extension-image": "2.3.0",
"@tiptap/extension-italic": "2.3.0",
"@tiptap/extension-link": "2.3.0",
"@tiptap/extension-list-item": "2.3.0",
"@tiptap/extension-ordered-list": "2.3.0",
"@tiptap/extension-paragraph": "2.3.0",
"@tiptap/extension-placeholder": "2.3.0",
"@tiptap/extension-strike": "2.3.0",
"@tiptap/extension-table": "2.3.0",
"@tiptap/extension-table-cell": "2.3.0",
"@tiptap/extension-table-header": "2.3.0",
"@tiptap/extension-table-row": "2.3.0",
"@tiptap/extension-task-item": "2.3.0",
"@tiptap/extension-task-list": "2.3.0",
"@tiptap/extension-text": "2.3.0",
"@tiptap/extension-typography": "2.3.0",
"@tiptap/extension-underline": "2.3.0",
"@tiptap/pm": "2.3.0",
"@tiptap/suggestion": "2.3.0",
"@tiptap/vue-3": "2.3.0",
"@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",
"date-fns": "3.6.0",
"dayjs": "1.11.10",
"dompurify": "3.0.9",
"dompurify": "3.1.0",
"fast-deep-equal": "3.1.3",
"flatpickr": "4.6.13",
"flexsearch": "0.7.31",
@ -117,12 +117,13 @@
"snake-case": "3.0.4",
"sortablejs": "1.15.2",
"tippy.js": "6.3.7",
"ufo": "1.4.0",
"ufo": "1.5.3",
"vue": "3.4.21",
"vue-advanced-cropper": "2.8.8",
"vue-flatpickr-component": "11.0.5",
"vue-i18n": "9.10.1",
"vue-i18n": "9.11.1",
"vue-router": "4.3.0",
"vuemoji-picker": "0.2.1",
"workbox-precaching": "7.0.0",
"zhyswan-vuedraggable": "4.1.3"
},
@ -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.25",
"@types/node": "20.12.7",
"@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",
"@typescript-eslint/eslint-plugin": "7.6.0",
"@typescript-eslint/parser": "7.6.0",
"@vitejs/plugin-legacy": "5.3.2",
"@vitejs/plugin-vue": "5.0.4",
"@vue/eslint-config-typescript": "13.0.0",
"@vue/test-utils": "2.4.4",
"@vue/test-utils": "2.4.5",
"@vue/tsconfig": "0.5.1",
"autoprefixer": "10.4.18",
"autoprefixer": "10.4.19",
"browserslist": "4.23.0",
"caniuse-lite": "1.0.30001596",
"css-has-pseudo": "6.0.2",
"caniuse-lite": "1.0.30001608",
"css-has-pseudo": "6.0.3",
"csstype": "3.1.3",
"cypress": "13.6.6",
"esbuild": "0.20.1",
"cypress": "13.7.2",
"esbuild": "0.20.2",
"eslint": "8.57.0",
"eslint-plugin-vue": "9.22.0",
"happy-dom": "13.7.1",
"histoire": "0.17.9",
"postcss": "8.4.35",
"eslint-plugin-vue": "9.24.1",
"happy-dom": "14.7.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.5.0",
"rollup": "4.12.1",
"postcss-preset-env": "9.5.4",
"rollup": "4.14.1",
"rollup-plugin-visualizer": "5.12.0",
"sass": "1.71.1",
"sass": "1.74.1",
"start-server-and-test": "2.0.3",
"typescript": "5.4.2",
"vite": "5.1.5",
"typescript": "5.4.5",
"vite": "5.2.8",
"vite-plugin-inject-preload": "1.3.3",
"vite-plugin-pwa": "0.19.2",
"vite-plugin-pwa": "0.19.8",
"vite-plugin-sentry": "1.4.0",
"vite-svg-loader": "5.1.0",
"vitest": "1.3.1",
"vue-tsc": "2.0.6",
"vitest": "1.4.0",
"vue-tsc": "2.0.12",
"wait-on": "7.2.0",
"workbox-cli": "7.0.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 })

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>

View File

@ -33,11 +33,15 @@ import {useBaseStore} from '@/stores/base'
import Logo from '@/components/home/Logo.vue'
import PoweredByLink from './PoweredByLink.vue'
import {useProjectStore} from '@/stores/projects'
const baseStore = useBaseStore()
const currentProject = computed(() => baseStore.currentProject)
const background = computed(() => baseStore.background)
const logoVisible = computed(() => baseStore.logoVisible)
const projectStore = useProjectStore()
projectStore.loadAllProjects()
</script>
<style lang="scss" scoped>

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"
@ -374,7 +374,7 @@ const editor = useEditor({
Typography,
Underline,
Link.configure({
openOnClick: true,
openOnClick: false,
validate: (href: string) => /^https?:\/\//.test(href),
}),
Table.configure({
@ -489,6 +489,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 +531,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 +571,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 +613,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.
@ -874,8 +891,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(() => currentProject.value?.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,50 @@ 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;
@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 +202,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 +210,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

@ -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,53 @@ import {computed, ref, watch} from 'vue'
import Filters from '@/components/project/partials/filters.vue'
import {getDefaultParams} from '@/composables/useTaskList'
import {getDefaultTaskFilterParams, type TaskFilterParams} from '@/services/taskCollection'
import {useRouteQuery} from '@vueuse/router'
const props = defineProps({
modelValue: {
required: true,
},
})
const emit = defineEmits(['update:modelValue'])
const modelValue = defineModel<TaskFilterParams>({})
const value = computed({
get() {
return props.modelValue
},
set(value) {
if(props.modelValue === value) {
return
}
emit('update:modelValue', value)
},
})
const value = ref<TaskFilterParams>({})
const filter = useRouteQuery('filter')
watch(
() => props.modelValue,
(modelValue) => {
() => modelValue.value,
(modelValue: TaskFilterParams) => {
value.value = modelValue
if (value.value.filter !== '' && value.value.filter !== getDefaultTaskFilterParams().filter) {
filter.value = value.value.filter
}
},
{immediate: true},
)
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()}
watch(
() => filter.value,
val => {
if (modelValue.value?.filter === val || typeof val === 'undefined') {
return
}
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,
modelValue.value.filter = val
},
{immediate: true},
)
function emitChanges(newValue: TaskFilterParams) {
filter.value = newValue.filter
if (modelValue.value?.filter === newValue.filter && modelValue.value?.s === newValue.s) {
return
}
return JSON.stringify(params) !== JSON.stringify(defaultParams)
modelValue.value.filter = newValue.filter
modelValue.value.s = newValue.s
}
const hasFilters = computed(() => {
return value.value.filter !== '' ||
value.value.s !== ''
})
const modalOpen = ref(false)
function clearFilters() {
value.value = {...getDefaultParams()}
}
</script>
<style scoped lang="scss">
@ -96,4 +87,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="params.filter"
: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="params.filter === ''"
@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,101 @@ 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()
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
},
{immediate: true, debounce: 500, maxWait: 1000},
{immediate: true},
)
const sortAlphabetically = computed({
get() {
return params.value?.sort_by?.find(sortBy => sortBy === ALPHABETICAL_SORT) !== undefined
},
set(sortAlphabetically) {
params.value.sort_by = sortAlphabetically
? [ALPHABETICAL_SORT]
: getDefaultParams().sort_by
change()
},
})
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(
params.value.filter,
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() {
params.value.filter = ''
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')})
}
@ -738,6 +800,7 @@ $filter-container-height: '1rem - #{$switch-view-height}';
* {
opacity: 0;
}
&::after {
content: '';
position: absolute;
@ -779,6 +842,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,

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>
@ -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,9 +334,7 @@ const {
const tasks: Ref<ITask[]> = taskList.tasks
Object.assign(params.value, {
filter_by: [],
filter_value: [],
filter_comparator: [],
filter: '',
})
// FIXME: by doing this we can have multiple sort orders
@ -383,6 +382,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 +396,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

@ -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 !== ''}"
>
@ -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 }}

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

@ -2,10 +2,12 @@ import {ref, shallowReactive, watch, computed, type ComputedGetter} from 'vue'
import {useRoute} 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,11 +55,16 @@ 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 })
@ -91,11 +88,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,
]
})

View File

@ -0,0 +1,222 @@
import {describe, expect, it} from 'vitest'
import {transformFilterStringForApi, transformFilterStringFromApi} from '@/helpers/filters'
const nullTitleToIdResolver = (title: string) => null
const nullIdToTitleResolver = (id: number) => null
describe('Filter Transformation', () => {
const fieldCases = {
'done': 'done',
'priority': 'priority',
'percentDone': 'percent_done',
'dueDate': 'due_date',
'startDate': 'start_date',
'endDate': 'end_date',
'doneAt': 'done_at',
'reminders': 'reminders',
'assignees': 'assignees',
'labels': 'labels',
}
describe('For API', () => {
for (const c in fieldCases) {
it('should transform all filter params for ' + c + ' to snake_case', () => {
const transformed = transformFilterStringForApi(c + ' = ipsum', nullTitleToIdResolver, nullTitleToIdResolver)
expect(transformed).toBe(fieldCases[c] + ' = ipsum')
})
}
it('should correctly resolve labels', () => {
const transformed = transformFilterStringForApi(
'labels = lorem',
(title: string) => 1,
nullTitleToIdResolver,
)
expect(transformed).toBe('labels = 1')
})
const multipleDummyResolver = (title: string) => {
switch (title) {
case 'lorem':
return 1
case 'ipsum':
return 2
default:
return null
}
}
it('should correctly resolve multiple labels', () => {
const transformed = transformFilterStringForApi(
'labels = lorem && dueDate = now && labels = ipsum',
multipleDummyResolver,
nullTitleToIdResolver,
)
expect(transformed).toBe('labels = 1 && due_date = now && labels = 2')
})
it('should correctly resolve multiple labels with an in clause', () => {
const transformed = transformFilterStringForApi(
'labels in lorem, ipsum && dueDate = now',
multipleDummyResolver,
nullTitleToIdResolver,
)
expect(transformed).toBe('labels in 1, 2 && due_date = now')
})
it('should correctly resolve projects', () => {
const transformed = transformFilterStringForApi(
'project = lorem',
nullTitleToIdResolver,
(title: string) => 1,
)
expect(transformed).toBe('project = 1')
})
it('should correctly resolve multiple projects', () => {
const transformed = transformFilterStringForApi(
'project = lorem && dueDate = now || project = ipsum',
nullTitleToIdResolver,
multipleDummyResolver,
)
expect(transformed).toBe('project = 1 && due_date = now || project = 2')
})
it('should correctly resolve multiple projects with in', () => {
const transformed = transformFilterStringForApi(
'project in lorem, ipsum',
nullTitleToIdResolver,
multipleDummyResolver,
)
expect(transformed).toBe('project in 1, 2')
})
it('should resolve projects at the correct position', () => {
const transformed = transformFilterStringForApi(
'project = pr',
nullTitleToIdResolver,
(title: string) => 1,
)
expect(transformed).toBe('project = 1')
})
it('should resolve project and labels independently', () => {
const transformed = transformFilterStringForApi(
'project = lorem && labels = ipsum',
multipleDummyResolver,
multipleDummyResolver,
)
expect(transformed).toBe('project = 1 && labels = 2')
})
it('should transform the same attribute multiple times', () => {
const transformed = transformFilterStringForApi(
'dueDate = now/d || dueDate > now/w+1w',
nullTitleToIdResolver,
nullTitleToIdResolver,
)
expect(transformed).toBe('due_date = now/d || due_date > now/w+1w')
})
})
describe('To API', () => {
for (const c in fieldCases) {
it('should transform all filter params for ' + c + ' to snake_case', () => {
const transformed = transformFilterStringFromApi(fieldCases[c] + ' = ipsum', nullTitleToIdResolver, nullTitleToIdResolver)
expect(transformed).toBe(c + ' = ipsum')
})
}
it('should correctly resolve labels', () => {
const transformed = transformFilterStringFromApi(
'labels = 1',
(id: number) => 'lorem',
nullIdToTitleResolver,
)
expect(transformed).toBe('labels = lorem')
})
const multipleIdToTitleResolver = (id: number) => {
switch (id) {
case 1:
return 'lorem'
case 2:
return 'ipsum'
default:
return null
}
}
it('should correctly resolve multiple labels', () => {
const transformed = transformFilterStringFromApi(
'labels = 1 && due_date = now && labels = 2',
multipleIdToTitleResolver,
nullIdToTitleResolver,
)
expect(transformed).toBe('labels = lorem && dueDate = now && labels = ipsum')
})
it('should correctly resolve multiple labels in', () => {
const transformed = transformFilterStringFromApi(
'labels in 1, 2',
multipleIdToTitleResolver,
nullIdToTitleResolver,
)
expect(transformed).toBe('labels in lorem, ipsum')
})
it('should correctly resolve projects', () => {
const transformed = transformFilterStringFromApi(
'project = 1',
nullIdToTitleResolver,
(id: number) => 'lorem',
)
expect(transformed).toBe('project = lorem')
})
it('should correctly resolve multiple projects', () => {
const transformed = transformFilterStringFromApi(
'project = 1 && due_date = now || project = 2',
nullIdToTitleResolver,
multipleIdToTitleResolver,
)
expect(transformed).toBe('project = lorem && dueDate = now || project = ipsum')
})
it('should correctly resolve multiple projects in', () => {
const transformed = transformFilterStringFromApi(
'project in 1, 2',
nullIdToTitleResolver,
multipleIdToTitleResolver,
)
expect(transformed).toBe('project in lorem, ipsum')
})
it('should transform the same attribute multiple times', () => {
const transformed = transformFilterStringFromApi(
'due_date = now/d || due_date > now/w+1w',
nullIdToTitleResolver,
nullIdToTitleResolver,
)
expect(transformed).toBe('dueDate = now/d || dueDate > now/w+1w')
})
})
})

View File

@ -0,0 +1,200 @@
import {snakeCase} from 'snake-case'
export const DATE_FIELDS = [
'dueDate',
'startDate',
'endDate',
'doneAt',
'reminders',
]
export const ASSIGNEE_FIELDS = [
'assignees',
]
export const LABEL_FIELDS = [
'labels',
]
export const PROJECT_FIELDS = [
'project',
]
export const AUTOCOMPLETE_FIELDS = [
...LABEL_FIELDS,
...ASSIGNEE_FIELDS,
...PROJECT_FIELDS,
]
export const AVAILABLE_FILTER_FIELDS = [
...DATE_FIELDS,
...ASSIGNEE_FIELDS,
...LABEL_FIELDS,
...PROJECT_FIELDS,
'done',
'priority',
'percentDone',
]
export const FILTER_OPERATORS = [
'!=',
'=',
'>',
'>=',
'<',
'<=',
'like',
'in',
'?=',
]
export const FILTER_JOIN_OPERATOR = [
'&&',
'||',
'(',
')',
]
export const FILTER_OPERATORS_REGEX = '(&lt;|&gt;|&lt;=|&gt;=|=|!=|in)'
export function getFilterFieldRegexPattern(field: string): RegExp {
return new RegExp('(' + field + '\\s*' + FILTER_OPERATORS_REGEX + '\\s*)([\'"]?)([^\'"&|()<]+\\1?)?', 'ig')
}
export function transformFilterStringForApi(
filter: string,
labelResolver: (title: string) => number | null,
projectResolver: (title: string) => number | null,
): string {
if (filter.trim() === '') {
return ''
}
// Transform labels to ids
LABEL_FIELDS.forEach(field => {
const pattern = getFilterFieldRegexPattern(field)
let match: RegExpExecArray | null
while ((match = pattern.exec(filter)) !== null) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [matched, prefix, operator, space, keyword] = match
if (keyword) {
let keywords = [keyword.trim()]
if (operator === 'in' || operator === '?=') {
keywords = keyword.trim().split(',').map(k => k.trim())
}
keywords.forEach(k => {
const labelId = labelResolver(k)
if (labelId !== null) {
filter = filter.replace(k, String(labelId))
}
})
}
}
})
// Transform projects to ids
PROJECT_FIELDS.forEach(field => {
const pattern = getFilterFieldRegexPattern(field)
let match: RegExpExecArray | null
while ((match = pattern.exec(filter)) !== null) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [matched, prefix, operator, space, keyword] = match
if (keyword) {
let keywords = [keyword.trim()]
if (operator === 'in' || operator === '?=') {
keywords = keyword.trim().split(',').map(k => k.trim())
}
let replaced = keyword
keywords.forEach(k => {
const projectId = projectResolver(k)
if (projectId !== null) {
replaced = replaced.replace(k, String(projectId))
}
})
const actualKeywordStart = (match?.index || 0) + prefix.length
filter = filter.substring(0, actualKeywordStart) +
replaced +
filter.substring(actualKeywordStart + keyword.length)
}
}
})
// Transform all attributes to snake case
AVAILABLE_FILTER_FIELDS.forEach(f => {
filter = filter.replaceAll(f, snakeCase(f))
})
return filter
}
export function transformFilterStringFromApi(
filter: string,
labelResolver: (id: number) => string | null,
projectResolver: (id: number) => string | null,
): string {
if (filter.trim() === '') {
return ''
}
// Transform all attributes from snake case
AVAILABLE_FILTER_FIELDS.forEach(f => {
filter = filter.replaceAll(snakeCase(f), f)
})
// Transform labels to their titles
LABEL_FIELDS.forEach(field => {
const pattern = getFilterFieldRegexPattern(field)
let match: RegExpExecArray | null
while ((match = pattern.exec(filter)) !== null) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [matched, prefix, operator, space, keyword] = match
if (keyword) {
let keywords = [keyword.trim()]
if (operator === 'in' || operator === '?=') {
keywords = keyword.trim().split(',').map(k => k.trim())
}
keywords.forEach(k => {
const labelTitle = labelResolver(parseInt(k))
if (labelTitle !== null) {
filter = filter.replace(k, labelTitle)
}
})
}
}
})
// Transform projects to ids
PROJECT_FIELDS.forEach(field => {
const pattern = getFilterFieldRegexPattern(field)
let match: RegExpExecArray | null
while ((match = pattern.exec(filter)) !== null) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [matched, prefix, operator, space, keyword] = match
if (keyword) {
let keywords = [keyword.trim()]
if (operator === 'in' || operator === '?=') {
keywords = keyword.trim().split(',').map(k => k.trim())
}
keywords.forEach(k => {
const project = projectResolver(parseInt(k))
if (project !== null) {
filter = filter.replace(k, project)
}
})
}
}
})
return filter
}

View File

@ -1,64 +1,17 @@
import type { RouteRecordName } from 'vue-router'
import router from '@/router'
import type {IProject} from '@/modelTypes/IProject'
export type ProjectRouteName = Extract<RouteRecordName, string>
export type ProjectViewSettings = Record<
IProject['id'],
Extract<RouteRecordName, ProjectRouteName>
>
export type ProjectViewSettings = Record<IProject['id'], number>
const SETTINGS_KEY_PROJECT_VIEW = 'projectView'
// TODO: remove migration when releasing 1.0
type ListViewSettings = ProjectViewSettings
const SETTINGS_KEY_DEPRECATED_LIST_VIEW = 'listView'
function migrateStoredProjectRouteSettings() {
try {
const listViewSettingsString = localStorage.getItem(SETTINGS_KEY_DEPRECATED_LIST_VIEW)
if (listViewSettingsString === null) {
return
}
// A) the first version stored one setting for all lists in a string
if (listViewSettingsString.startsWith('list.')) {
const projectView = listViewSettingsString.replace('list.', 'project.')
if (!router.hasRoute(projectView)) {
return
}
return projectView as RouteRecordName
}
// B) the last version used a 'list.' prefix
const listViewSettings: ListViewSettings = JSON.parse(listViewSettingsString)
const projectViewSettingEntries = Object.entries(listViewSettings).map(([id, value]) => {
return [id, value.replace('list.', 'project.')]
})
const projectViewSettings = Object.fromEntries(projectViewSettingEntries)
localStorage.setItem(SETTINGS_KEY_PROJECT_VIEW, JSON.stringify(projectViewSettings))
} catch(e) {
//
} finally {
localStorage.removeItem(SETTINGS_KEY_DEPRECATED_LIST_VIEW)
}
}
/**
* Save the current project view to local storage
*/
export function saveProjectView(projectId: IProject['id'], routeName: string) {
if (routeName.includes('settings.')) {
export function saveProjectView(projectId: IProject['id'], viewId: number) {
if (!projectId || !viewId) {
return
}
if (!projectId) {
return
}
// We use local storage and not the store here to make it persistent across reloads.
const savedProjectView = localStorage.getItem(SETTINGS_KEY_PROJECT_VIEW)
let savedProjectViewSettings: ProjectViewSettings | false = false
@ -71,30 +24,19 @@ export function saveProjectView(projectId: IProject['id'], routeName: string) {
projectViewSettings = savedProjectViewSettings
}
projectViewSettings[projectId] = routeName
projectViewSettings[projectId] = viewId
localStorage.setItem(SETTINGS_KEY_PROJECT_VIEW, JSON.stringify(projectViewSettings))
}
export const getProjectView = (projectId: IProject['id']) => {
// TODO: remove migration when releasing 1.0
const migratedProjectView = migrateStoredProjectRouteSettings()
if (migratedProjectView !== undefined && router.hasRoute(migratedProjectView)) {
return migratedProjectView
export function getProjectViewId(projectId: IProject['id']): number {
const projectViewSettingsString = localStorage.getItem(SETTINGS_KEY_PROJECT_VIEW)
if (!projectViewSettingsString) {
return 0
}
try {
const projectViewSettingsString = localStorage.getItem(SETTINGS_KEY_PROJECT_VIEW)
if (!projectViewSettingsString) {
throw new Error()
}
const projectViewSettings = JSON.parse(projectViewSettingsString) as ProjectViewSettings
if (!router.hasRoute(projectViewSettings[projectId])) {
throw new Error()
}
return projectViewSettings[projectId]
} catch (e) {
return
}
const projectViewSettings = JSON.parse(projectViewSettingsString) as ProjectViewSettings
if (isNaN(projectViewSettings[projectId])) {
return 0
}
return projectViewSettings[projectId]
}

View File

@ -248,6 +248,7 @@
"text2": "هذا يشمل جميع المهام ولا يمكن التراجع عن هذا الإجراء!",
"success": "تم حذف المشروع بنجاح.",
"tasksToDelete": "سيؤدي هذا إلى حذف ما يقارب {count} من المهام.",
"tasksAndChildProjectsToDelete": "This will irrevocably remove approx. {tasks} tasks and {projects} projects.",
"noTasksToDelete": "هذا المشروع لا يحتوي على أي مهام، يمكن حذفه بشكل آمن."
},
"duplicate": {
@ -380,11 +381,28 @@
"secret": "Secret",
"secretHint": "If provided, all requests to the webhook target URL will be signed using HMAC.",
"secretDocs": "Check out the docs for more details about how to use secrets."
},
"views": {
"header": "Edit views",
"title": "Title",
"actions": "Actions",
"kind": "Kind",
"bucketConfigMode": "Bucket configuration mode",
"bucketConfig": "Bucket configuration",
"bucketConfigManual": "Manual",
"filter": "Filter",
"create": "Create view",
"createSuccess": "The view was created successfully.",
"titleRequired": "Please provide a title.",
"delete": "Delete this view",
"deleteText": "Are you sure you want to remove this view? It will no longer be possible to use it to view tasks in this project. This action won't delete any tasks. This cannot be undone!",
"deleteSuccess": "The view was successfully deleted"
}
},
"filters": {
"title": "الفلاتر",
"clear": "مسح الفلاتر",
"showResults": "Show results",
"attributes": {
"title": "العنوان",
"titlePlaceholder": "عنوان الفلتر المحفوظ هنا…",
@ -415,6 +433,52 @@
"edit": {
"title": "تعديل هذا الفلتر المحفوظ",
"success": "تم حفظ الفلتر بنجاح."
},
"query": {
"title": "Query",
"placeholder": "Type a search or filter query…",
"help": {
"intro": "To filter tasks, you can use a query syntax similar to SQL. The available fields for filtering include:",
"link": "How does this work?",
"canUseDatemath": "You can date math to set relative dates. Click on the date value in a query to find out more.",
"fields": {
"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)"
},
"operators": {
"intro": "The available operators for filtering include:",
"notEqual": "Not equal to",
"equal": "Equal to",
"greaterThan": "Greater than",
"greaterThanOrEqual": "Greater than or equal to",
"lessThan": "Less than",
"lessThanOrEqual": "Less than or equal to",
"like": "Matches a pattern (using wildcard %)",
"in": "Matches any value in a comma-seperated list of values"
},
"logicalOperators": {
"intro": "To combine multiple conditions, you can use the following logical operators:",
"and": "AND operator, matches if all conditions are true",
"or": "OR operator, matches if any of the conditions are true",
"parentheses": "Parentheses for grouping conditions"
},
"examples": {
"intro": "Here are some examples of filter queries:",
"priorityEqual": "Matches tasks with priority level 4",
"dueDatePast": "Matches tasks with a due date in the past",
"undoneHighPriority": "Matches undone tasks with priority level 3 or higher",
"assigneesIn": "Matches tasks assigned to either \"user1\" or \"user2\"",
"priorityOneOrTwoPastDue": "Matches tasks with priority level 1 or 2 and a due date in the past"
}
}
}
},
"migrate": {
@ -584,6 +648,7 @@
"to": "إلى",
"from": "من",
"fromto": "{from} إلى {to}",
"date": "Date",
"ranges": {
"today": "اليوم",
"thisWeek": "هذا الأسبوع",
@ -598,6 +663,27 @@
"lastMonth": "الشهر الماضي",
"thisYear": "هذه السنة",
"restOfThisYear": "المتبقي من هذه السنة"
},
"values": {
"now": "Now",
"startOfToday": "Start of today",
"endOfToday": "End of today",
"beginningOflastWeek": "Beginning of last week",
"endOfLastWeek": "End of last week",
"beginningOfThisWeek": "Beginning of this week",
"endOfThisWeek": "End of this week",
"startOfNextWeek": "Start of next week",
"endOfNextWeek": "End of next week",
"in7Days": "In 7 days",
"beginningOfLastMonth": "Beginning of last month",
"endOfLastMonth": "End of last month",
"startOfThisMonth": "Start of this month",
"endOfThisMonth": "End of this month",
"startOfNextMonth": "Start of next month",
"endOfNextMonth": "End of next month",
"in30Days": "In 30 days",
"startOfThisYear": "Beginning of this year",
"endOfThisYear": "End of this year"
}
},
"datemathHelp": {
@ -916,7 +1002,9 @@
"description": "الوصف",
"descriptionPlaceholder": "Describe the team here, hit '/' for more options…",
"admin": "مشرف",
"member": "عضو"
"member": "عضو",
"isPublic": "Public Team",
"isPublicDescription": "Make the team publicly discoverable. When enabled, anyone can share projects with this team even when not being a direct member."
}
},
"keyboardShortcuts": {
@ -976,7 +1064,8 @@
"newProject": "مشروع جديد",
"createProject": "إنشاء مشروع",
"cantArchiveIsDefault": "You cannot archive this because it is your default project.",
"cantDeleteIsDefault": "You cannot delete this because it is your default project."
"cantDeleteIsDefault": "You cannot delete this because it is your default project.",
"views": "Views"
},
"apiConfig": {
"url": "رابط Vikunja",
@ -1023,6 +1112,12 @@
"altFormatLong": "j M Y H:i",
"altFormatShort": "j M Y"
},
"reaction": {
"reactedWith": "{user} reacted with {value}",
"reactedWithAnd": "{users} and {lastUser} reacted with {value}",
"reactedWithAndMany": "{users} and {num} more reacted reacted with {value}",
"add": "Add your reaction"
},
"error": {
"error": "خطأ",
"success": "تم بنجاح",

View File

@ -248,6 +248,7 @@
"text2": "This includes all tasks and CANNOT BE UNDONE!",
"success": "The project was successfully deleted.",
"tasksToDelete": "This will irrevocably remove approx. {count} tasks.",
"tasksAndChildProjectsToDelete": "This will irrevocably remove approx. {tasks} tasks and {projects} projects.",
"noTasksToDelete": "This project does not contain any tasks, it should be safe to delete."
},
"duplicate": {
@ -380,11 +381,28 @@
"secret": "Secret",
"secretHint": "If provided, all requests to the webhook target URL will be signed using HMAC.",
"secretDocs": "Check out the docs for more details about how to use secrets."
},
"views": {
"header": "Edit views",
"title": "Title",
"actions": "Actions",
"kind": "Kind",
"bucketConfigMode": "Bucket configuration mode",
"bucketConfig": "Bucket configuration",
"bucketConfigManual": "Manual",
"filter": "Filter",
"create": "Create view",
"createSuccess": "The view was created successfully.",
"titleRequired": "Please provide a title.",
"delete": "Delete this view",
"deleteText": "Are you sure you want to remove this view? It will no longer be possible to use it to view tasks in this project. This action won't delete any tasks. This cannot be undone!",
"deleteSuccess": "The view was successfully deleted"
}
},
"filters": {
"title": "Filters",
"clear": "Clear Filters",
"showResults": "Show results",
"attributes": {
"title": "Title",
"titlePlaceholder": "The saved filter title goes here…",
@ -415,6 +433,52 @@
"edit": {
"title": "Edit This Saved Filter",
"success": "The filter was saved successfully."
},
"query": {
"title": "Query",
"placeholder": "Type a search or filter query…",
"help": {
"intro": "To filter tasks, you can use a query syntax similar to SQL. The available fields for filtering include:",
"link": "How does this work?",
"canUseDatemath": "You can date math to set relative dates. Click on the date value in a query to find out more.",
"fields": {
"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)"
},
"operators": {
"intro": "The available operators for filtering include:",
"notEqual": "Not equal to",
"equal": "Equal to",
"greaterThan": "Greater than",
"greaterThanOrEqual": "Greater than or equal to",
"lessThan": "Less than",
"lessThanOrEqual": "Less than or equal to",
"like": "Matches a pattern (using wildcard %)",
"in": "Matches any value in a comma-seperated list of values"
},
"logicalOperators": {
"intro": "To combine multiple conditions, you can use the following logical operators:",
"and": "AND operator, matches if all conditions are true",
"or": "OR operator, matches if any of the conditions are true",
"parentheses": "Parentheses for grouping conditions"
},
"examples": {
"intro": "Here are some examples of filter queries:",
"priorityEqual": "Matches tasks with priority level 4",
"dueDatePast": "Matches tasks with a due date in the past",
"undoneHighPriority": "Matches undone tasks with priority level 3 or higher",
"assigneesIn": "Matches tasks assigned to either \"user1\" or \"user2\"",
"priorityOneOrTwoPastDue": "Matches tasks with priority level 1 or 2 and a due date in the past"
}
}
}
},
"migrate": {
@ -584,6 +648,7 @@
"to": "To",
"from": "From",
"fromto": "{from} to {to}",
"date": "Date",
"ranges": {
"today": "Today",
"thisWeek": "This Week",
@ -598,6 +663,27 @@
"lastMonth": "Last Month",
"thisYear": "This Year",
"restOfThisYear": "The Rest of This Year"
},
"values": {
"now": "Now",
"startOfToday": "Start of today",
"endOfToday": "End of today",
"beginningOflastWeek": "Beginning of last week",
"endOfLastWeek": "End of last week",
"beginningOfThisWeek": "Beginning of this week",
"endOfThisWeek": "End of this week",
"startOfNextWeek": "Start of next week",
"endOfNextWeek": "End of next week",
"in7Days": "In 7 days",
"beginningOfLastMonth": "Beginning of last month",
"endOfLastMonth": "End of last month",
"startOfThisMonth": "Start of this month",
"endOfThisMonth": "End of this month",
"startOfNextMonth": "Start of next month",
"endOfNextMonth": "End of next month",
"in30Days": "In 30 days",
"startOfThisYear": "Beginning of this year",
"endOfThisYear": "End of this year"
}
},
"datemathHelp": {
@ -916,7 +1002,9 @@
"description": "Description",
"descriptionPlaceholder": "Describe the team here, hit '/' for more options…",
"admin": "Admin",
"member": "Member"
"member": "Member",
"isPublic": "Public Team",
"isPublicDescription": "Make the team publicly discoverable. When enabled, anyone can share projects with this team even when not being a direct member."
}
},
"keyboardShortcuts": {
@ -976,7 +1064,8 @@
"newProject": "New project",
"createProject": "Create project",
"cantArchiveIsDefault": "You cannot archive this because it is your default project.",
"cantDeleteIsDefault": "You cannot delete this because it is your default project."
"cantDeleteIsDefault": "You cannot delete this because it is your default project.",
"views": "Views"
},
"apiConfig": {
"url": "Vikunja URL",
@ -1023,6 +1112,12 @@
"altFormatLong": "j M Y H:i",
"altFormatShort": "j M Y"
},
"reaction": {
"reactedWith": "{user} reacted with {value}",
"reactedWithAnd": "{users} and {lastUser} reacted with {value}",
"reactedWithAndMany": "{users} and {num} more reacted reacted with {value}",
"add": "Add your reaction"
},
"error": {
"error": "Error",
"success": "Success",

View File

@ -57,11 +57,11 @@
"logout": "Odhlásit se",
"emailInvalid": "Prosím zadejte platnou emailovou adresu.",
"usernameRequired": "Zadejte prosím uživatelské jméno.",
"usernameMustNotContainSpace": "The username must not contain spaces.",
"usernameMustNotLookLikeUrl": "The username must not look like a URL.",
"usernameMustNotContainSpace": "Uživatelské jméno nesmí obsahovat mezery.",
"usernameMustNotLookLikeUrl": "Uživatelské jméno nesmí vypadat jako adresa URL.",
"passwordRequired": "Zadejte prosím heslo.",
"passwordNotMin": "Password must have at least 8 characters.",
"passwordNotMax": "Password must have at most 250 characters.",
"passwordNotMin": "Heslo musí mít nejméně 8 znaků.",
"passwordNotMax": "Heslo může mít maximálně 250 znaků.",
"showPassword": "Ukázat heslo",
"hidePassword": "Skrýt heslo",
"noAccountYet": "Ještě nemáte účet?",
@ -248,6 +248,7 @@
"text2": "To zahrnuje všechny úkoly a JE TO NEVRATNÉ!",
"success": "Projekt byl úspěšně smazán.",
"tasksToDelete": "Neodvolatelně tím odstraníme asi {count} úloh.",
"tasksAndChildProjectsToDelete": "Chystáte se neodvolatelně odstranit nějaké úkoly (cca {tasks}) a projekty (cca {projects}).",
"noTasksToDelete": "Tento projekt neobsahuje žádné úkoly, mělo by být bezpečné ho smazat."
},
"duplicate": {
@ -380,11 +381,28 @@
"secret": "Tajný klíč",
"secretHint": "Pokud je zadáno, všechny požadavky na cílovou adresu URL webhooku budou podepsány pomocí HMAC.",
"secretDocs": "Další podrobnosti o používání tajných klíčů naleznete v dokumentaci."
},
"views": {
"header": "Edit views",
"title": "Title",
"actions": "Actions",
"kind": "Kind",
"bucketConfigMode": "Bucket configuration mode",
"bucketConfig": "Bucket configuration",
"bucketConfigManual": "Manual",
"filter": "Filter",
"create": "Create view",
"createSuccess": "The view was created successfully.",
"titleRequired": "Please provide a title.",
"delete": "Delete this view",
"deleteText": "Are you sure you want to remove this view? It will no longer be possible to use it to view tasks in this project. This action won't delete any tasks. This cannot be undone!",
"deleteSuccess": "The view was successfully deleted"
}
},
"filters": {
"title": "Filtry",
"clear": "Vymazat filtry",
"showResults": "Zobrazit výsledky",
"attributes": {
"title": "Název",
"titlePlaceholder": "Název uloženého filtru přijde sem…",
@ -415,6 +433,52 @@
"edit": {
"title": "Upravit tento uložený filtr",
"success": "Filtr byl úspěšně uložen."
},
"query": {
"title": "Dotaz",
"placeholder": "Zadejte vyhledávací nebo filtrační dotaz…",
"help": {
"intro": "K filtrování úkolů můžete použít syntaxi dotazů podobnou SQL. Dostupná pole pro filtrování zahrnují:",
"link": "Jak to funguje?",
"canUseDatemath": "Můžete použít matematiku a nastavit relativní data. Pro více informací klikněte na datum v dotazu.",
"fields": {
"done": "Zda je úkol dokončen nebo ne",
"priority": "Úroveň priority úkolu (1-5)",
"percentDone": "Procento dokončení úkolu (0-100)",
"dueDate": "Datum dokončení úkolu",
"startDate": "Datum zahájení úkolu",
"endDate": "Datum dokončení úkolu",
"doneAt": "Datum a čas, kdy byl úkol dokončen",
"assignees": "Přiřazení uživatelé",
"labels": "Štítky přiřazené úkolu",
"project": "Projekt, do kterého úkol patří (k dispozici pouze pro uložené filtry, ne na úrovni projektu)"
},
"operators": {
"intro": "Dostupné operátory pro filtrování zahrnují:",
"notEqual": "Nerovná se",
"equal": "Rovná se",
"greaterThan": "Větší než",
"greaterThanOrEqual": "Větší nebo rovno než",
"lessThan": "Menší než",
"lessThanOrEqual": "Menší nebo rovno než",
"like": "Odpovídá vzoru (s použitím zástupného znaku %)",
"in": "Odpovídá libovolné hodnotě v seznamu hodnot oddělených čárkou"
},
"logicalOperators": {
"intro": "Pro kombinování více podmínek můžete použít tyto logické operátory:",
"and": "AND - shoduje se, pokud jsou splněny všechny podmínky",
"or": "OR - shoduje se, pokud je splněna alespoň jedna podmínka",
"parentheses": "Závorky seskupují podmínky"
},
"examples": {
"intro": "Zde jsou některé příklady filtrovacích dotazů:",
"priorityEqual": "Odpovídá úkolům s úrovní priority 4",
"dueDatePast": "Odpovídá úkolům s termínem dokončení v minulosti",
"undoneHighPriority": "Odpovídá nedokončeným úkolům s úrovní priority 3 nebo vyšší",
"assigneesIn": "Odpovídá úkolům přiřazeným buď \"uživatel1\", nebo \"uživatel2\"",
"priorityOneOrTwoPastDue": "Odpovídá úkolům s prioritou 1 nebo 2 a termínem dokončení v minulosti"
}
}
}
},
"migrate": {
@ -584,6 +648,7 @@
"to": "Do",
"from": "Od",
"fromto": "{from} {to}",
"date": "Datum",
"ranges": {
"today": "Dnes",
"thisWeek": "Tento týden",
@ -598,6 +663,27 @@
"lastMonth": "Minulý měsíc",
"thisYear": "Tento rok",
"restOfThisYear": "Zbytek tohoto roku"
},
"values": {
"now": "Teď",
"startOfToday": "Začátek dnes",
"endOfToday": "Konec dnes",
"beginningOflastWeek": "Začátek minulého týdne",
"endOfLastWeek": "Konec posledního týdne",
"beginningOfThisWeek": "Začátek tohoto týdne",
"endOfThisWeek": "Konec tohoto týdne",
"startOfNextWeek": "Začátek příštího týdne",
"endOfNextWeek": "Konec tohoto týdne",
"in7Days": "Za 7 dní",
"beginningOfLastMonth": "Začátek minulého měsíce",
"endOfLastMonth": "Konec minulého měsíce",
"startOfThisMonth": "Začátek tohoto měsíce",
"endOfThisMonth": "Konec tohoto měsíce",
"startOfNextMonth": "Začátek příštího měsíce",
"endOfNextMonth": "Konec příštího měsíce",
"in30Days": "Za 30 dní",
"startOfThisYear": "Začátek tohoto roku",
"endOfThisYear": "Konec tohoto roku"
}
},
"datemathHelp": {
@ -713,7 +799,7 @@
"startDate": "Počáteční datum",
"title": "Název",
"updated": "Aktualizováno",
"doneAt": "Done At"
"doneAt": "Dokončeno"
},
"subscription": {
"subscribedTaskThroughParentProject": "Zde se nemůžete odhlásit, protože jste přihlášeni k odběru tohoto úkolu prostřednictvím jeho projektu.",
@ -916,7 +1002,9 @@
"description": "Popis",
"descriptionPlaceholder": "Popište tým, stiskněte '/' pro více možností…",
"admin": "Administrátor",
"member": "Člen"
"member": "Člen",
"isPublic": "Veřejný tým",
"isPublicDescription": "Učinit tým veřejně dostupným. Pokud je povoleno, každý může s tímto týmem sdílet projekty, i když není přímým členem."
}
},
"keyboardShortcuts": {
@ -975,8 +1063,9 @@
"share": "Sdílet",
"newProject": "Nový projekt",
"createProject": "Vytvořit projekt",
"cantArchiveIsDefault": "You cannot archive this because it is your default project.",
"cantDeleteIsDefault": "You cannot delete this because it is your default project."
"cantArchiveIsDefault": "Nemůžete archivovat svůj výchozí projekt.",
"cantDeleteIsDefault": "Nemůžete smazat svůj výchozí projekt.",
"views": "Views"
},
"apiConfig": {
"url": "Vikunja URL",
@ -1023,6 +1112,12 @@
"altFormatLong": "j M Y H:i",
"altFormatShort": "j M Y"
},
"reaction": {
"reactedWith": "{user} reacted with {value}",
"reactedWithAnd": "{users} and {lastUser} reacted with {value}",
"reactedWithAndMany": "{users} and {num} more reacted reacted with {value}",
"add": "Add your reaction"
},
"error": {
"error": "Chyba",
"success": "Úspěch",
@ -1096,7 +1191,7 @@
},
"about": {
"title": "O aplikaci",
"version": "Version: {version}"
"version": "Verze: {version}"
},
"time": {
"units": {

View File

@ -248,6 +248,7 @@
"text2": "This includes all tasks and CANNOT BE UNDONE!",
"success": "The project was successfully deleted.",
"tasksToDelete": "This will irrevocably remove approx. {count} tasks.",
"tasksAndChildProjectsToDelete": "This will irrevocably remove approx. {tasks} tasks and {projects} projects.",
"noTasksToDelete": "This project does not contain any tasks, it should be safe to delete."
},
"duplicate": {
@ -380,11 +381,28 @@
"secret": "Secret",
"secretHint": "If provided, all requests to the webhook target URL will be signed using HMAC.",
"secretDocs": "Check out the docs for more details about how to use secrets."
},
"views": {
"header": "Edit views",
"title": "Title",
"actions": "Actions",
"kind": "Kind",
"bucketConfigMode": "Bucket configuration mode",
"bucketConfig": "Bucket configuration",
"bucketConfigManual": "Manual",
"filter": "Filter",
"create": "Create view",
"createSuccess": "The view was created successfully.",
"titleRequired": "Please provide a title.",
"delete": "Delete this view",
"deleteText": "Are you sure you want to remove this view? It will no longer be possible to use it to view tasks in this project. This action won't delete any tasks. This cannot be undone!",
"deleteSuccess": "The view was successfully deleted"
}
},
"filters": {
"title": "Filtre",
"clear": "Ryd Filtre",
"showResults": "Show results",
"attributes": {
"title": "Titel",
"titlePlaceholder": "Det gemte filters titel skrives her…",
@ -415,6 +433,52 @@
"edit": {
"title": "Rediger Dette Gemte Filter",
"success": "Filteret blev slettet."
},
"query": {
"title": "Query",
"placeholder": "Type a search or filter query…",
"help": {
"intro": "To filter tasks, you can use a query syntax similar to SQL. The available fields for filtering include:",
"link": "How does this work?",
"canUseDatemath": "You can date math to set relative dates. Click on the date value in a query to find out more.",
"fields": {
"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)"
},
"operators": {
"intro": "The available operators for filtering include:",
"notEqual": "Not equal to",
"equal": "Equal to",
"greaterThan": "Greater than",
"greaterThanOrEqual": "Greater than or equal to",
"lessThan": "Less than",
"lessThanOrEqual": "Less than or equal to",
"like": "Matches a pattern (using wildcard %)",
"in": "Matches any value in a comma-seperated list of values"
},
"logicalOperators": {
"intro": "To combine multiple conditions, you can use the following logical operators:",
"and": "AND operator, matches if all conditions are true",
"or": "OR operator, matches if any of the conditions are true",
"parentheses": "Parentheses for grouping conditions"
},
"examples": {
"intro": "Here are some examples of filter queries:",
"priorityEqual": "Matches tasks with priority level 4",
"dueDatePast": "Matches tasks with a due date in the past",
"undoneHighPriority": "Matches undone tasks with priority level 3 or higher",
"assigneesIn": "Matches tasks assigned to either \"user1\" or \"user2\"",
"priorityOneOrTwoPastDue": "Matches tasks with priority level 1 or 2 and a due date in the past"
}
}
}
},
"migrate": {
@ -584,6 +648,7 @@
"to": "Til",
"from": "Fra",
"fromto": "{from} til {to}",
"date": "Date",
"ranges": {
"today": "I dag",
"thisWeek": "Denne uge",
@ -598,6 +663,27 @@
"lastMonth": "Sidste måned",
"thisYear": "Dette år",
"restOfThisYear": "Resten af dette år"
},
"values": {
"now": "Now",
"startOfToday": "Start of today",
"endOfToday": "End of today",
"beginningOflastWeek": "Beginning of last week",
"endOfLastWeek": "End of last week",
"beginningOfThisWeek": "Beginning of this week",
"endOfThisWeek": "End of this week",
"startOfNextWeek": "Start of next week",
"endOfNextWeek": "End of next week",
"in7Days": "In 7 days",
"beginningOfLastMonth": "Beginning of last month",
"endOfLastMonth": "End of last month",
"startOfThisMonth": "Start of this month",
"endOfThisMonth": "End of this month",
"startOfNextMonth": "Start of next month",
"endOfNextMonth": "End of next month",
"in30Days": "In 30 days",
"startOfThisYear": "Beginning of this year",
"endOfThisYear": "End of this year"
}
},
"datemathHelp": {
@ -916,7 +1002,9 @@
"description": "Beskrivelse",
"descriptionPlaceholder": "Describe the team here, hit '/' for more options…",
"admin": "Administrator",
"member": "Medlem"
"member": "Medlem",
"isPublic": "Public Team",
"isPublicDescription": "Make the team publicly discoverable. When enabled, anyone can share projects with this team even when not being a direct member."
}
},
"keyboardShortcuts": {
@ -976,7 +1064,8 @@
"newProject": "New project",
"createProject": "Create project",
"cantArchiveIsDefault": "You cannot archive this because it is your default project.",
"cantDeleteIsDefault": "You cannot delete this because it is your default project."
"cantDeleteIsDefault": "You cannot delete this because it is your default project.",
"views": "Views"
},
"apiConfig": {
"url": "Vikunja URL",
@ -1023,6 +1112,12 @@
"altFormatLong": "d m Y H:i",
"altFormatShort": "j M Y"
},
"reaction": {
"reactedWith": "{user} reacted with {value}",
"reactedWithAnd": "{users} and {lastUser} reacted with {value}",
"reactedWithAndMany": "{users} and {num} more reacted reacted with {value}",
"add": "Add your reaction"
},
"error": {
"error": "Fejl",
"success": "Succes",

View File

@ -248,6 +248,7 @@
"text2": "Dies umfasst alle Aufgaben und kann NICHT rückgängig gemacht werden!",
"success": "Das Projekt wurde erfolgreich gelöscht.",
"tasksToDelete": "Dies löscht unwiderruflich ca. {count} Aufgaben.",
"tasksAndChildProjectsToDelete": "Dies löscht unwiderruflich ca. {tasks} Aufgaben und {projects} Projekte.",
"noTasksToDelete": "Dieses Projekt enthält keine Aufgaben, es kann sicher gelöscht werden."
},
"duplicate": {
@ -380,11 +381,28 @@
"secret": "Schlüssel",
"secretHint": "Wenn angegeben, werden alle Anfragen an die Webhook Ziel-URL mit HMAC signiert.",
"secretDocs": "In der Dokumentation findest du weitere Informationen zum Umgang mit Schlüsseln."
},
"views": {
"header": "Edit views",
"title": "Title",
"actions": "Actions",
"kind": "Kind",
"bucketConfigMode": "Bucket configuration mode",
"bucketConfig": "Bucket configuration",
"bucketConfigManual": "Manual",
"filter": "Filter",
"create": "Create view",
"createSuccess": "The view was created successfully.",
"titleRequired": "Please provide a title.",
"delete": "Delete this view",
"deleteText": "Are you sure you want to remove this view? It will no longer be possible to use it to view tasks in this project. This action won't delete any tasks. This cannot be undone!",
"deleteSuccess": "The view was successfully deleted"
}
},
"filters": {
"title": "Filter",
"clear": "Filter zurücksetzen",
"showResults": "Ergebnisse anzeigen",
"attributes": {
"title": "Titel",
"titlePlaceholder": "Einen gespeicherten Filternamen eingeben …",
@ -415,6 +433,52 @@
"edit": {
"title": "Diesen gespeicherten Filter bearbeiten",
"success": "Filter gespeichert."
},
"query": {
"title": "Abfrage",
"placeholder": "Gib eine Suche oder Filterabfrage ein…",
"help": {
"intro": "Um Aufgaben zu filtern, kannst du eine SQL-ähnliche Abfragesyntax verwenden. Du kannst die folgenden Felder in deinem Filter verwenden:",
"link": "Wie funktioniert das?",
"canUseDatemath": "Du kannst Date Math verwenden, um relative Daten festzulegen. Klicke auf den Datumswert in einer Abfrage, um mehr zu erfahren.",
"fields": {
"done": "Ob die Aufgabe erledigt ist oder nicht",
"priority": "Die Priorität der Aufgabe (1-5)",
"percentDone": "Prozentsatz der Fertigstellung der Aufgabe (0-100)",
"dueDate": "Das Fälligkeitsdatum der Aufgabe",
"startDate": "Das Startdatum der Aufgabe",
"endDate": "Das Enddatum der Aufgabe",
"doneAt": "Datum und Uhrzeit, an dem die Aufgabe als erledigt markiert wurde",
"assignees": "Die der Aufgabe Zugewiesenen",
"labels": "Die der Aufgabe zugeordneten Labels",
"project": "Das Projekt, zu dem die Aufgabe gehört (nur verfügbar für gespeicherte Filter, nicht auf Projektebene)"
},
"operators": {
"intro": "Die verfügbaren Operatoren für die Filterung sind:",
"notEqual": "Ungleich",
"equal": "Gleich",
"greaterThan": "Größer als",
"greaterThanOrEqual": "Größer oder gleich",
"lessThan": "Kleiner als",
"lessThanOrEqual": "Kleiner als oder gleich",
"like": "Vergleicht zu einem Muster (mit Platzhalter %)",
"in": "Filtert einen beliebigen Wert in einer kommaseparierten Liste"
},
"logicalOperators": {
"intro": "Um mehrere Bedingungen zu kombinieren, kannst du folgende logische Operatoren verwenden:",
"and": "UND Operator, stimmt überein, wenn alle Bedingungen wahr sind",
"or": "ODER Operator, stimmt überein, wenn eine der Bedingungen wahr ist",
"parentheses": "Klammern zum Gruppieren von Bedingungen"
},
"examples": {
"intro": "Hier sind einige Beispiele für Filterabfragen:",
"priorityEqual": "Findet Aufgaben mit Priorität Level 4",
"dueDatePast": "Findet Aufgaben mit einem Fälligkeitsdatum in der Vergangenheit",
"undoneHighPriority": "Findet Aufgaben, die nicht erledigt sind und Priorität Level mindestens 3 haben",
"assigneesIn": "Findet Aufgaben, die entweder \"user1\" oder \"user2\" zugewiesen sind",
"priorityOneOrTwoPastDue": "Findet Aufgaben mit Priorität Level 1 oder 2 und einem Fälligkeitsdatum in der Vergangenheit"
}
}
}
},
"migrate": {
@ -584,6 +648,7 @@
"to": "Bis",
"from": "Von",
"fromto": "{from} bis {to}",
"date": "Datum",
"ranges": {
"today": "Heute",
"thisWeek": "Diese Woche",
@ -598,6 +663,27 @@
"lastMonth": "Letzter Monat",
"thisYear": "Dieses Jahr",
"restOfThisYear": "Der Rest des Jahres"
},
"values": {
"now": "Jetzt",
"startOfToday": "Beginn des heutigen Tages",
"endOfToday": "Ende des heutigen Tages",
"beginningOflastWeek": "Beginn der letzten Woche",
"endOfLastWeek": "Ende der letzten Woche",
"beginningOfThisWeek": "Beginn dieser Woche",
"endOfThisWeek": "Ende dieser Woche",
"startOfNextWeek": "Beginn der nächsten Woche",
"endOfNextWeek": "Ende der nächsten Woche",
"in7Days": "In 7 Tagen",
"beginningOfLastMonth": "Beginn des letzten Monats",
"endOfLastMonth": "Ende des letzten Monats",
"startOfThisMonth": "Beginn diesen Monats",
"endOfThisMonth": "Ende diesen Monats",
"startOfNextMonth": "Beginn des nächsten Monats",
"endOfNextMonth": "Ende des nächsten Monats",
"in30Days": "In 30 Tagen",
"startOfThisYear": "Beginn dieses Jahres",
"endOfThisYear": "Ende dieses Jahres"
}
},
"datemathHelp": {
@ -916,7 +1002,9 @@
"description": "Beschreibung",
"descriptionPlaceholder": "Gib eine Beschreibung für dieses Team ein, drücke '/' für mehr Optionen…",
"admin": "Admin",
"member": "Mitglied"
"member": "Mitglied",
"isPublic": "Öffentliches Team",
"isPublicDescription": "Machs das Team öffentlich sichtbar. Wenn aktiviert, kann jede:r mit diesem Team Projekte teilen, auch wenn er oder sie kein direktes Mitglied des Teams ist."
}
},
"keyboardShortcuts": {
@ -976,7 +1064,8 @@
"newProject": "Neues Projekt",
"createProject": "Projekt erstellen",
"cantArchiveIsDefault": "Du kannst dieses Projekt nicht archivieren, da es dein Standardprojekt ist.",
"cantDeleteIsDefault": "Du kannst dieses Projekt nicht löschen, da es dein Standardprojekt ist."
"cantDeleteIsDefault": "Du kannst dieses Projekt nicht löschen, da es dein Standardprojekt ist.",
"views": "Views"
},
"apiConfig": {
"url": "Vikunja-URL",
@ -1023,6 +1112,12 @@
"altFormatLong": "j M Y H:i",
"altFormatShort": "j M Y"
},
"reaction": {
"reactedWith": "{user} hat mit {value} reagiert",
"reactedWithAnd": "{users} und {lastUser} haben mit {value} reagiert",
"reactedWithAndMany": "{users} und {num} weitere haben mit {value} reagiert",
"add": "Reaktion hinzufügen"
},
"error": {
"error": "Fehler",
"success": "Erfolgreich",

View File

@ -248,6 +248,7 @@
"text2": "Dies umfasst alle Aufgaben und kann NICHT rückgängig gemacht werden!",
"success": "Das Projekt wurde erfolgreich gelöscht.",
"tasksToDelete": "Dies löscht unwiderruflich ca. {count} Aufgaben.",
"tasksAndChildProjectsToDelete": "Dies löscht unwiderruflich ca. {tasks} Aufgaben und {projects} Projekte.",
"noTasksToDelete": "Dieses Projekt enthält keine Aufgaben, es kann sicher gelöscht werden."
},
"duplicate": {
@ -380,11 +381,28 @@
"secret": "Schlüssel",
"secretHint": "Wenn angegeben, werden alle Anfragen an die Webhook Ziel-URL mit HMAC signiert.",
"secretDocs": "In der Dokumentation findest du weitere Informationen zum Umgang mit Schlüsseln."
},
"views": {
"header": "Edit views",
"title": "Title",
"actions": "Actions",
"kind": "Kind",
"bucketConfigMode": "Bucket configuration mode",
"bucketConfig": "Bucket configuration",
"bucketConfigManual": "Manual",
"filter": "Filter",
"create": "Create view",
"createSuccess": "The view was created successfully.",
"titleRequired": "Please provide a title.",
"delete": "Delete this view",
"deleteText": "Are you sure you want to remove this view? It will no longer be possible to use it to view tasks in this project. This action won't delete any tasks. This cannot be undone!",
"deleteSuccess": "The view was successfully deleted"
}
},
"filters": {
"title": "Filter",
"clear": "Filter zurücksetzen",
"showResults": "Ergebnisse anzeigen",
"attributes": {
"title": "Titl",
"titlePlaceholder": "De Name für de g'speicheret Filter chunt da ahne…",
@ -415,6 +433,52 @@
"edit": {
"title": "De g'speicheret Filter bearbeite",
"success": "De filter isch erfolgriich g'speichered wore."
},
"query": {
"title": "Abfrage",
"placeholder": "Gib eine Suche oder Filterabfrage ein…",
"help": {
"intro": "Um Aufgaben zu filtern, kannst du eine SQL-ähnliche Abfragesyntax verwenden. Du kannst die folgenden Felder in deinem Filter verwenden:",
"link": "Wie funktioniert das?",
"canUseDatemath": "Du kannst Date Math verwenden, um relative Daten festzulegen. Klicke auf den Datumswert in einer Abfrage, um mehr zu erfahren.",
"fields": {
"done": "Ob die Aufgabe erledigt ist oder nicht",
"priority": "Die Priorität der Aufgabe (1-5)",
"percentDone": "Prozentsatz der Fertigstellung der Aufgabe (0-100)",
"dueDate": "Das Fälligkeitsdatum der Aufgabe",
"startDate": "Das Startdatum der Aufgabe",
"endDate": "Das Enddatum der Aufgabe",
"doneAt": "Datum und Uhrzeit, an dem die Aufgabe als erledigt markiert wurde",
"assignees": "Die der Aufgabe Zugewiesenen",
"labels": "Die der Aufgabe zugeordneten Labels",
"project": "Das Projekt, zu dem die Aufgabe gehört (nur verfügbar für gespeicherte Filter, nicht auf Projektebene)"
},
"operators": {
"intro": "Die verfügbaren Operatoren für die Filterung sind:",
"notEqual": "Ungleich",
"equal": "Gleich",
"greaterThan": "Größer als",
"greaterThanOrEqual": "Größer oder gleich",
"lessThan": "Kleiner als",
"lessThanOrEqual": "Kleiner als oder gleich",
"like": "Vergleicht zu einem Muster (mit Platzhalter %)",
"in": "Filtert einen beliebigen Wert in einer kommaseparierten Liste"
},
"logicalOperators": {
"intro": "Um mehrere Bedingungen zu kombinieren, kannst du folgende logische Operatoren verwenden:",
"and": "UND Operator, stimmt überein, wenn alle Bedingungen wahr sind",
"or": "ODER Operator, stimmt überein, wenn eine der Bedingungen wahr ist",
"parentheses": "Klammern zum Gruppieren von Bedingungen"
},
"examples": {
"intro": "Hier sind einige Beispiele für Filterabfragen:",
"priorityEqual": "Findet Aufgaben mit Priorität Level 4",
"dueDatePast": "Findet Aufgaben mit einem Fälligkeitsdatum in der Vergangenheit",
"undoneHighPriority": "Findet Aufgaben, die nicht erledigt sind und Priorität Level mindestens 3 haben",
"assigneesIn": "Findet Aufgaben, die entweder \"user1\" oder \"user2\" zugewiesen sind",
"priorityOneOrTwoPastDue": "Findet Aufgaben mit Priorität Level 1 oder 2 und einem Fälligkeitsdatum in der Vergangenheit"
}
}
}
},
"migrate": {
@ -584,6 +648,7 @@
"to": "Bis",
"from": "Von",
"fromto": "{from} bis {to}",
"date": "Datum",
"ranges": {
"today": "Heute",
"thisWeek": "Diese Woche",
@ -598,6 +663,27 @@
"lastMonth": "Letzter Monat",
"thisYear": "Dieses Jahr",
"restOfThisYear": "Der Rest des Jahres"
},
"values": {
"now": "Jetzt",
"startOfToday": "Beginn des heutigen Tages",
"endOfToday": "Ende des heutigen Tages",
"beginningOflastWeek": "Beginn der letzten Woche",
"endOfLastWeek": "Ende der letzten Woche",
"beginningOfThisWeek": "Beginn dieser Woche",
"endOfThisWeek": "Ende dieser Woche",
"startOfNextWeek": "Beginn der nächsten Woche",
"endOfNextWeek": "Ende der nächsten Woche",
"in7Days": "In 7 Tagen",
"beginningOfLastMonth": "Beginn des letzten Monats",
"endOfLastMonth": "Ende des letzten Monats",
"startOfThisMonth": "Beginn diesen Monats",
"endOfThisMonth": "Ende diesen Monats",
"startOfNextMonth": "Beginn des nächsten Monats",
"endOfNextMonth": "Ende des nächsten Monats",
"in30Days": "In 30 Tagen",
"startOfThisYear": "Beginn dieses Jahres",
"endOfThisYear": "Ende dieses Jahres"
}
},
"datemathHelp": {
@ -916,7 +1002,9 @@
"description": "Beschriibig",
"descriptionPlaceholder": "Gib eine Beschreibung für dieses Team ein, drücke '/' für mehr Optionen…",
"admin": "Chef",
"member": "Mitglied"
"member": "Mitglied",
"isPublic": "Öffentliches Team",
"isPublicDescription": "Machs das Team öffentlich sichtbar. Wenn aktiviert, kann jede:r mit diesem Team Projekte teilen, auch wenn er oder sie kein direktes Mitglied des Teams ist."
}
},
"keyboardShortcuts": {
@ -976,7 +1064,8 @@
"newProject": "Neues Projekt",
"createProject": "Projekt erstellen",
"cantArchiveIsDefault": "Du kannst dieses Projekt nicht archivieren, da es dein Standardprojekt ist.",
"cantDeleteIsDefault": "Du kannst dieses Projekt nicht löschen, da es dein Standardprojekt ist."
"cantDeleteIsDefault": "Du kannst dieses Projekt nicht löschen, da es dein Standardprojekt ist.",
"views": "Views"
},
"apiConfig": {
"url": "Vikunja URL",
@ -1023,6 +1112,12 @@
"altFormatLong": "j M Y H:i",
"altFormatShort": "j M Y"
},
"reaction": {
"reactedWith": "{user} hat mit {value} reagiert",
"reactedWithAnd": "{users} und {lastUser} haben mit {value} reagiert",
"reactedWithAndMany": "{users} und {num} weitere haben mit {value} reagiert",
"add": "Reaktion hinzufügen"
},
"error": {
"error": "Fähler",
"success": "Erfolg",

View File

@ -248,6 +248,7 @@
"text2": "This includes all tasks and CANNOT BE UNDONE!",
"success": "The project was successfully deleted.",
"tasksToDelete": "This will irrevocably remove approx. {count} tasks.",
"tasksAndChildProjectsToDelete": "This will irrevocably remove approx. {tasks} tasks and {projects} projects.",
"noTasksToDelete": "This project does not contain any tasks, it should be safe to delete."
},
"duplicate": {
@ -380,11 +381,28 @@
"secret": "Secret",
"secretHint": "If provided, all requests to the webhook target URL will be signed using HMAC.",
"secretDocs": "Check out the docs for more details about how to use secrets."
},
"views": {
"header": "Edit views",
"title": "Title",
"actions": "Actions",
"kind": "Kind",
"bucketConfigMode": "Bucket configuration mode",
"bucketConfig": "Bucket configuration",
"bucketConfigManual": "Manual",
"filter": "Filter",
"create": "Create view",
"createSuccess": "The view was created successfully.",
"titleRequired": "Please provide a title.",
"delete": "Delete this view",
"deleteText": "Are you sure you want to remove this view? It will no longer be possible to use it to view tasks in this project. This action won't delete any tasks. This cannot be undone!",
"deleteSuccess": "The view was successfully deleted"
}
},
"filters": {
"title": "Filters",
"clear": "Clear Filters",
"showResults": "Show results",
"attributes": {
"title": "Title",
"titlePlaceholder": "The saved filter title goes here…",
@ -415,6 +433,52 @@
"edit": {
"title": "Edit This Saved Filter",
"success": "The filter was saved successfully."
},
"query": {
"title": "Query",
"placeholder": "Type a search or filter query…",
"help": {
"intro": "To filter tasks, you can use a query syntax similar to SQL. The available fields for filtering include:",
"link": "How does this work?",
"canUseDatemath": "You can date math to set relative dates. Click on the date value in a query to find out more.",
"fields": {
"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)"
},
"operators": {
"intro": "The available operators for filtering include:",
"notEqual": "Not equal to",
"equal": "Equal to",
"greaterThan": "Greater than",
"greaterThanOrEqual": "Greater than or equal to",
"lessThan": "Less than",
"lessThanOrEqual": "Less than or equal to",
"like": "Matches a pattern (using wildcard %)",
"in": "Matches any value in a comma-seperated list of values"
},
"logicalOperators": {
"intro": "To combine multiple conditions, you can use the following logical operators:",
"and": "AND operator, matches if all conditions are true",
"or": "OR operator, matches if any of the conditions are true",
"parentheses": "Parentheses for grouping conditions"
},
"examples": {
"intro": "Here are some examples of filter queries:",
"priorityEqual": "Matches tasks with priority level 4",
"dueDatePast": "Matches tasks with a due date in the past",
"undoneHighPriority": "Matches undone tasks with priority level 3 or higher",
"assigneesIn": "Matches tasks assigned to either \"user1\" or \"user2\"",
"priorityOneOrTwoPastDue": "Matches tasks with priority level 1 or 2 and a due date in the past"
}
}
}
},
"migrate": {
@ -585,23 +649,42 @@
"to": "To",
"from": "From",
"fromto": "{from} to {to}",
"date": "Date",
"ranges": {
"today": "Today",
"thisWeek": "This Week",
"restOfThisWeek": "The Rest of This Week",
"nextWeek": "Next Week",
"next7Days": "Next 7 Days",
"lastWeek": "Last Week",
"thisMonth": "This Month",
"restOfThisMonth": "The Rest of This Month",
"nextMonth": "Next Month",
"next30Days": "Next 30 Days",
"lastMonth": "Last Month",
"thisYear": "This Year",
"restOfThisYear": "The Rest of This Year"
},
"values": {
"now": "Now",
"startOfToday": "Start of today",
"endOfToday": "End of today",
"beginningOflastWeek": "Beginning of last week",
"endOfLastWeek": "End of last week",
"beginningOfThisWeek": "Beginning of this week",
"endOfThisWeek": "End of this week",
"startOfNextWeek": "Start of next week",
"endOfNextWeek": "End of next week",
"in7Days": "In 7 days",
"beginningOfLastMonth": "Beginning of last month",
"endOfLastMonth": "End of last month",
"startOfThisMonth": "Start of this month",
"endOfThisMonth": "End of this month",
"startOfNextMonth": "Start of next month",
"endOfNextMonth": "End of next month",
"in30Days": "In 30 days",
"startOfThisYear": "Beginning of this year",
"endOfThisYear": "End of this year"
}
},
"datemathHelp": {
@ -920,7 +1003,9 @@
"description": "Description",
"descriptionPlaceholder": "Describe the team here, hit '/' for more options…",
"admin": "Admin",
"member": "Member"
"member": "Member",
"isPublic": "Public Team",
"isPublicDescription": "Make the team publicly discoverable. When enabled, anyone can share projects with this team even when not being a direct member."
}
},
"keyboardShortcuts": {
@ -980,7 +1065,8 @@
"newProject": "New project",
"createProject": "Create project",
"cantArchiveIsDefault": "You cannot archive this because it is your default project.",
"cantDeleteIsDefault": "You cannot delete this because it is your default project."
"cantDeleteIsDefault": "You cannot delete this because it is your default project.",
"views": "Views"
},
"apiConfig": {
"url": "Vikunja URL",
@ -1027,6 +1113,12 @@
"altFormatLong": "j M Y H:i",
"altFormatShort": "j M Y"
},
"reaction": {
"reactedWith": "{user} reacted with {value}",
"reactedWithAnd": "{users} and {lastUser} reacted with {value}",
"reactedWithAndMany": "{users} and {num} more reacted reacted with {value}",
"add": "Add your reaction"
},
"error": {
"error": "Error",
"success": "Success",

View File

@ -248,6 +248,7 @@
"text2": "This includes all tasks and CANNOT BE UNDONE!",
"success": "The project was successfully deleted.",
"tasksToDelete": "This will irrevocably remove approx. {count} tasks.",
"tasksAndChildProjectsToDelete": "This will irrevocably remove approx. {tasks} tasks and {projects} projects.",
"noTasksToDelete": "This project does not contain any tasks, it should be safe to delete."
},
"duplicate": {
@ -380,11 +381,28 @@
"secret": "Secret",
"secretHint": "If provided, all requests to the webhook target URL will be signed using HMAC.",
"secretDocs": "Check out the docs for more details about how to use secrets."
},
"views": {
"header": "Edit views",
"title": "Title",
"actions": "Actions",
"kind": "Kind",
"bucketConfigMode": "Bucket configuration mode",
"bucketConfig": "Bucket configuration",
"bucketConfigManual": "Manual",
"filter": "Filter",
"create": "Create view",
"createSuccess": "The view was created successfully.",
"titleRequired": "Please provide a title.",
"delete": "Delete this view",
"deleteText": "Are you sure you want to remove this view? It will no longer be possible to use it to view tasks in this project. This action won't delete any tasks. This cannot be undone!",
"deleteSuccess": "The view was successfully deleted"
}
},
"filters": {
"title": "Filters",
"clear": "Clear Filters",
"showResults": "Show results",
"attributes": {
"title": "Title",
"titlePlaceholder": "The saved filter title goes here…",
@ -415,6 +433,52 @@
"edit": {
"title": "Edit This Saved Filter",
"success": "The filter was saved successfully."
},
"query": {
"title": "Query",
"placeholder": "Type a search or filter query…",
"help": {
"intro": "To filter tasks, you can use a query syntax similar to SQL. The available fields for filtering include:",
"link": "How does this work?",
"canUseDatemath": "You can date math to set relative dates. Click on the date value in a query to find out more.",
"fields": {
"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)"
},
"operators": {
"intro": "The available operators for filtering include:",
"notEqual": "Not equal to",
"equal": "Equal to",
"greaterThan": "Greater than",
"greaterThanOrEqual": "Greater than or equal to",
"lessThan": "Less than",
"lessThanOrEqual": "Less than or equal to",
"like": "Matches a pattern (using wildcard %)",
"in": "Matches any value in a comma-seperated list of values"
},
"logicalOperators": {
"intro": "To combine multiple conditions, you can use the following logical operators:",
"and": "AND operator, matches if all conditions are true",
"or": "OR operator, matches if any of the conditions are true",
"parentheses": "Parentheses for grouping conditions"
},
"examples": {
"intro": "Here are some examples of filter queries:",
"priorityEqual": "Matches tasks with priority level 4",
"dueDatePast": "Matches tasks with a due date in the past",
"undoneHighPriority": "Matches undone tasks with priority level 3 or higher",
"assigneesIn": "Matches tasks assigned to either \"user1\" or \"user2\"",
"priorityOneOrTwoPastDue": "Matches tasks with priority level 1 or 2 and a due date in the past"
}
}
}
},
"migrate": {
@ -584,6 +648,7 @@
"to": "To",
"from": "From",
"fromto": "{from} to {to}",
"date": "Date",
"ranges": {
"today": "Today",
"thisWeek": "This Week",
@ -598,6 +663,27 @@
"lastMonth": "Last Month",
"thisYear": "This Year",
"restOfThisYear": "The Rest of This Year"
},
"values": {
"now": "Now",
"startOfToday": "Start of today",
"endOfToday": "End of today",
"beginningOflastWeek": "Beginning of last week",
"endOfLastWeek": "End of last week",
"beginningOfThisWeek": "Beginning of this week",
"endOfThisWeek": "End of this week",
"startOfNextWeek": "Start of next week",
"endOfNextWeek": "End of next week",
"in7Days": "In 7 days",
"beginningOfLastMonth": "Beginning of last month",
"endOfLastMonth": "End of last month",
"startOfThisMonth": "Start of this month",
"endOfThisMonth": "End of this month",
"startOfNextMonth": "Start of next month",
"endOfNextMonth": "End of next month",
"in30Days": "In 30 days",
"startOfThisYear": "Beginning of this year",
"endOfThisYear": "End of this year"
}
},
"datemathHelp": {
@ -916,7 +1002,9 @@
"description": "Description",
"descriptionPlaceholder": "Describe the team here, hit '/' for more options…",
"admin": "Admin",
"member": "Member"
"member": "Member",
"isPublic": "Public Team",
"isPublicDescription": "Make the team publicly discoverable. When enabled, anyone can share projects with this team even when not being a direct member."
}
},
"keyboardShortcuts": {
@ -976,7 +1064,8 @@
"newProject": "New project",
"createProject": "Create project",
"cantArchiveIsDefault": "You cannot archive this because it is your default project.",
"cantDeleteIsDefault": "You cannot delete this because it is your default project."
"cantDeleteIsDefault": "You cannot delete this because it is your default project.",
"views": "Views"
},
"apiConfig": {
"url": "Vikunja URL",
@ -1023,6 +1112,12 @@
"altFormatLong": "j M Y H:i",
"altFormatShort": "j M Y"
},
"reaction": {
"reactedWith": "{user} reacted with {value}",
"reactedWithAnd": "{users} and {lastUser} reacted with {value}",
"reactedWithAndMany": "{users} and {num} more reacted reacted with {value}",
"add": "Add your reaction"
},
"error": {
"error": "Error",
"success": "Success",

View File

@ -248,6 +248,7 @@
"text2": "¡Esto incluye todas las tareas y NO SE PUEDE DESHACER!",
"success": "El proyecto se eliminó con éxito.",
"tasksToDelete": "Esto eliminará de forma definitiva aprox. {count} tareas.",
"tasksAndChildProjectsToDelete": "This will irrevocably remove approx. {tasks} tasks and {projects} projects.",
"noTasksToDelete": "Este proyecto no contiene tareas. Debería ser seguro eliminarlo."
},
"duplicate": {
@ -380,11 +381,28 @@
"secret": "Secret",
"secretHint": "If provided, all requests to the webhook target URL will be signed using HMAC.",
"secretDocs": "Check out the docs for more details about how to use secrets."
},
"views": {
"header": "Edit views",
"title": "Title",
"actions": "Actions",
"kind": "Kind",
"bucketConfigMode": "Bucket configuration mode",
"bucketConfig": "Bucket configuration",
"bucketConfigManual": "Manual",
"filter": "Filter",
"create": "Create view",
"createSuccess": "The view was created successfully.",
"titleRequired": "Please provide a title.",
"delete": "Delete this view",
"deleteText": "Are you sure you want to remove this view? It will no longer be possible to use it to view tasks in this project. This action won't delete any tasks. This cannot be undone!",
"deleteSuccess": "The view was successfully deleted"
}
},
"filters": {
"title": "Filtros",
"clear": "Limpiar Filtros",
"showResults": "Show results",
"attributes": {
"title": "Título",
"titlePlaceholder": "El título del filtro guardado va aquí…",
@ -415,6 +433,52 @@
"edit": {
"title": "Editar este Filtro Guardado",
"success": "El filtro se guardó con éxito."
},
"query": {
"title": "Query",
"placeholder": "Type a search or filter query…",
"help": {
"intro": "To filter tasks, you can use a query syntax similar to SQL. The available fields for filtering include:",
"link": "How does this work?",
"canUseDatemath": "You can date math to set relative dates. Click on the date value in a query to find out more.",
"fields": {
"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)"
},
"operators": {
"intro": "The available operators for filtering include:",
"notEqual": "Not equal to",
"equal": "Equal to",
"greaterThan": "Greater than",
"greaterThanOrEqual": "Greater than or equal to",
"lessThan": "Less than",
"lessThanOrEqual": "Less than or equal to",
"like": "Matches a pattern (using wildcard %)",
"in": "Matches any value in a comma-seperated list of values"
},
"logicalOperators": {
"intro": "To combine multiple conditions, you can use the following logical operators:",
"and": "AND operator, matches if all conditions are true",
"or": "OR operator, matches if any of the conditions are true",
"parentheses": "Parentheses for grouping conditions"
},
"examples": {
"intro": "Here are some examples of filter queries:",
"priorityEqual": "Matches tasks with priority level 4",
"dueDatePast": "Matches tasks with a due date in the past",
"undoneHighPriority": "Matches undone tasks with priority level 3 or higher",
"assigneesIn": "Matches tasks assigned to either \"user1\" or \"user2\"",
"priorityOneOrTwoPastDue": "Matches tasks with priority level 1 or 2 and a due date in the past"
}
}
}
},
"migrate": {
@ -584,6 +648,7 @@
"to": "Para",
"from": "De",
"fromto": "De {from} para {to}",
"date": "Date",
"ranges": {
"today": "Hoy",
"thisWeek": "Esta Semana",
@ -598,6 +663,27 @@
"lastMonth": "El Mes Pasado",
"thisYear": "Este Año",
"restOfThisYear": "El Resto del Año"
},
"values": {
"now": "Now",
"startOfToday": "Start of today",
"endOfToday": "End of today",
"beginningOflastWeek": "Beginning of last week",
"endOfLastWeek": "End of last week",
"beginningOfThisWeek": "Beginning of this week",
"endOfThisWeek": "End of this week",
"startOfNextWeek": "Start of next week",
"endOfNextWeek": "End of next week",
"in7Days": "In 7 days",
"beginningOfLastMonth": "Beginning of last month",
"endOfLastMonth": "End of last month",
"startOfThisMonth": "Start of this month",
"endOfThisMonth": "End of this month",
"startOfNextMonth": "Start of next month",
"endOfNextMonth": "End of next month",
"in30Days": "In 30 days",
"startOfThisYear": "Beginning of this year",
"endOfThisYear": "End of this year"
}
},
"datemathHelp": {
@ -916,7 +1002,9 @@
"description": "Descripción",
"descriptionPlaceholder": "Describe the team here, hit '/' for more options…",
"admin": "Admin",
"member": "Miembro"
"member": "Miembro",
"isPublic": "Public Team",
"isPublicDescription": "Make the team publicly discoverable. When enabled, anyone can share projects with this team even when not being a direct member."
}
},
"keyboardShortcuts": {
@ -976,7 +1064,8 @@
"newProject": "Nuevo proyecto",
"createProject": "Crear proyecto",
"cantArchiveIsDefault": "You cannot archive this because it is your default project.",
"cantDeleteIsDefault": "You cannot delete this because it is your default project."
"cantDeleteIsDefault": "You cannot delete this because it is your default project.",
"views": "Views"
},
"apiConfig": {
"url": "URL de Vikunja",
@ -1023,6 +1112,12 @@
"altFormatLong": "j M Y H:i",
"altFormatShort": "j M Y"
},
"reaction": {
"reactedWith": "{user} reacted with {value}",
"reactedWithAnd": "{users} and {lastUser} reacted with {value}",
"reactedWithAndMany": "{users} and {num} more reacted reacted with {value}",
"add": "Add your reaction"
},
"error": {
"error": "Error",
"success": "Éxito",

View File

@ -248,6 +248,7 @@
"text2": "Ceci inclut toutes les tâches et NE PEUT PAS ÊTRE ANNULÉ !",
"success": "Le projet a bien été supprimé.",
"tasksToDelete": "Cela supprimera définitivement environ {count} tâches.",
"tasksAndChildProjectsToDelete": "This will irrevocably remove approx. {tasks} tasks and {projects} projects.",
"noTasksToDelete": "Ce projet ne contient aucune tâche, vous pouvez le supprimer sans problème."
},
"duplicate": {
@ -380,11 +381,28 @@
"secret": "Secret",
"secretHint": "If provided, all requests to the webhook target URL will be signed using HMAC.",
"secretDocs": "Check out the docs for more details about how to use secrets."
},
"views": {
"header": "Edit views",
"title": "Title",
"actions": "Actions",
"kind": "Kind",
"bucketConfigMode": "Bucket configuration mode",
"bucketConfig": "Bucket configuration",
"bucketConfigManual": "Manual",
"filter": "Filter",
"create": "Create view",
"createSuccess": "The view was created successfully.",
"titleRequired": "Please provide a title.",
"delete": "Delete this view",
"deleteText": "Are you sure you want to remove this view? It will no longer be possible to use it to view tasks in this project. This action won't delete any tasks. This cannot be undone!",
"deleteSuccess": "The view was successfully deleted"
}
},
"filters": {
"title": "Filtres",
"clear": "Effacer les filtres",
"showResults": "Show results",
"attributes": {
"title": "Nom",
"titlePlaceholder": "Entre un nom de filtre enregistré…",
@ -415,6 +433,52 @@
"edit": {
"title": "Modifier ce filtre enregistré",
"success": "Filtre enregistré."
},
"query": {
"title": "Query",
"placeholder": "Type a search or filter query…",
"help": {
"intro": "To filter tasks, you can use a query syntax similar to SQL. The available fields for filtering include:",
"link": "How does this work?",
"canUseDatemath": "You can date math to set relative dates. Click on the date value in a query to find out more.",
"fields": {
"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)"
},
"operators": {
"intro": "The available operators for filtering include:",
"notEqual": "Not equal to",
"equal": "Equal to",
"greaterThan": "Greater than",
"greaterThanOrEqual": "Greater than or equal to",
"lessThan": "Less than",
"lessThanOrEqual": "Less than or equal to",
"like": "Matches a pattern (using wildcard %)",
"in": "Matches any value in a comma-seperated list of values"
},
"logicalOperators": {
"intro": "To combine multiple conditions, you can use the following logical operators:",
"and": "AND operator, matches if all conditions are true",
"or": "OR operator, matches if any of the conditions are true",
"parentheses": "Parentheses for grouping conditions"
},
"examples": {
"intro": "Here are some examples of filter queries:",
"priorityEqual": "Matches tasks with priority level 4",
"dueDatePast": "Matches tasks with a due date in the past",
"undoneHighPriority": "Matches undone tasks with priority level 3 or higher",
"assigneesIn": "Matches tasks assigned to either \"user1\" or \"user2\"",
"priorityOneOrTwoPastDue": "Matches tasks with priority level 1 or 2 and a due date in the past"
}
}
}
},
"migrate": {
@ -584,6 +648,7 @@
"to": "À",
"from": "De",
"fromto": "Du {from} au {to}",
"date": "Date",
"ranges": {
"today": "Aujourdhui",
"thisWeek": "Cette semaine",
@ -598,6 +663,27 @@
"lastMonth": "Le mois dernier",
"thisYear": "Cette année",
"restOfThisYear": "Le reste de cette année"
},
"values": {
"now": "Now",
"startOfToday": "Start of today",
"endOfToday": "End of today",
"beginningOflastWeek": "Beginning of last week",
"endOfLastWeek": "End of last week",
"beginningOfThisWeek": "Beginning of this week",
"endOfThisWeek": "End of this week",
"startOfNextWeek": "Start of next week",
"endOfNextWeek": "End of next week",
"in7Days": "In 7 days",
"beginningOfLastMonth": "Beginning of last month",
"endOfLastMonth": "End of last month",
"startOfThisMonth": "Start of this month",
"endOfThisMonth": "End of this month",
"startOfNextMonth": "Start of next month",
"endOfNextMonth": "End of next month",
"in30Days": "In 30 days",
"startOfThisYear": "Beginning of this year",
"endOfThisYear": "End of this year"
}
},
"datemathHelp": {
@ -916,7 +1002,9 @@
"description": "Description",
"descriptionPlaceholder": "Describe the team here, hit '/' for more options…",
"admin": "Admin",
"member": "Membre"
"member": "Membre",
"isPublic": "Public Team",
"isPublicDescription": "Make the team publicly discoverable. When enabled, anyone can share projects with this team even when not being a direct member."
}
},
"keyboardShortcuts": {
@ -976,7 +1064,8 @@
"newProject": "Nouveau projet",
"createProject": "Créer un projet",
"cantArchiveIsDefault": "You cannot archive this because it is your default project.",
"cantDeleteIsDefault": "You cannot delete this because it is your default project."
"cantDeleteIsDefault": "You cannot delete this because it is your default project.",
"views": "Views"
},
"apiConfig": {
"url": "URL Vikunja",
@ -1023,6 +1112,12 @@
"altFormatLong": "j M Y H:i",
"altFormatShort": "j M Y"
},
"reaction": {
"reactedWith": "{user} reacted with {value}",
"reactedWithAnd": "{users} and {lastUser} reacted with {value}",
"reactedWithAndMany": "{users} and {num} more reacted reacted with {value}",
"add": "Add your reaction"
},
"error": {
"error": "Erreur",
"success": "Succès",

View File

@ -6,7 +6,7 @@
"welcomeEvening": "Jó estét {username}!",
"lastViewed": "Utoljára megtekintve",
"addToHomeScreen": "Adja hozzá ezt az alkalmazást a kezdőképernyőhöz a gyorsabb hozzáférés és a jobb élmény érdekében.",
"goToOverview": "Go to overview",
"goToOverview": "Tovább az áttekintéshez",
"project": {
"importText": "Importálja projektjeit és feladatait más szolgáltatásokból a Vikunjába:",
"import": "Importálja adatait a Vikunjába"
@ -57,11 +57,11 @@
"logout": "Kijelentkezés",
"emailInvalid": "Kérjük, adjon meg egy valós email címet!",
"usernameRequired": "Kérjük adjon meg egy felhasználónevet.",
"usernameMustNotContainSpace": "The username must not contain spaces.",
"usernameMustNotLookLikeUrl": "The username must not look like a URL.",
"usernameMustNotContainSpace": "A felhasználónév nem tartalmazhat szóközt.",
"usernameMustNotLookLikeUrl": "A felhasználónév nem nézhet ki URL-nek.",
"passwordRequired": "Kérjük, adjon meg új jelszót.",
"passwordNotMin": "Password must have at least 8 characters.",
"passwordNotMax": "Password must have at most 250 characters.",
"passwordNotMin": "A jelszónak legalább 8 karakterből kell állnia.",
"passwordNotMax": "A jelszó legfeljebb 250 karakterből állhat.",
"showPassword": "Jelszó megjelenítése",
"hidePassword": "A jelszó elrejtése",
"noAccountYet": "Még nincs fiókja?",
@ -164,7 +164,7 @@
"expired": "Ez a token lejárt {ago}.",
"tokenCreatedSuccess": "Íme az új API tokenje: {token}",
"tokenCreatedNotSeeAgain": "Tárolja el biztonságos helyen, többé nem fogja látni!",
"selectAll": "Select all",
"selectAll": "Összes kijelölése",
"delete": {
"header": "Token törlése",
"text1": "Biztos benne, hogy törölni akarja ezt a tokent \"{token}\"?",
@ -248,6 +248,7 @@
"text2": "Ez magában foglalja az összes feladatot és NEM VISSZAVONHATÓ!",
"success": "A projekt sikeresen törölve.",
"tasksToDelete": "Ezzel visszavonhatatlanul eltávolítjuk kb. {count} feladatát.",
"tasksAndChildProjectsToDelete": "Ezzel visszavonhatatlanul eltávolítunk kb. {tasks} feladatot és {projects} projektet.",
"noTasksToDelete": "Ez a projekt nem tartalmaz feladatokat, biztonságosan törölhető."
},
"duplicate": {
@ -264,7 +265,7 @@
"identifier": "Projektazonosító",
"identifierPlaceholder": "Írja be a projekt projektazonosítót...",
"description": "Leírás",
"descriptionPlaceholder": "Enter a description for this project, hit '/' for more options…",
"descriptionPlaceholder": "Adja meg a projekt leírását, a további lehetőségekért nyomja meg a „/” gombot…",
"color": "Szín",
"success": "A projekt sikeresen frissítve."
},
@ -367,29 +368,46 @@
}
},
"webhooks": {
"title": "Webhooks",
"targetUrl": "Target URL",
"targetUrlInvalid": "Please provide a valid URL.",
"events": "Events",
"eventsHint": "Select all events this webhook should recieve updates for (within the current project).",
"mustSelectEvents": "You must select at least one event.",
"delete": "Delete this webhook",
"deleteText": "Are you sure you want to delete this webhook? External targets will not be notified of its events anymore.",
"deleteSuccess": "The webhook was successfully deleted.",
"create": "Create webhook",
"secret": "Secret",
"secretHint": "If provided, all requests to the webhook target URL will be signed using HMAC.",
"secretDocs": "Check out the docs for more details about how to use secrets."
"title": "Webhookok",
"targetUrl": "Cél URL",
"targetUrlInvalid": "Kérjük adjon meg egy érvényes URL-t.",
"events": "Események",
"eventsHint": "Válassza ki az összes olyan eseményt, amelyhez ez a webhook frissítéseket kap (az aktuális projekten belül).",
"mustSelectEvents": "Legalább egy eseményt ki kell választania.",
"delete": "Webhook törlése",
"deleteText": "Biztosan törli ezt a webhook-ot? A külső célpontok többé nem kapnak értesítést az eseményeiről.",
"deleteSuccess": "A webhook törlése sikeresen megtörtént.",
"create": "Webhook létrehozása",
"secret": "Kulcs",
"secretHint": "Amennyiben meg van adva, a webhook cél URL-címére irányuló összes kérés HMAC használatával lesz aláírva.",
"secretDocs": "Tekintse meg a dokumentációt a titkok használatának további részleteiért."
},
"views": {
"header": "Nézetek szerkesztése",
"title": "Cím",
"actions": "Műveletek",
"kind": "Fajta",
"bucketConfigMode": "Vödör konfigurációs mód",
"bucketConfig": "Vödör konfiguráció",
"bucketConfigManual": "Manuális",
"filter": "Szűrő",
"create": "Nézet létrehozása",
"createSuccess": "A nézet létrehozása sikeres volt.",
"titleRequired": "Kérjük, adjon meg egy címet.",
"delete": "Törölje ezt a nézetet",
"deleteText": "Biztosan eltávolítja ezt a nézetet? A továbbiakban nem lesz használható a projektben szereplő feladatok megtekintésére. Ez a művelet nem töröl semmilyen feladatot. Ezt nem lehet visszacsinálni!",
"deleteSuccess": "A nézet sikeresen törölve"
}
},
"filters": {
"title": "Szűrők",
"clear": "Szűrők törlése",
"showResults": "Eredmények megjelenítése",
"attributes": {
"title": "Cím",
"titlePlaceholder": "A mentett szűrő címe ide kerül…",
"description": "Leírás",
"descriptionPlaceholder": "Add a description for this filter here, hit '/' for more options…",
"descriptionPlaceholder": "Adjon hozzá leírást ehhez a szűrőhöz, nyomja meg a „/” gombot a további lehetőségekért…",
"includeNulls": "Tartalmazza az olyan feladatokat, amelyeknek nincs beállított értéke",
"requireAll": "A feladat megjelenítéséhez minden szűrőnek igaznak kell lennie",
"showDoneTasks": "Elkészült feladatok megjelenítése",
@ -415,6 +433,52 @@
"edit": {
"title": "Mentett szűrő szerkesztése",
"success": "A szűrőt sikeresen mentette."
},
"query": {
"title": "Lekérdezés",
"placeholder": "Írjon be egy keresési, vagy szűrési lekérdezést…",
"help": {
"intro": "A feladatok szűréséhez az SQL-hez hasonló lekérdezési szintaxist használhat. A szűréshez rendelkezésre álló mezők a következők:",
"link": "Hogyan működik?",
"canUseDatemath": "A relatív dátumok beállításához a matematikai dátumokat is használhat. További információért kattintson a dátum értékére a lekérdezésben.",
"fields": {
"done": "Akár befejeződött a feladat, akár nem",
"priority": "A feladat prioritási szintje (1-5)",
"percentDone": "A feladat teljesítésének százalékos aránya (0-100)",
"dueDate": "A feladat teljesítésének határideje",
"startDate": "A feladat kezdési dátuma",
"endDate": "A feladat befejezési dátuma",
"doneAt": "A feladat befejezésének dátuma és időpontja",
"assignees": "A feladattal megbízottak",
"labels": "A feladathoz társított címkék",
"project": "A projekt, amelyhez a feladat tartozik (csak a mentett szűrőkhöz érhető el, projekt szinten nem)"
},
"operators": {
"intro": "A szűréshez elérhető operátorok a következők:",
"notEqual": "Nem egyenlő",
"equal": "Egyenlő",
"greaterThan": "Nagyobb, mint",
"greaterThanOrEqual": "Nagyobb, vagy egyenlő",
"lessThan": "Kevesebb, mint",
"lessThanOrEqual": "Kevesebb, vagy egyenlő mint",
"like": "Megfelel egy mintának (helyettesítő karakterrel %)",
"in": "A vesszővel elválasztott értéklistában szereplő bármely értéknek megfelel"
},
"logicalOperators": {
"intro": "Több feltétel kombinálásához a következő logikai operátorokat használhatja:",
"and": "ÉS operátor, akkor egyezik, ha minden feltétel igaz",
"or": "VAGY operátor, akkor felel meg, ha valamelyik feltétel igaz",
"parentheses": "Zárójelek a csoportosítási feltételekhez"
},
"examples": {
"intro": "Íme néhány példa a szűrőlekérdezésekre:",
"priorityEqual": "4. prioritási szintű feladatokat egyezteti",
"dueDatePast": "A múltbeli esedékességgel rendelkező feladatokat egyezteti",
"undoneHighPriority": "Megfelel a 3-as, vagy magasabb prioritási szintű még nem elvégzett feladatoknak",
"assigneesIn": "Megfelel a \"felhasználó1\", vagy a \"felhasználó2\"-nek rendelt feladatoknak",
"priorityOneOrTwoPastDue": "Megfelel az 1-es, vagy 2-es prioritási szinttel és a múltbeli esedékességgel rendelkező feladatoknak"
}
}
}
},
"migrate": {
@ -431,8 +495,8 @@
"confirm": "Biztos vagyok benne, kezdje el a migrációt most!",
"importUpload": "Ha adatokat szeretne importálni a(z) {name} webhelyről a Vikunjába, kattintson az alábbi gombra a fájl kiválasztásához.",
"upload": "Fájl feltöltése",
"migrationStartedWillReciveEmail": "Vikunja will now import your lists/projects, tasks, notes, reminders and files from {service}. As this will take a while, we will send you an email once done. You can close this window now.",
"migrationInProgress": "A migration is currently in progress. Please wait until it is done."
"migrationStartedWillReciveEmail": "A Vikunja mostantól importálja listáit/projektjeit, feladatait, jegyzeteit, emlékeztetőit és fájljait a {service} szolgáltatásból. Mivel ez eltart egy ideig, amint elkészült, küldünk Önnek egy e-mailt. Most bezárhatja ezt az ablakot.",
"migrationInProgress": "A migráció folyamatban van. Kérjük várjon, amíg elkészül."
},
"label": {
"title": "Címkék",
@ -503,7 +567,7 @@
"custom": "Egyéni",
"id": "Azonosító",
"created": "Létrehozva ekkor:",
"createdBy": "Created by {0}",
"createdBy": "Létrehozta: {0}",
"actions": "Műveletek",
"cannotBeUndone": "Ezt nem lehet visszavonni!"
},
@ -522,59 +586,59 @@
"edit": "Szerkesztés",
"done": "Befejezve",
"heading1": "Címsor 1",
"heading1Tooltip": "Big section heading.",
"heading1Tooltip": "Nagy szakasz címsora.",
"heading2": "Címsor 2",
"heading2Tooltip": "Medium section heading.",
"heading2Tooltip": "Közepes szakasz címsora.",
"heading3": "Címsor 3",
"heading3Tooltip": "Smaller section header.",
"heading3Tooltip": "Kisebb szakasz címsora.",
"headingSmaller": "Kisebb címsor",
"headingBigger": "Nagyobb címsor",
"bold": "Félkövér",
"italic": "Dőlt",
"strikethrough": "Áthúzott",
"underline": "Underline",
"underline": "Aláhúzott",
"code": "Kód",
"codeTooltip": "Capture a code snippet.",
"codeTooltip": "Rögzítsen egy kódrészletet.",
"quote": "Idézet",
"quoteTooltip": "Capture a quote.",
"bulletList": "Bullet list",
"bulletListTooltip": "Create a simple bullet list.",
"unorderedList": "Unordered list",
"orderedList": "Ordered list",
"orderedListTooltip": "Create a list with numbering.",
"quoteTooltip": "Rögzítsen egy idézetet.",
"bulletList": "Felsorolásos lista",
"bulletListTooltip": "Hozzon létre egy egyszerű felsoroláslistát.",
"unorderedList": "Rendezetlen lista",
"orderedList": "Rendezett lista",
"orderedListTooltip": "Készítsen listát számozással.",
"cleanBlock": "Blokk kitisztítása",
"link": "Hivatkozás",
"image": "Kép",
"imageTooltip": "Upload an image from your computer.",
"imageTooltip": "Fájl feltöltése a számítógépről.",
"table": {
"title": "Table",
"insert": "Insert table",
"addColumnBefore": "Add column before",
"addColumnAfter": "Add column after",
"deleteColumn": "Delete column",
"addRowBefore": "Add row before",
"addRowAfter": "Add row after",
"deleteRow": "Delete row",
"deleteTable": "Delete table",
"mergeCells": "Merge cells",
"splitCell": "Split cell",
"toggleHeaderColumn": "Toggle header column",
"toggleHeaderRow": "Toggle header row",
"toggleHeaderCell": "Toggle header cell",
"mergeOrSplit": "Merge or split",
"fixTables": "Fix tables"
"title": "Táblázat",
"insert": "Táblázat beszúrása",
"addColumnBefore": "Oszlop hozzáadása előtte",
"addColumnAfter": "Oszlop hozzáadása utána",
"deleteColumn": "Oszlop törlése",
"addRowBefore": "Sor hozzáadása előtte",
"addRowAfter": "Sor hozzáadása utána",
"deleteRow": "Sor törlése",
"deleteTable": "Táblázat törlése",
"mergeCells": "Cellák egyesítése",
"splitCell": "Cellák szétválasztása",
"toggleHeaderColumn": "A fejléc oszlopának váltása",
"toggleHeaderRow": "Váltás a fejlécsorra",
"toggleHeaderCell": "Kapcsolja be a fejléccellát",
"mergeOrSplit": "Egyesítés vagy felosztás",
"fixTables": "Táblázatok javítása"
},
"horizontalRule": "Vízszintes vonal",
"horizontalRuleTooltip": "Divide a section.",
"horizontalRuleTooltip": "Egy szakasz felosztása.",
"sideBySide": "Egymás mellett",
"guide": "Útmutató",
"text": "Text",
"textTooltip": "Just start typing with plain text.",
"taskList": "Task list",
"taskListTooltip": "Track tasks with a to-do list.",
"undo": "Undo",
"redo": "Redo",
"placeholder": "Type some text or hit '/' to see more options…"
"text": "Szöveg",
"textTooltip": "Csak kezdje el a gépelést egyszerű szöveggel.",
"taskList": "Feladatlista",
"taskListTooltip": "Kövesse nyomon a feladatokat egy teendőlistával.",
"undo": "Visszavonás",
"redo": "Újra",
"placeholder": "Írjon be egy szöveget, vagy nyomja meg a „/” gombot a további lehetőségek megtekintéséhez…"
},
"multiselect": {
"createPlaceholder": "Új létrehozása",
@ -584,6 +648,7 @@
"to": "Eddig",
"from": "Ettől",
"fromto": "{from} - tól {to} - ig",
"date": "Dátum",
"ranges": {
"today": "Ma",
"thisWeek": "Ezen a héten",
@ -598,6 +663,27 @@
"lastMonth": "Előző hónap",
"thisYear": "Aktuális év",
"restOfThisYear": "Az év hátralévő része"
},
"values": {
"now": "Most",
"startOfToday": "A mai nap kezdete",
"endOfToday": "A mai nap vége",
"beginningOflastWeek": "Múlt hét eleje",
"endOfLastWeek": "Múlt hét vége",
"beginningOfThisWeek": "E hét eleje",
"endOfThisWeek": "E hét vége",
"startOfNextWeek": "Jövő hét eleje",
"endOfNextWeek": "Jövő hét vége",
"in7Days": "7 napon belül",
"beginningOfLastMonth": "Múlt hónap eleje",
"endOfLastMonth": "Múlt hónap vége",
"startOfThisMonth": "E hónap eleje",
"endOfThisMonth": "E hónap vége",
"startOfNextMonth": "Jövő hónap eleje",
"endOfNextMonth": "Jövő hónap vége",
"in30Days": "30 napon belül",
"startOfThisYear": "Ez év eleje",
"endOfThisYear": "Ez év vége"
}
},
"datemathHelp": {
@ -713,7 +799,7 @@
"startDate": "Kezdő dátum",
"title": "Cím",
"updated": "Frissítve",
"doneAt": "Done At"
"doneAt": "Befejezve ekkor"
},
"subscription": {
"subscribedTaskThroughParentProject": "Itt nem iratkozhat le, mert a projektjén keresztül feliratkozott erre a feladatra.",
@ -749,7 +835,7 @@
"loading": "Hozzászólások betöltése…",
"edited": "Szerkesztve: {date}",
"creating": "Hozzászólás létrehozása…",
"placeholder": "Add your comment, hit '/' for more options…",
"placeholder": "Adja hozzá megjegyzését, nyomja meg a „/” gombot a további lehetőségekért…",
"comment": "Hozzászólás",
"delete": "Hozzászólás törlése",
"deleteText1": "Biztos benne, hogy törölni akarja ezt a hozzászólást?",
@ -763,7 +849,7 @@
"1week": "1 hét"
},
"description": {
"placeholder": "Enter a description, hit '/' for more options…",
"placeholder": "Írja be a leírást, nyomja meg a '/' gombot a további lehetőségekért…",
"empty": "Nem érhető el leírás."
},
"assignee": {
@ -914,9 +1000,11 @@
"namePlaceholder": "A csapat nevét ide írja…",
"nameRequired": "Kérjük, adjon meg egy nevet.",
"description": "Leírás",
"descriptionPlaceholder": "Describe the team here, hit '/' for more options…",
"descriptionPlaceholder": "Jellemezze a csapatot itt, kattintson a '/' gombra a további lehetőségekért…",
"admin": "Adminisztrátor",
"member": "Tag"
"member": "Tag",
"isPublic": "Nyilvános csapat",
"isPublicDescription": "Tedd nyilvánosan felfedezhetővé a csapatot. Ha engedélyezve van, bárki megoszthat projekteket ezzel a csapattal, még akkor is, ha nem közvetlen tagja."
}
},
"keyboardShortcuts": {
@ -975,8 +1063,9 @@
"share": "Megosztás",
"newProject": "Új projekt",
"createProject": "Projekt létrehozása",
"cantArchiveIsDefault": "You cannot archive this because it is your default project.",
"cantDeleteIsDefault": "You cannot delete this because it is your default project."
"cantArchiveIsDefault": "Ezt nem archiválhatja, mert ez az alapértelmezett projektje.",
"cantDeleteIsDefault": "Ezt nem törölheti, mert ez az alapértelmezett projektje.",
"views": "Nézetek"
},
"apiConfig": {
"url": "Vikunja URL",
@ -996,8 +1085,8 @@
"title": "Értesítések",
"none": "Nincsenek értesítései. Legyen szép napja!",
"explainer": "Az értesítések itt jelennek meg, amikor olyan projektek vagy feladatok történnek, amelyekre feliratkozott.",
"markAllRead": "Mark all notifications as read",
"markAllReadSuccess": "Successfully marked all notifications as read."
"markAllRead": "Minden értesítés megjelölése olvasottként",
"markAllReadSuccess": "Az összes értesítést olvasottként jelölte meg."
},
"quickActions": {
"commands": "Parancsok",
@ -1023,6 +1112,12 @@
"altFormatLong": "j M Y H:i",
"altFormatShort": "j M Y"
},
"reaction": {
"reactedWith": "{user} a következővel reagált: {value}",
"reactedWithAnd": "{users} és {lastUser} a következővel reagált: {value}",
"reactedWithAndMany": "{users} és további {num} felhasználó reagált a következővel: {value}",
"add": "Adja hozzá reakcióját"
},
"error": {
"error": "Hiba",
"success": "Siker",
@ -1096,7 +1191,7 @@
},
"about": {
"title": "Névjegy",
"version": "Version: {version}"
"version": "Verzió: {version}"
},
"time": {
"units": {

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