Compare commits

..

1 Commits

Author SHA1 Message Date
89a9f5a779 fix(deps): update dependency flexsearch to v0.7.31
Some checks failed
continuous-integration/drone/pr Build is failing
2023-02-15 22:03:43 +00:00
198 changed files with 3609 additions and 4251 deletions

View File

@ -15,7 +15,6 @@ trigger:
services: services:
- name: api - name: api
image: vikunja/api:unstable image: vikunja/api:unstable
pull: always
environment: environment:
VIKUNJA_SERVICE_TESTINGTOKEN: averyLongSecretToSe33dtheDB VIKUNJA_SERVICE_TESTINGTOKEN: averyLongSecretToSe33dtheDB
VIKUNJA_LOG_LEVEL: DEBUG VIKUNJA_LOG_LEVEL: DEBUG
@ -522,6 +521,6 @@ steps:
from_secret: crowdin_key from_secret: crowdin_key
--- ---
kind: signature kind: signature
hmac: 303afeb09b75a57ba88720b45dc06c8bf2c7320e19d738d8299f325438246f75 hmac: 971875b90c7bb1649d1b00d022d0b594ba9b68f927bf8f0dbe840190816d676b
... ...

2
.nvmrc
View File

@ -1 +1 @@
18.15.0 v18

View File

@ -18,12 +18,6 @@
"javascriptreact", "javascriptreact",
"vue" "vue"
], ],
"volar.completion.preferredTagNameCase": "pascal",
// disable vetur in case it is installed
"vetur.validation.template": false,
// i18n ally // i18n ally
"i18n-ally.localesPaths": [ "i18n-ally.localesPaths": [
"src/i18n/lang" "src/i18n/lang"

View File

@ -9,279 +9,6 @@ All releases can be found on https://code.vikunja.io/frontend/releases.
The releases aim at the api versions which is why there are missing versions. The releases aim at the api versions which is why there are missing versions.
## [0.20.5] - 2023-03-12
### Bug Fixes
* *(docker)* Add cap_net_bind to the nginx binary in the docker container
* *(docker)* Revert unprivileged user
### Dependencies
* *(deps)* Update dependency sass to v1.59.2
* *(deps)* Update dependency eslint to v8.36.0
## [0.20.4] - 2023-03-10
### Bug Fixes
* *(base)* Use Build Time Base Path
* *(docker)* Cross compilation with buildx
* *(docker)* Default api url
* *(docker)* Make sure the service worker and webmanifest are never cached
* *(filter)* Validate title before creating or editing a filter
* *(filter)* Don't allow marking a filter as favorite
* *(i18n)* Load language files before doing anything else (#3218)
* *(keyboard-shortcuts)* Use card prop
* *(list)* Make sure favorite lists are not duplicated in the menu when renaming them
* *(menu)* Don't show drag handle for not draggable menu items
* *(postcss-preset-env)* Client side polyfills (#3051)
* *(quick actions)* Don't throw an error message when selecting the last items with the arrow keys
* *(quick actions)* Hide edges of last entry on hover
* *(quick add magic)* Correctly parse "next {weekday}" on the beginning of the text
* *(quick-actions)* Nothing happening on team click (#3186)
* *(table view)* Correctly load sort order from local storage
* *(task)* Allow clicking on the whole task to open the task detail view
* *(tests)* Only look in src for tests
* Make sure global error handler handles unrejected promises correctly ([4576da0](4576da0dd394ee68801b1dc424c9550896d63737))
* Use Build Time Base Path (#2964) ([6572f75](6572f75e5d111f7f2dd06e8c2ad0e0d16091fca6))
* Always show update popup on top ([7cbf0ac](7cbf0acac503c508a44e0491ae51e6d5749dfa04))
* Button styles ([d40729c](d40729cbe70b760bcc64d56130a410b05ef9d3dc))
* Stop revealing elements on hover if hover is not supported (#3191) ([7b6f76d](7b6f76d1b4698d0d6c6889aaab3f1cdad80469f8))
* Sync sidebar transition with `<main>` (#3200) ([0f97ba6](0f97ba6ec904226ed91cd3ade8223e2959e9207a))
* Collapse menu on mobile when path changes ([1b06112](1b06112db4ba5ad4144b5868dd04e954be1d77f7))
* I18ze a string (#3210) ([b4dd23b](b4dd23b85d909f7e629e953f1d8543ccbf963a1c))
### Dependencies
* *(deps)* Update sentry-javascript monorepo to v7.33.0 (#3004)
* *(deps)* Update dependency axios to v1.2.4 (#3005)
* *(deps)* Update pnpm to v7.26.0 (#3002)
* *(deps)* Update dependency cypress to v12.4.0 (#3006)
* *(deps)* Update dependency @infectoone/vue-ganttastic to v2.1.4 (#3009)
* *(deps)* Update dependency vitest to v0.28.2 (#3008)
* *(deps)* Update dependency rollup to v3.11.0 (#3013)
* *(deps)* Update dependency @vitejs/plugin-legacy to v3.0.2 (#3012)
* *(deps)* Update dependency axios to v1.2.5
* *(deps)* Update sentry-javascript monorepo to v7.34.0
* *(deps)* Update pnpm to v7.26.1
* *(deps)* Update dependency @vue/test-utils to v2.2.8
* *(deps)* Update dependency vitest to v0.28.3 (#3019)
* *(deps)* Update dependency cypress to v12.4.1
* *(deps)* Update dependency rollup to v3.12.0
* *(deps)* Update dependency esbuild to v0.17.5
* *(deps)* Update dependency axios to v1.2.6
* *(deps)* Update dependency @vueuse/core to v9.12.0
* *(deps)* Update pnpm to v7.26.2
* *(deps)* Update dependency eslint to v8.33.0
* *(deps)* Update dependency netlify-cli to v12.10.0
* *(deps)* Update dependency happy-dom to v8.2.0
* *(deps)* Update dependency caniuse-lite to v1.0.30001449
* *(deps)* Update dependency typescript to v4.9.5
* *(deps)* Update typescript-eslint monorepo to v5.50.0
* *(deps)* Update dependency axios to v1.3.0 (#3036)
* *(deps)* Update dependency sass to v1.58.0
* *(deps)* Update dependency cypress to v12.5.0
* *(deps)* Update pnpm to v7.26.3
* *(deps)* Update dependency rollup to v3.12.1
* *(deps)* Update sentry-javascript monorepo to v7.35.0 (#3041)
* *(deps)* Update dependency pinia to v2.0.30 (#3042)
* *(deps)* Update dependency @vue/test-utils to v2.2.9
* *(deps)* Update dependency axios to v1.3.1
* *(deps)* Update dependency vue to v3.2.47
* *(deps)* Update dependency vite to v4.1.0
* *(deps)* Update dependency postcss-preset-env to v8 (#3000)
* *(deps)* Update dependency @vitejs/plugin-legacy to v4
* *(deps)* Update dependency @vitejs/plugin-legacy to v4.0.1
* *(deps)* Update sentry-javascript monorepo to v7.36.0
* *(deps)* Update dependency vite to v4.1.1
* *(deps)* Update dependency cypress to v12.5.1
* *(deps)* Update dependency @vue/test-utils to v2.2.10
* *(deps)* Update dependency vitest to v0.28.4
* *(deps)* Update dependency rollup to v3.13.0
* *(deps)* Update dependency axios to v1.3.2
* *(deps)* Update dependency rollup to v3.14.0
* *(deps)* Update dependency @types/node to v18.11.19
* *(deps)* Update dependency @histoire/plugin-screenshot to v0.13.0
* *(deps)* Update dependency histoire to v0.13.0
* *(deps)* Update caniuse-and-related
* *(deps)* Update dependency @histoire/plugin-vue to v0.13.0
* *(deps)* Update dependency happy-dom to v8.2.6
* *(deps)* Update typescript-eslint monorepo to v5.51.0
* *(deps)* Update dependency esbuild to v0.17.6
* *(deps)* Update dependency @cypress/vue to v5.0.4
* *(deps)* Update dependency @types/node to v18.13.0
* *(deps)* Update dependency vite-plugin-pwa to v0.14.2
* *(deps)* Update font awesome to v6.3.0
* *(deps)* Update pnpm to v7.27.0
* *(deps)* Update dependency @histoire/plugin-screenshot to v0.13.1
* *(deps)* Update dependency @histoire/plugin-vue to v0.13.1
* *(deps)* Update dependency vite-plugin-pwa to v0.14.3
* *(deps)* Update dependency histoire to v0.13.1
* *(deps)* Update dependency @histoire/plugin-screenshot to v0.13.2
* *(deps)* Update dependency @histoire/plugin-vue to v0.13.2
* *(deps)* Update dependency histoire to v0.13.2
* *(deps)* Update dependency @intlify/unplugin-vue-i18n to v0.8.2
* *(deps)* Update sentry-javascript monorepo to v7.37.0
* *(deps)* Update dependency esbuild to v0.17.7
* *(deps)* Update dependency rollup to v3.15.0
* *(deps)* Create a group for all histoire dependencies
* *(deps)* Update dependency @histoire/plugin-vue to v0.14.0
* *(deps)* Update dependency @histoire/plugin-screenshot to v0.14.0
* *(deps)* Update dependency @histoire/plugin-vue to v0.14.0
* *(deps)* Update dependency histoire to v0.14.0
* *(deps)* Update sentry-javascript monorepo to v7.37.1
* *(deps)* Update dependency histoire to v0.14.2
* *(deps)* Include histoire main package in histoire renovate group
* *(deps)* Histoire renovate group
* *(deps)* Update dependency eslint to v8.34.0
* *(deps)* Update histoire to v0.14.2
* *(deps)* Update dependency vite-plugin-pwa to v0.14.4
* *(deps)* Update dependency esbuild to v0.17.8
* *(deps)* Update dependency netlify-cli to v12.12.0
* *(deps)* Update dependency caniuse-lite to v1.0.30001451
* *(deps)* Update dependency vite-plugin-inject-preload to v1.3.0
* *(deps)* Update dependency vitest to v0.28.5
* *(deps)* Update sentry-javascript monorepo to v7.37.2
* *(deps)* Update dependency dompurify to v3 (#3107)
* *(deps)* Update typescript-eslint monorepo to v5.52.0
* *(deps)* Update dependency axios to v1.3.3
* *(deps)* Update dependency start-server-and-test to v1.15.4 (#3109)
* *(deps)* Update dependency sass to v1.58.1
* *(deps)* Update dependency vue-flatpickr-component to v11.0.2 (#3112)
* *(deps)* Update dependency @kyvg/vue3-notification to v2.9.0 (#3113)
* *(deps)* Update histoire to v0.15.1
* *(deps)* Update histoire to v0.15.3
* *(deps)* Update dependency vue-tsc to v1.1.0
* *(deps)* Pin node.js to 18.14.0
* *(deps)* Update dependency cypress to v12.6.0 (#3115)
* *(deps)* Update histoire to v0.15.4
* *(deps)* Update dependency vue-tsc to v1.1.2
* *(deps)* Update dependency sass to v1.58.2
* *(deps)* Update dependency ufo to v1.1.0
* *(deps)* Update node.js to v18.14.1
* *(deps)* Update dependency vite to v4.1.2
* *(deps)* Update sentry-javascript monorepo to v7.38.0
* *(deps)* Update dependency rollup to v3.16.0
* *(deps)* Update histoire to v0.15.7
* *(deps)* Update dependency blurhash to v2.0.5
* *(deps)* Update dependency @cypress/vite-dev-server to v5.0.3
* *(deps)* Update dependency @types/node to v18.14.0
* *(deps)* Update histoire to v0.15.8
* *(deps)* Update dependency @vueuse/core to v9.13.0
* *(deps)* Update dependency rollup to v3.17.0
* *(deps)* Update pnpm to v7.27.1
* *(deps)* Update dependency vue-tsc to v1.1.3
* *(deps)* Update dependency sass to v1.58.3
* *(deps)* Update dependency rollup to v3.17.1
* *(deps)* Update dependency esbuild to v0.17.9
* *(deps)* Update dependency vite to v4.1.3
* *(deps)* Update dependency @vue/test-utils to v2.3.0
* *(deps)* Update dependency caniuse-lite to v1.0.30001457
* *(deps)* Update dependency codemirror to v5.65.12
* *(deps)* Update dependency pinia to v2.0.31
* *(deps)* Update dependency vue-tsc to v1.1.4
* *(deps)* Update dependency rollup to v3.17.2
* *(deps)* Update dependency happy-dom to v8.6.0
* *(deps)* Update dependency netlify-cli to v12.13.2
* *(deps)* Update dependency esbuild to v0.17.10
* *(deps)* Update typescript-eslint monorepo to v5.53.0
* *(deps)* Update dependency vue-tsc to v1.1.5
* *(deps)* Update dependency pinia to v2.0.32
* *(deps)* Update node.js to v18.14.2
* *(deps)* Update dependency vite to v4.1.4
* *(deps)* Update dependency vue-tsc to v1.1.7
* *(deps)* Update dependency axios to v1.3.4
* *(deps)* Update dependency @types/node to v18.14.1
* *(deps)* Update dependency @cypress/vite-dev-server to v5.0.4
* *(deps)* Update dependency cypress to v12.7.0
* *(deps)* Update dependency vue-tsc to v1.2.0
* *(deps)* Update dependency vitest to v0.29.1
* *(deps)* Update pnpm to v7.28.0
* *(deps)* Update dependency eslint to v8.35.0
* *(deps)* Update dependency rollup to v3.17.3
* *(deps)* Update dependency netlify-cli to v13
* *(deps)* Update dependency happy-dom to v8.9.0
* *(deps)* Update dependency caniuse-lite to v1.0.30001458
* *(deps)* Update dependency start-server-and-test to v1.15.5
* *(deps)* Update dependency start-server-and-test to v2
* *(deps)* Update dependency @types/node to v18.14.2
* *(deps)* Update sentry-javascript monorepo to v7.39.0
* *(deps)* Update typescript-eslint monorepo to v5.54.0
* *(deps)* Update dependency ufo to v1.1.1
* *(deps)* Update dependency vitest to v0.29.2
* *(deps)* Update dependency rollup to v3.18.0
* *(deps)* Update dependency dompurify to v3.0.1
* *(deps)* Update sentry-javascript monorepo to v7.40.0
* *(deps)* Update dependency @types/node to v18.14.4
* *(deps)* Update dependency @types/node to v18.14.5
* *(deps)* Update dependency @types/node to v18.14.6
* *(deps)* Update dependency esbuild to v0.17.11
* *(deps)* Update dependency netlify-cli to v13.0.1
* *(deps)* Update dependency caniuse-lite to v1.0.30001460
* *(deps)* Update pnpm to v7.29.0
* *(deps)* Update sentry-javascript monorepo to v7.41.0
* *(deps)* Update typescript-eslint monorepo to v5.54.1
* *(deps)* Update dependency pinia to v2.0.33
* *(deps)* Update node.js to v18.15.0
* *(deps)* Update dependency @intlify/unplugin-vue-i18n to v0.9.0
* *(deps)* Update pnpm to v7.29.1
* *(deps)* Update dependency @vue/test-utils to v2.3.1
* *(deps)* Update dependency @intlify/unplugin-vue-i18n to v0.9.1
* *(deps)* Update sentry-javascript monorepo to v7.42.0
* *(deps)* Update dependency rollup to v3.19.0
* *(deps)* Update dependency vite-plugin-inject-preload to v1.3.1
* *(deps)* Update dependency @types/node to v18.15.0
* *(deps)* Update dependency autoprefixer to v10.4.14
* *(deps)* Update dependency rollup to v3.19.1
### Features
* *(config)* Support Setting Base Path in .env
* Use v-show for navigation buttons ([7ed1a37](7ed1a37de53cb8c15994e9524a52080170db5950))
* Unindent settings page (#2996) ([13a39be](13a39be3de4d0f7e0f6be9c20e0464e86b87c676))
* Small content auth improvements (#2998) ([2be7847](2be784766f54810f8969e48291ce9181f2854a5b))
* Move update from navigation to app ([3db5ea4](3db5ea45d768d10458eaab0f5ee9dad0df2996e4))
* Improve naming and styles ([eaeddda](eaeddda4e468c2040862d18c9b2d37a1c0ba099e))
* Use klona instead of lodash.clonedeep (#3073) ([7b96397](7b96397e3bfa43a393ca84439069290bc4c8a5c8))
* Refactor to composable ([c502f9b](c502f9b840ee2d65193aa4ef29c7f260b49db0d2))
* Header improvements ([e8db2c2](e8db2c2b458bcae592609d5a5bc3f1b333651b25))
* Persistent menuActive state with Local Storage (#3011) ([e3dd4ef](e3dd4ef78ac818add138d0323bf65abe8a4caa29))
* Fix calculation of token invalidation (#3077) ([d6b55c7](d6b55c757067413bbc34acd48af9fb553f36db8a))
* Use renovate js-app as preset (#3087) ([97c8970](97c8970dd60b2ba1e894ca0039524c8f6a5cd5df))
* Improve recommended vscode settings ([e0f0699](e0f06999beb0a9fb5da817323744307401e85e47))
### Miscellaneous Tasks
* *(refactor)* Improve `stores/config` types (#3190)
* *(services)* Add examples for some functions
* *(services)* Let `getAll`: always return `Model[]`
* Move class name to top ([c6ed925](c6ed9254247efeb43e0763e095b145d6ec1965e1))
* Simplify error handling for login and OpenId Auth ([e67088f](e67088fdb7bd3b24cea6ee37851ef45f1fb7bdad))
* Simplify getting the error text from an exception ([9adf1ab](9adf1aba895a02f416148ddf8b6925689d6e2687))
* Typo ([81a4f2d](81a4f2d9775716bc0056348664fc24185af040d4))
* Update funding links ([7cb0cd2](7cb0cd293d6d277172eccf2558a62427bc86dfe6))
* Update funding links ([b26ea45](b26ea45fe0d1d6f5f070ef42a5d68aa6db8e6b70))
* Remove minimist dependency (not used anywhere) ([f697640](f697640636466e8f035c7d31597ee589379fa017))
* Remove sponsor ([fa0e46a](fa0e46a3991ab423c9364b65439d9e8e5a28cb7b))
* Histoire add logo link ([af4a039](af4a039502b29e9e7e21cf30d44715c7af056c15))
* Improve `@/message` `action` type (#3209) ([0eb78e3](0eb78e32f994e7032725e38d564320a5a04cbf2a))
* Remove an unused duplicate key ([9db3aed](9db3aedde9566fb94717e1dd66a21abdbda6e84a))
### Other
* *(other)* Add Ipv6 support to nginx (#100)
* *(other)* Added ipv6 control script
* *(other)* Disable listening on IPv6 ports when IPv6 is not supported (#102)
* *(other)* Docker refactoring (#3018)
* *(other)* Persist menuActive state in Local Storage
* *(other)* Refactor to only used local storage value when on desktop viewport widths
* *(other)* Solve for resize()
* *(other)* [skip ci] Updated translations via Crowdin
## [0.20.3] - 2023-01-24 ## [0.20.3] - 2023-01-24
### Bug Fixes ### Bug Fixes

View File

@ -66,3 +66,5 @@ RUN chmod 0755 /docker-entrypoint.d/*.sh /etc/nginx/templates && \
chmod -R 0644 /etc/nginx/nginx.conf && \ chmod -R 0644 /etc/nginx/nginx.conf && \
chown -R nginx:nginx ./ /etc/nginx/conf.d /etc/nginx/templates && \ chown -R nginx:nginx ./ /etc/nginx/conf.d /etc/nginx/templates && \
rm -f /docker-entrypoint.d/10-listen-on-ipv6-by-default.sh rm -f /docker-entrypoint.d/10-listen-on-ipv6-by-default.sh
# unprivileged user
USER nginx

View File

@ -4,7 +4,7 @@
[![Build Status](https://drone.kolaente.de/api/badges/vikunja/frontend/status.svg)](https://drone.kolaente.de/vikunja/frontend) [![Build Status](https://drone.kolaente.de/api/badges/vikunja/frontend/status.svg)](https://drone.kolaente.de/vikunja/frontend)
[![License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](LICENSE) [![License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](LICENSE)
[![Download](https://img.shields.io/badge/download-v0.20.5-brightgreen.svg)](https://dl.vikunja.io) [![Download](https://img.shields.io/badge/download-v0.20.3-brightgreen.svg)](https://dl.vikunja.io)
[![Translation](https://badges.crowdin.net/vikunja/localized.svg)](https://crowdin.com/project/vikunja) [![Translation](https://badges.crowdin.net/vikunja/localized.svg)](https://crowdin.com/project/vikunja)
This is the web frontend for Vikunja, written in Vue.js. This is the web frontend for Vikunja, written in Vue.js.

View File

@ -0,0 +1,57 @@
import {createFakeUserAndLogin} from '../../support/authenticateUser'
import {ListFactory} from '../../factories/list'
import {prepareLists} from './prepareLists'
describe('List History', () => {
createFakeUserAndLogin()
prepareLists()
it('should show a list history on the home page', () => {
cy.intercept(Cypress.env('API_URL') + '/namespaces*').as('loadNamespaces')
cy.intercept(Cypress.env('API_URL') + '/lists/*').as('loadList')
const lists = ListFactory.create(6)
cy.visit('/')
cy.wait('@loadNamespaces')
cy.get('body')
.should('not.contain', 'Last viewed')
cy.visit(`/lists/${lists[0].id}`)
cy.wait('@loadNamespaces')
cy.wait('@loadList')
cy.visit(`/lists/${lists[1].id}`)
cy.wait('@loadNamespaces')
cy.wait('@loadList')
cy.visit(`/lists/${lists[2].id}`)
cy.wait('@loadNamespaces')
cy.wait('@loadList')
cy.visit(`/lists/${lists[3].id}`)
cy.wait('@loadNamespaces')
cy.wait('@loadList')
cy.visit(`/lists/${lists[4].id}`)
cy.wait('@loadNamespaces')
cy.wait('@loadList')
cy.visit(`/lists/${lists[5].id}`)
cy.wait('@loadNamespaces')
cy.wait('@loadList')
// cy.visit('/')
// cy.wait('@loadNamespaces')
// Not using cy.visit here to work around the redirect issue fixed in #1337
cy.get('nav.menu.top-menu a')
.contains('Overview')
.click()
cy.get('body')
.should('contain', 'Last viewed')
cy.get('[data-cy="listCardGrid"]')
.should('not.contain', lists[0].title)
.should('contain', lists[1].title)
.should('contain', lists[2].title)
.should('contain', lists[3].title)
.should('contain', lists[4].title)
.should('contain', lists[5].title)
})
})

View File

@ -3,15 +3,15 @@ import {formatISO, format} from 'date-fns'
import {createFakeUserAndLogin} from '../../support/authenticateUser' import {createFakeUserAndLogin} from '../../support/authenticateUser'
import {TaskFactory} from '../../factories/task' import {TaskFactory} from '../../factories/task'
import {prepareProjects} from './prepareProjects' import {prepareLists} from './prepareLists'
describe('Project View Gantt', () => { describe('List View Gantt', () => {
createFakeUserAndLogin() createFakeUserAndLogin()
prepareProjects() prepareLists()
it('Hides tasks with no dates', () => { it('Hides tasks with no dates', () => {
const tasks = TaskFactory.create(1) const tasks = TaskFactory.create(1)
cy.visit('/projects/1/gantt') cy.visit('/lists/1/gantt')
cy.get('.g-gantt-rows-container') cy.get('.g-gantt-rows-container')
.should('not.contain', tasks[0].title) .should('not.contain', tasks[0].title)
@ -25,7 +25,7 @@ describe('Project View Gantt', () => {
nextMonth.setDate(1) nextMonth.setDate(1)
nextMonth.setMonth(9) nextMonth.setMonth(9)
cy.visit('/projects/1/gantt') cy.visit('/lists/1/gantt')
cy.get('.g-timeunits-container') cy.get('.g-timeunits-container')
.should('contain', format(now, 'MMMM')) .should('contain', format(now, 'MMMM'))
@ -38,7 +38,7 @@ describe('Project View Gantt', () => {
start_date: now.toISOString(), start_date: now.toISOString(),
end_date: new Date(new Date(now).setDate(now.getDate() + 4)).toISOString(), end_date: new Date(new Date(now).setDate(now.getDate() + 4)).toISOString(),
}) })
cy.visit('/projects/1/gantt') cy.visit('/lists/1/gantt')
cy.get('.g-gantt-rows-container') cy.get('.g-gantt-rows-container')
.should('not.be.empty') .should('not.be.empty')
@ -50,7 +50,7 @@ describe('Project View Gantt', () => {
start_date: null, start_date: null,
end_date: null, end_date: null,
}) })
cy.visit('/projects/1/gantt') cy.visit('/lists/1/gantt')
cy.get('.gantt-options .fancycheckbox') cy.get('.gantt-options .fancycheckbox')
.contains('Show tasks which don\'t have dates set') .contains('Show tasks which don\'t have dates set')
@ -69,7 +69,7 @@ describe('Project View Gantt', () => {
start_date: now.toISOString(), start_date: now.toISOString(),
end_date: new Date(new Date(now).setDate(now.getDate() + 4)).toISOString(), end_date: new Date(new Date(now).setDate(now.getDate() + 4)).toISOString(),
}) })
cy.visit('/projects/1/gantt') cy.visit('/lists/1/gantt')
cy.get('.g-gantt-rows-container .g-gantt-row .g-gantt-row-bars-container div .g-gantt-bar') cy.get('.g-gantt-rows-container .g-gantt-row .g-gantt-row-bars-container div .g-gantt-bar')
.first() .first()
@ -83,9 +83,9 @@ describe('Project View Gantt', () => {
const now = Date.UTC(2022, 10, 9) const now = Date.UTC(2022, 10, 9)
cy.clock(now, ['Date']) cy.clock(now, ['Date'])
cy.visit('/projects/1/gantt') cy.visit('/lists/1/gantt')
cy.get('.project-gantt .gantt-options .field .control input.input.form-control') cy.get('.list-gantt .gantt-options .field .control input.input.form-control')
.click() .click()
cy.get('.flatpickr-calendar .flatpickr-innerContainer .dayContainer .flatpickr-day') cy.get('.flatpickr-calendar .flatpickr-innerContainer .dayContainer .flatpickr-day')
.first() .first()
@ -99,13 +99,13 @@ describe('Project View Gantt', () => {
}) })
it('Should change the date range based on date query parameters', () => { 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('/lists/1/gantt?dateFrom=2022-09-25&dateTo=2022-11-05')
cy.get('.g-timeunits-container') cy.get('.g-timeunits-container')
.should('contain', 'September 2022') .should('contain', 'September 2022')
.should('contain', 'October 2022') .should('contain', 'October 2022')
.should('contain', 'November 2022') .should('contain', 'November 2022')
cy.get('.project-gantt .gantt-options .field .control input.input.form-control') cy.get('.list-gantt .gantt-options .field .control input.input.form-control')
.should('have.value', '25 Sep 2022 to 5 Nov 2022') .should('have.value', '25 Sep 2022 to 5 Nov 2022')
}) })
@ -115,7 +115,7 @@ describe('Project View Gantt', () => {
start_date: formatISO(now), start_date: formatISO(now),
end_date: formatISO(now.setDate(now.getDate() + 4)), end_date: formatISO(now.setDate(now.getDate() + 4)),
}) })
cy.visit('/projects/1/gantt') cy.visit('/lists/1/gantt')
cy.get('.gantt-container .g-gantt-chart .g-gantt-row-bars-container .g-gantt-bar') cy.get('.gantt-container .g-gantt-chart .g-gantt-row-bars-container .g-gantt-bar')
.dblclick() .dblclick()

View File

@ -1,13 +1,13 @@
import {createFakeUserAndLogin} from '../../support/authenticateUser' import {createFakeUserAndLogin} from '../../support/authenticateUser'
import {BucketFactory} from '../../factories/bucket' import {BucketFactory} from '../../factories/bucket'
import {ProjectFactory} from '../../factories/project' import {ListFactory} from '../../factories/list'
import {TaskFactory} from '../../factories/task' import {TaskFactory} from '../../factories/task'
import {prepareProjects} from './prepareProjects' import {prepareLists} from './prepareLists'
describe('Project View Kanban', () => { describe('List View Kanban', () => {
createFakeUserAndLogin() createFakeUserAndLogin()
prepareProjects() prepareLists()
let buckets let buckets
beforeEach(() => { beforeEach(() => {
@ -16,10 +16,10 @@ describe('Project View Kanban', () => {
it('Shows all buckets with their tasks', () => { it('Shows all buckets with their tasks', () => {
const data = TaskFactory.create(10, { const data = TaskFactory.create(10, {
project_id: 1, list_id: 1,
bucket_id: 1, bucket_id: 1,
}) })
cy.visit('/projects/1/kanban') cy.visit('/lists/1/kanban')
cy.get('.kanban .bucket .title') cy.get('.kanban .bucket .title')
.contains(buckets[0].title) .contains(buckets[0].title)
@ -34,10 +34,10 @@ describe('Project View Kanban', () => {
it('Can add a new task to a bucket', () => { it('Can add a new task to a bucket', () => {
TaskFactory.create(2, { TaskFactory.create(2, {
project_id: 1, list_id: 1,
bucket_id: 1, bucket_id: 1,
}) })
cy.visit('/projects/1/kanban') cy.visit('/lists/1/kanban')
cy.get('.kanban .bucket') cy.get('.kanban .bucket')
.contains(buckets[0].title) .contains(buckets[0].title)
@ -55,7 +55,7 @@ describe('Project View Kanban', () => {
}) })
it('Can create a new bucket', () => { it('Can create a new bucket', () => {
cy.visit('/projects/1/kanban') cy.visit('/lists/1/kanban')
cy.get('.kanban .bucket.new-bucket .button') cy.get('.kanban .bucket.new-bucket .button')
.click() .click()
@ -69,7 +69,7 @@ describe('Project View Kanban', () => {
}) })
it('Can set a bucket limit', () => { it('Can set a bucket limit', () => {
cy.visit('/projects/1/kanban') cy.visit('/lists/1/kanban')
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-trigger') cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-trigger')
.first() .first()
@ -90,7 +90,7 @@ describe('Project View Kanban', () => {
}) })
it('Can rename a bucket', () => { it('Can rename a bucket', () => {
cy.visit('/projects/1/kanban') cy.visit('/lists/1/kanban')
cy.get('.kanban .bucket .bucket-header .title') cy.get('.kanban .bucket .bucket-header .title')
.first() .first()
@ -101,7 +101,7 @@ describe('Project View Kanban', () => {
}) })
it('Can delete a bucket', () => { it('Can delete a bucket', () => {
cy.visit('/projects/1/kanban') cy.visit('/lists/1/kanban')
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-trigger') cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-trigger')
.first() .first()
@ -125,10 +125,10 @@ describe('Project View Kanban', () => {
it('Can drag tasks around', () => { it('Can drag tasks around', () => {
const tasks = TaskFactory.create(2, { const tasks = TaskFactory.create(2, {
project_id: 1, list_id: 1,
bucket_id: 1, bucket_id: 1,
}) })
cy.visit('/projects/1/kanban') cy.visit('/lists/1/kanban')
cy.get('.kanban .bucket .tasks .task') cy.get('.kanban .bucket .tasks .task')
.contains(tasks[0].title) .contains(tasks[0].title)
@ -144,10 +144,10 @@ describe('Project View Kanban', () => {
it('Should navigate to the task when the task card is clicked', () => { it('Should navigate to the task when the task card is clicked', () => {
const tasks = TaskFactory.create(5, { const tasks = TaskFactory.create(5, {
id: '{increment}', id: '{increment}',
project_id: 1, list_id: 1,
bucket_id: 1, bucket_id: 1,
}) })
cy.visit('/projects/1/kanban') cy.visit('/lists/1/kanban')
cy.get('.kanban .bucket .tasks .task') cy.get('.kanban .bucket .tasks .task')
.contains(tasks[0].title) .contains(tasks[0].title)
@ -158,18 +158,18 @@ describe('Project View Kanban', () => {
.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', () => { it('Should remove a task from the kanban board when moving it to another list', () => {
const projects = ProjectFactory.create(2) const lists = ListFactory.create(2)
BucketFactory.create(2, { BucketFactory.create(2, {
project_id: '{increment}', list_id: '{increment}',
}) })
const tasks = TaskFactory.create(5, { const tasks = TaskFactory.create(5, {
id: '{increment}', id: '{increment}',
project_id: 1, list_id: 1,
bucket_id: 1, bucket_id: 1,
}) })
const task = tasks[0] const task = tasks[0]
cy.visit('/projects/1/kanban') cy.visit('/lists/1/kanban')
cy.get('.kanban .bucket .tasks .task') cy.get('.kanban .bucket .tasks .task')
.contains(task.title) .contains(task.title)
@ -180,7 +180,7 @@ describe('Project View Kanban', () => {
.contains('Move') .contains('Move')
.click() .click()
cy.get('.task-view .content.details .field .multiselect.control .input-wrapper input') cy.get('.task-view .content.details .field .multiselect.control .input-wrapper input')
.type(`${projects[1].title}{enter}`) .type(`${lists[1].title}{enter}`)
// The requests happen with a 200ms timeout. Because of that, the results are not yet there when cypress // The requests happen with a 200ms timeout. Because of that, the results are not yet there when cypress
// presses enter and we can't simulate pressing on enter to select the item. // presses enter and we can't simulate pressing on enter to select the item.
cy.get('.task-view .content.details .field .multiselect.control .search-results') cy.get('.task-view .content.details .field .multiselect.control .search-results')
@ -197,26 +197,26 @@ describe('Project View Kanban', () => {
it('Shows a button to filter the kanban board', () => { it('Shows a button to filter the kanban board', () => {
const data = TaskFactory.create(10, { const data = TaskFactory.create(10, {
project_id: 1, list_id: 1,
bucket_id: 1, bucket_id: 1,
}) })
cy.visit('/projects/1/kanban') cy.visit('/lists/1/kanban')
cy.get('.project-kanban .filter-container .base-button') cy.get('.list-kanban .filter-container .base-button')
.should('exist') .should('exist')
}) })
it('Should remove a task from the board when deleting it', () => { it('Should remove a task from the board when deleting it', () => {
const projects = ProjectFactory.create(1) const lists = ListFactory.create(1)
const buckets = BucketFactory.create(2, { const buckets = BucketFactory.create(2, {
project_id: projects[0].id, list_id: lists[0].id,
}) })
const tasks = TaskFactory.create(5, { const tasks = TaskFactory.create(5, {
project_id: 1, list_id: 1,
bucket_id: buckets[0].id, bucket_id: buckets[0].id,
}) })
const task = tasks[0] const task = tasks[0]
cy.visit('/projects/1/kanban') cy.visit('/lists/1/kanban')
cy.get('.kanban .bucket .tasks .task') cy.get('.kanban .bucket .tasks .task')
.contains(task.title) .contains(task.title)

View File

@ -1,32 +1,32 @@
import {createFakeUserAndLogin} from '../../support/authenticateUser' import {createFakeUserAndLogin} from '../../support/authenticateUser'
import {UserProjectFactory} from '../../factories/users_project' import {UserListFactory} from '../../factories/users_list'
import {TaskFactory} from '../../factories/task' import {TaskFactory} from '../../factories/task'
import {UserFactory} from '../../factories/user' import {UserFactory} from '../../factories/user'
import {ProjectFactory} from '../../factories/project' import {ListFactory} from '../../factories/list'
import {prepareProjects} from './prepareProjects' import {prepareLists} from './prepareLists'
describe('Project View Project', () => { describe('List View List', () => {
createFakeUserAndLogin() createFakeUserAndLogin()
prepareProjects() prepareLists()
it('Should be an empty project', () => { it('Should be an empty list', () => {
cy.visit('/projects/1') cy.visit('/lists/1')
cy.url() cy.url()
.should('contain', '/projects/1/list') .should('contain', '/lists/1/list')
cy.get('.project-title') cy.get('.list-title')
.should('contain', 'First Project') .should('contain', 'First List')
cy.get('.project-title-dropdown') cy.get('.list-title-dropdown')
.should('exist') .should('exist')
cy.get('p') cy.get('p')
.contains('This project is currently empty.') .contains('This list is currently empty.')
.should('exist') .should('exist')
}) })
it('Should create a new task', () => { it('Should create a new task', () => {
const newTaskTitle = 'New task' const newTaskTitle = 'New task'
cy.visit('/projects/1') cy.visit('/lists/1')
cy.get('.task-add textarea') cy.get('.task-add textarea')
.type(newTaskTitle+'{enter}') .type(newTaskTitle+'{enter}')
cy.get('.tasks') cy.get('.tasks')
@ -36,9 +36,9 @@ describe('Project View Project', () => {
it('Should navigate to the task when the title is clicked', () => { it('Should navigate to the task when the title is clicked', () => {
const tasks = TaskFactory.create(5, { const tasks = TaskFactory.create(5, {
id: '{increment}', id: '{increment}',
project_id: 1, list_id: 1,
}) })
cy.visit('/projects/1/list') cy.visit('/lists/1/list')
cy.get('.tasks .task .tasktext') cy.get('.tasks .task .tasktext')
.contains(tasks[0].title) .contains(tasks[0].title)
@ -49,33 +49,33 @@ describe('Project View Project', () => {
.should('contain', `/tasks/${tasks[0].id}`) .should('contain', `/tasks/${tasks[0].id}`)
}) })
it('Should not see any elements for a project which is shared read only', () => { it('Should not see any elements for a list which is shared read only', () => {
UserFactory.create(2) UserFactory.create(2)
UserProjectFactory.create(1, { UserListFactory.create(1, {
project_id: 2, list_id: 2,
user_id: 1, user_id: 1,
right: 0, right: 0,
}) })
const projects = ProjectFactory.create(2, { const lists = ListFactory.create(2, {
owner_id: '{increment}', owner_id: '{increment}',
namespace_id: '{increment}', namespace_id: '{increment}',
}) })
cy.visit(`/projects/${projects[1].id}/`) cy.visit(`/lists/${lists[1].id}/`)
cy.get('.project-title-wrapper .icon') cy.get('.list-title-wrapper .icon')
.should('not.exist') .should('not.exist')
cy.get('input.input[placeholder="Add a new task..."') cy.get('input.input[placeholder="Add a new task..."')
.should('not.exist') .should('not.exist')
}) })
it('Should only show the color of a project in the navigation and not in the list view', () => { it('Should only show the color of a list in the navigation and not in the list view', () => {
const projects = ProjectFactory.create(1, { const lists = ListFactory.create(1, {
hex_color: '00db60', hex_color: '00db60',
}) })
TaskFactory.create(10, { TaskFactory.create(10, {
project_id: projects[0].id, list_id: lists[0].id,
}) })
cy.visit(`/projects/${projects[0].id}/`) cy.visit(`/lists/${lists[0].id}/`)
cy.get('.menu-list li .list-menu-link .color-bubble') cy.get('.menu-list li .list-menu-link .color-bubble')
.should('have.css', 'background-color', 'rgb(0, 219, 96)') .should('have.css', 'background-color', 'rgb(0, 219, 96)')
@ -87,9 +87,9 @@ describe('Project View Project', () => {
const tasks = TaskFactory.create(100, { const tasks = TaskFactory.create(100, {
id: '{increment}', id: '{increment}',
title: i => `task${i}`, title: i => `task${i}`,
project_id: 1, list_id: 1,
}) })
cy.visit('/projects/1/list') cy.visit('/lists/1/list')
cy.get('.tasks') cy.get('.tasks')
.should('contain', tasks[1].title) .should('contain', tasks[1].title)

View File

@ -2,37 +2,37 @@ import {createFakeUserAndLogin} from '../../support/authenticateUser'
import {TaskFactory} from '../../factories/task' import {TaskFactory} from '../../factories/task'
describe('Project View Table', () => { describe('List View Table', () => {
createFakeUserAndLogin() createFakeUserAndLogin()
it('Should show a table with tasks', () => { it('Should show a table with tasks', () => {
const tasks = TaskFactory.create(1) const tasks = TaskFactory.create(1)
cy.visit('/projects/1/table') cy.visit('/lists/1/table')
cy.get('.project-table table.table') cy.get('.list-table table.table')
.should('exist') .should('exist')
cy.get('.project-table table.table') cy.get('.list-table table.table')
.should('contain', tasks[0].title) .should('contain', tasks[0].title)
}) })
it('Should have working column switches', () => { it('Should have working column switches', () => {
TaskFactory.create(1) TaskFactory.create(1)
cy.visit('/projects/1/table') cy.visit('/lists/1/table')
cy.get('.project-table .filter-container .items .button') cy.get('.list-table .filter-container .items .button')
.contains('Columns') .contains('Columns')
.click() .click()
cy.get('.project-table .filter-container .card.columns-filter .card-content .fancycheckbox .check') cy.get('.list-table .filter-container .card.columns-filter .card-content .fancycheckbox .check')
.contains('Priority') .contains('Priority')
.click() .click()
cy.get('.project-table .filter-container .card.columns-filter .card-content .fancycheckbox .check') cy.get('.list-table .filter-container .card.columns-filter .card-content .fancycheckbox .check')
.contains('Done') .contains('Done')
.click() .click()
cy.get('.project-table table.table th') cy.get('.list-table table.table th')
.contains('Priority') .contains('Priority')
.should('exist') .should('exist')
cy.get('.project-table table.table th') cy.get('.list-table table.table th')
.contains('Done') .contains('Done')
.should('not.exist') .should('not.exist')
}) })
@ -40,11 +40,11 @@ describe('Project View Table', () => {
it('Should navigate to the task when the title is clicked', () => { it('Should navigate to the task when the title is clicked', () => {
const tasks = TaskFactory.create(5, { const tasks = TaskFactory.create(5, {
id: '{increment}', id: '{increment}',
project_id: 1, list_id: 1,
}) })
cy.visit('/projects/1/table') cy.visit('/lists/1/table')
cy.get('.project-table table.table') cy.get('.list-table table.table')
.contains(tasks[0].title) .contains(tasks[0].title)
.click() .click()

View File

@ -1,60 +1,58 @@
import {createFakeUserAndLogin} from '../../support/authenticateUser' import {createFakeUserAndLogin} from '../../support/authenticateUser'
import {TaskFactory} from '../../factories/task' import {TaskFactory} from '../../factories/task'
import {prepareProjects} from './prepareProjects' import {prepareLists} from './prepareLists'
describe('Projects', () => { describe('Lists', () => {
createFakeUserAndLogin() createFakeUserAndLogin()
let projects let lists
prepareProjects((newProjects) => (projects = newProjects)) prepareLists((newLists) => (lists = newLists))
it('Should create a new project', () => { it('Should create a new list', () => {
cy.visit('/') cy.visit('/')
cy.get('.namespace-title .dropdown-trigger') cy.get('.namespace-title .dropdown-trigger')
.click() .click()
cy.get('.namespace-title .dropdown .dropdown-item') cy.get('.namespace-title .dropdown .dropdown-item')
.contains('New project') .contains('New list')
.click() .click()
cy.url() cy.url()
.should('contain', '/projects/new/1') .should('contain', '/lists/new/1')
cy.get('.card-header-title') cy.get('.card-header-title')
.contains('New project') .contains('New list')
cy.get('input.input') cy.get('input.input')
.type('New Project') .type('New List')
cy.get('.button') cy.get('.button')
.contains('Create') .contains('Create')
.click() .click()
cy.get('.global-notification', { timeout: 1000 }) // Waiting until the request to create the new project is done cy.get('.global-notification', { timeout: 1000 }) // Waiting until the request to create the new list is done
.should('contain', 'Success') .should('contain', 'Success')
cy.url() cy.url()
.should('contain', '/projects/') .should('contain', '/lists/')
cy.get('.project-title') cy.get('.list-title')
.should('contain', 'New Project') .should('contain', 'New List')
}) })
it('Should redirect to a specific project view after visited', () => { it('Should redirect to a specific list view after visited', () => {
cy.intercept(Cypress.env('API_URL') + '/projects/*/buckets*').as('loadBuckets') cy.visit('/lists/1/kanban')
cy.visit('/projects/1/kanban')
cy.url() cy.url()
.should('contain', '/projects/1/kanban') .should('contain', '/lists/1/kanban')
cy.wait('@loadBuckets') cy.visit('/lists/1')
cy.visit('/projects/1')
cy.url() cy.url()
.should('contain', '/projects/1/kanban') .should('contain', '/lists/1/kanban')
}) })
it('Should rename the project in all places', () => { it('Should rename the list in all places', () => {
TaskFactory.create(5, { TaskFactory.create(5, {
id: '{increment}', id: '{increment}',
project_id: 1, list_id: 1,
}) })
const newProjectName = 'New project name' const newListName = 'New list name'
cy.visit('/projects/1') cy.visit('/lists/1')
cy.get('.project-title') cy.get('.list-title')
.should('contain', 'First Project') .should('contain', 'First List')
cy.get('.namespace-container .menu.namespaces-lists .menu-list li:first-child .dropdown .menu-list-dropdown-trigger') cy.get('.namespace-container .menu.namespaces-lists .menu-list li:first-child .dropdown .menu-list-dropdown-trigger')
.click() .click()
@ -62,27 +60,27 @@ describe('Projects', () => {
.contains('Edit') .contains('Edit')
.click() .click()
cy.get('#title') cy.get('#title')
.type(`{selectall}${newProjectName}`) .type(`{selectall}${newListName}`)
cy.get('footer.card-footer .button') cy.get('footer.card-footer .button')
.contains('Save') .contains('Save')
.click() .click()
cy.get('.global-notification') cy.get('.global-notification')
.should('contain', 'Success') .should('contain', 'Success')
cy.get('.project-title') cy.get('.list-title')
.should('contain', newProjectName) .should('contain', newListName)
.should('not.contain', projects[0].title) .should('not.contain', lists[0].title)
cy.get('.namespace-container .menu.namespaces-lists .menu-list li:first-child') cy.get('.namespace-container .menu.namespaces-lists .menu-list li:first-child')
.should('contain', newProjectName) .should('contain', newListName)
.should('not.contain', projects[0].title) .should('not.contain', lists[0].title)
cy.visit('/') cy.visit('/')
cy.get('.card-content') cy.get('.card-content')
.should('contain', newProjectName) .should('contain', newListName)
.should('not.contain', projects[0].title) .should('not.contain', lists[0].title)
}) })
it('Should remove a project', () => { it('Should remove a list', () => {
cy.visit(`/projects/${projects[0].id}`) cy.visit(`/lists/${lists[0].id}`)
cy.get('.namespace-container .menu.namespaces-lists .menu-list li:first-child .dropdown .menu-list-dropdown-trigger') cy.get('.namespace-container .menu.namespaces-lists .menu-list li:first-child .dropdown .menu-list-dropdown-trigger')
.click() .click()
@ -98,27 +96,27 @@ describe('Projects', () => {
cy.get('.global-notification') cy.get('.global-notification')
.should('contain', 'Success') .should('contain', 'Success')
cy.get('.namespace-container .menu.namespaces-lists .menu-list') cy.get('.namespace-container .menu.namespaces-lists .menu-list')
.should('not.contain', projects[0].title) .should('not.contain', lists[0].title)
cy.location('pathname') cy.location('pathname')
.should('equal', '/') .should('equal', '/')
}) })
it('Should archive a project', () => { it('Should archive a list', () => {
cy.visit(`/projects/${projects[0].id}`) cy.visit(`/lists/${lists[0].id}`)
cy.get('.project-title-dropdown') cy.get('.list-title-dropdown')
.click() .click()
cy.get('.project-title-dropdown .dropdown-menu .dropdown-item') cy.get('.list-title-dropdown .dropdown-menu .dropdown-item')
.contains('Archive') .contains('Archive')
.click() .click()
cy.get('.modal-content') cy.get('.modal-content')
.should('contain.text', 'Archive this project') .should('contain.text', 'Archive this list')
cy.get('.modal-content [data-cy=modalPrimary]') cy.get('.modal-content [data-cy=modalPrimary]')
.click() .click()
cy.get('.namespace-container .menu.namespaces-lists .menu-list') cy.get('.namespace-container .menu.namespaces-lists .menu-list')
.should('not.contain', projects[0].title) .should('not.contain', lists[0].title)
cy.get('main.app-content') cy.get('main.app-content')
.should('contain.text', 'This project is archived. It is not possible to create new or edit tasks for it.') .should('contain.text', 'This list is archived. It is not possible to create new or edit tasks for it.')
}) })
}) })

View File

@ -1,6 +1,6 @@
import {createFakeUserAndLogin} from '../../support/authenticateUser' import {createFakeUserAndLogin} from '../../support/authenticateUser'
import {ProjectFactory} from '../../factories/project' import {ListFactory} from '../../factories/list'
import {NamespaceFactory} from '../../factories/namespace' import {NamespaceFactory} from '../../factories/namespace'
describe('Namepaces', () => { describe('Namepaces', () => {
@ -10,7 +10,7 @@ describe('Namepaces', () => {
beforeEach(() => { beforeEach(() => {
namespaces = NamespaceFactory.create(1) namespaces = NamespaceFactory.create(1)
ProjectFactory.create(1) ListFactory.create(1)
}) })
it('Should be all there', () => { it('Should be all there', () => {
@ -99,17 +99,17 @@ describe('Namepaces', () => {
.should('not.contain', newNamespaces[0].title) .should('not.contain', newNamespaces[0].title)
}) })
it('Should not show archived projects & namespaces if the filter is not checked', () => { it('Should not show archived lists & namespaces if the filter is not checked', () => {
const n = NamespaceFactory.create(1, { const n = NamespaceFactory.create(1, {
id: 2, id: 2,
is_archived: true, is_archived: true,
}, false) }, false)
ProjectFactory.create(1, { ListFactory.create(1, {
id: 2, id: 2,
namespace_id: n[0].id, namespace_id: n[0].id,
}, false) }, false)
ProjectFactory.create(1, { ListFactory.create(1, {
id: 3, id: 3,
is_archived: true, is_archived: true,
}, false) }, false)

View File

@ -0,0 +1,19 @@
import {ListFactory} from '../../factories/list'
import {NamespaceFactory} from '../../factories/namespace'
import {TaskFactory} from '../../factories/task'
export function createLists() {
NamespaceFactory.create(1)
const lists = ListFactory.create(1, {
title: 'First List'
})
TaskFactory.truncate()
return lists
}
export function prepareLists(setLists = (...args: any[]) => {}) {
beforeEach(() => {
const lists = createLists()
setLists(lists)
})
}

View File

@ -1,20 +1,18 @@
import {createFakeUserAndLogin} from '../../support/authenticateUser' import {createFakeUserAndLogin} from '../../support/authenticateUser'
import {TaskFactory} from '../../factories/task' import {TaskFactory} from '../../factories/task'
import {ProjectFactory} from '../../factories/project' import {ListFactory} from '../../factories/list'
import {NamespaceFactory} from '../../factories/namespace' import {NamespaceFactory} from '../../factories/namespace'
import {UserProjectFactory} from '../../factories/users_project' import {UserListFactory} from '../../factories/users_list'
import {BucketFactory} from '../../factories/bucket'
describe('Editor', () => { describe('Editor', () => {
createFakeUserAndLogin() createFakeUserAndLogin()
beforeEach(() => { beforeEach(() => {
NamespaceFactory.create(1) NamespaceFactory.create(1)
ProjectFactory.create(1) ListFactory.create(1)
BucketFactory.create(1)
TaskFactory.truncate() TaskFactory.truncate()
UserProjectFactory.truncate() UserListFactory.truncate()
}) })
it('Has a preview with checkable checkboxes', () => { it('Has a preview with checkable checkboxes', () => {
@ -26,7 +24,6 @@ describe('Editor', () => {
* [ ] Checklist * [ ] Checklist
* [x] Checklist checked * [x] Checklist checked
`, `,
bucket_id: 1,
}) })
cy.visit(`/tasks/${tasks[0].id}`) cy.visit(`/tasks/${tasks[0].id}`)

View File

@ -1,19 +0,0 @@
import {ProjectFactory} from '../../factories/project'
import {NamespaceFactory} from '../../factories/namespace'
import {TaskFactory} from '../../factories/task'
export function createProjects() {
NamespaceFactory.create(1)
const projects = ProjectFactory.create(1, {
title: 'First Project'
})
TaskFactory.truncate()
return projects
}
export function prepareProjects(setProjects = (...args: any[]) => {}) {
beforeEach(() => {
const projects = createProjects()
setProjects(projects)
})
}

View File

@ -1,57 +0,0 @@
import {createFakeUserAndLogin} from '../../support/authenticateUser'
import {ProjectFactory} from '../../factories/project'
import {prepareProjects} from './prepareProjects'
describe('Project History', () => {
createFakeUserAndLogin()
prepareProjects()
it('should show a project history on the home page', () => {
cy.intercept(Cypress.env('API_URL') + '/namespaces*').as('loadNamespaces')
cy.intercept(Cypress.env('API_URL') + '/projects/*').as('loadProject')
const projects = ProjectFactory.create(6)
cy.visit('/')
cy.wait('@loadNamespaces')
cy.get('body')
.should('not.contain', 'Last viewed')
cy.visit(`/projects/${projects[0].id}`)
cy.wait('@loadNamespaces')
cy.wait('@loadProject')
cy.visit(`/projects/${projects[1].id}`)
cy.wait('@loadNamespaces')
cy.wait('@loadProject')
cy.visit(`/projects/${projects[2].id}`)
cy.wait('@loadNamespaces')
cy.wait('@loadProject')
cy.visit(`/projects/${projects[3].id}`)
cy.wait('@loadNamespaces')
cy.wait('@loadProject')
cy.visit(`/projects/${projects[4].id}`)
cy.wait('@loadNamespaces')
cy.wait('@loadProject')
cy.visit(`/projects/${projects[5].id}`)
cy.wait('@loadNamespaces')
cy.wait('@loadProject')
// cy.visit('/')
// cy.wait('@loadNamespaces')
// Not using cy.visit here to work around the redirect issue fixed in #1337
cy.get('nav.menu.top-menu a')
.contains('Overview')
.click()
cy.get('body')
.should('contain', 'Last viewed')
cy.get('[data-cy="projectCardGrid"]')
.should('not.contain', projects[0].title)
.should('contain', projects[1].title)
.should('contain', projects[2].title)
.should('contain', projects[3].title)
.should('contain', projects[4].title)
.should('contain', projects[5].title)
})
})

View File

@ -1,22 +1,22 @@
import {LinkShareFactory} from '../../factories/link_sharing' import {LinkShareFactory} from '../../factories/link_sharing'
import {ProjectFactory} from '../../factories/project' import {ListFactory} from '../../factories/list'
import {TaskFactory} from '../../factories/task' import {TaskFactory} from '../../factories/task'
describe('Link shares', () => { describe('Link shares', () => {
it('Can view a link share', () => { it('Can view a link share', () => {
const projects = ProjectFactory.create(1) const lists = ListFactory.create(1)
const tasks = TaskFactory.create(10, { const tasks = TaskFactory.create(10, {
project_id: projects[0].id list_id: lists[0].id
}) })
const linkShares = LinkShareFactory.create(1, { const linkShares = LinkShareFactory.create(1, {
project_id: projects[0].id, list_id: lists[0].id,
right: 0, right: 0,
}) })
cy.visit(`/share/${linkShares[0].hash}/auth`) cy.visit(`/share/${linkShares[0].hash}/auth`)
cy.get('h1.title') cy.get('h1.title')
.should('contain', projects[0].title) .should('contain', lists[0].title)
cy.get('input.input[placeholder="Add a new task..."') cy.get('input.input[placeholder="Add a new task..."')
.should('not.exist') .should('not.exist')
cy.get('.tasks') cy.get('.tasks')

View File

@ -1,6 +1,6 @@
import {createFakeUserAndLogin} from '../../support/authenticateUser' import {createFakeUserAndLogin} from '../../support/authenticateUser'
import {ProjectFactory} from '../../factories/project' import {ListFactory} from '../../factories/list'
import {seed} from '../../support/seed' import {seed} from '../../support/seed'
import {TaskFactory} from '../../factories/task' import {TaskFactory} from '../../factories/task'
import {NamespaceFactory} from '../../factories/namespace' import {NamespaceFactory} from '../../factories/namespace'
@ -9,9 +9,9 @@ import {updateUserSettings} from '../../support/updateUserSettings'
function seedTasks(numberOfTasks = 50, startDueDate = new Date()) { function seedTasks(numberOfTasks = 50, startDueDate = new Date()) {
NamespaceFactory.create(1) NamespaceFactory.create(1)
const project = ProjectFactory.create()[0] const list = ListFactory.create()[0]
BucketFactory.create(1, { BucketFactory.create(1, {
project_id: project.id, list_id: list.id,
}) })
const tasks = [] const tasks = []
let dueDate = startDueDate let dueDate = startDueDate
@ -20,7 +20,7 @@ function seedTasks(numberOfTasks = 50, startDueDate = new Date()) {
dueDate = new Date(new Date(dueDate).setDate(dueDate.getDate() + 2)) dueDate = new Date(new Date(dueDate).setDate(dueDate.getDate() + 2))
tasks.push({ tasks.push({
id: i + 1, id: i + 1,
project_id: project.id, list_id: list.id,
done: false, done: false,
created_by_id: 1, created_by_id: 1,
title: 'Test Task ' + i, title: 'Test Task ' + i,
@ -31,7 +31,7 @@ function seedTasks(numberOfTasks = 50, startDueDate = new Date()) {
}) })
} }
seed(TaskFactory.table, tasks) seed(TaskFactory.table, tasks)
return {tasks, project} return {tasks, list}
} }
describe('Home Page Task Overview', () => { describe('Home Page Task Overview', () => {
@ -73,7 +73,7 @@ describe('Home Page Task Overview', () => {
due_date: new Date().toISOString(), due_date: new Date().toISOString(),
}, false) }, false)
cy.visit(`/projects/${tasks[0].project_id}/list`) cy.visit(`/lists/${tasks[0].list_id}/list`)
cy.get('.tasks .task') cy.get('.tasks .task')
.first() .first()
.should('contain.text', newTaskTitle) .should('contain.text', newTaskTitle)
@ -90,7 +90,7 @@ describe('Home Page Task Overview', () => {
cy.visit('/') cy.visit('/')
cy.visit(`/projects/${tasks[0].project_id}/list`) cy.visit(`/lists/${tasks[0].list_id}/list`)
cy.get('.task-add textarea') cy.get('.task-add textarea')
.type(newTaskTitle+'{enter}') .type(newTaskTitle+'{enter}')
cy.visit('/') cy.visit('/')
@ -113,10 +113,10 @@ describe('Home Page Task Overview', () => {
.should('contain.text', newTaskTitle) .should('contain.text', newTaskTitle)
}) })
it('Should show a task without a due date added via default project at the bottom', () => { it('Should show a task without a due date added via default list at the bottom', () => {
const {project} = seedTasks(40) const {list} = seedTasks(40)
updateUserSettings({ updateUserSettings({
default_project_id: project.id, default_list_id: list.id,
overdue_tasks_reminders_time: '9:00', overdue_tasks_reminders_time: '9:00',
}) })
@ -131,23 +131,23 @@ describe('Home Page Task Overview', () => {
.should('contain.text', newTaskTitle) .should('contain.text', newTaskTitle)
}) })
it('Should show the cta buttons for new project when there are no tasks', () => { it('Should show the cta buttons for new list when there are no tasks', () => {
TaskFactory.truncate() TaskFactory.truncate()
cy.visit('/') cy.visit('/')
cy.get('.home.app-content .content') cy.get('.home.app-content .content')
.should('contain.text', 'You can create a new project for your new tasks:') .should('contain.text', 'You can create a new list for your new tasks:')
.should('contain.text', 'Or import your projects and tasks from other services into Vikunja:') .should('contain.text', 'Or import your lists and tasks from other services into Vikunja:')
}) })
it('Should not show the cta buttons for new project when there are tasks', () => { it('Should not show the cta buttons for new list when there are tasks', () => {
seedTasks() seedTasks()
cy.visit('/') cy.visit('/')
cy.get('.home.app-content .content') cy.get('.home.app-content .content')
.should('not.contain.text', 'You can create a new project for your new tasks:') .should('not.contain.text', 'You can create a new list for your new tasks:')
.should('not.contain.text', 'Or import your projects and tasks from other services into Vikunja:') .should('not.contain.text', 'Or import your lists and tasks from other services into Vikunja:')
}) })
}) })

View File

@ -1,11 +1,11 @@
import {createFakeUserAndLogin} from '../../support/authenticateUser' import {createFakeUserAndLogin} from '../../support/authenticateUser'
import {TaskFactory} from '../../factories/task' import {TaskFactory} from '../../factories/task'
import {ProjectFactory} from '../../factories/project' import {ListFactory} from '../../factories/list'
import {TaskCommentFactory} from '../../factories/task_comment' import {TaskCommentFactory} from '../../factories/task_comment'
import {UserFactory} from '../../factories/user' import {UserFactory} from '../../factories/user'
import {NamespaceFactory} from '../../factories/namespace' import {NamespaceFactory} from '../../factories/namespace'
import {UserProjectFactory} from '../../factories/users_project' import {UserListFactory} from '../../factories/users_list'
import {TaskAssigneeFactory} from '../../factories/task_assignee' import {TaskAssigneeFactory} from '../../factories/task_assignee'
import {LabelFactory} from '../../factories/labels' import {LabelFactory} from '../../factories/labels'
import {LabelTaskFactory} from '../../factories/label_task' import {LabelTaskFactory} from '../../factories/label_task'
@ -48,22 +48,22 @@ describe('Task', () => {
createFakeUserAndLogin() createFakeUserAndLogin()
let namespaces let namespaces
let projects let lists
let buckets let buckets
beforeEach(() => { beforeEach(() => {
// UserFactory.create(1) // UserFactory.create(1)
namespaces = NamespaceFactory.create(1) namespaces = NamespaceFactory.create(1)
projects = ProjectFactory.create(1) lists = ListFactory.create(1)
buckets = BucketFactory.create(1, { buckets = BucketFactory.create(1, {
project_id: projects[0].id, list_id: lists[0].id,
}) })
TaskFactory.truncate() TaskFactory.truncate()
UserProjectFactory.truncate() UserListFactory.truncate()
}) })
it('Should be created new', () => { it('Should be created new', () => {
cy.visit('/projects/1/list') cy.visit('/lists/1/list')
cy.get('.input[placeholder="Add a new task…"') cy.get('.input[placeholder="Add a new task…"')
.type('New Task') .type('New Task')
cy.get('.button') cy.get('.button')
@ -74,11 +74,11 @@ describe('Task', () => {
.should('contain', 'New Task') .should('contain', 'New Task')
}) })
it('Inserts new tasks at the top of the project', () => { it('Inserts new tasks at the top of the list', () => {
TaskFactory.create(1) TaskFactory.create(1)
cy.visit('/projects/1/list') cy.visit('/lists/1/list')
cy.get('.project-is-empty-notice') cy.get('.list-is-empty-notice')
.should('not.exist') .should('not.exist')
cy.get('.input[placeholder="Add a new task…"') cy.get('.input[placeholder="Add a new task…"')
.type('New Task') .type('New Task')
@ -95,7 +95,7 @@ describe('Task', () => {
it('Marks a task as done', () => { it('Marks a task as done', () => {
TaskFactory.create(1) TaskFactory.create(1)
cy.visit('/projects/1/list') cy.visit('/lists/1/list')
cy.get('.tasks .task .fancycheckbox label.check') cy.get('.tasks .task .fancycheckbox label.check')
.first() .first()
.click() .click()
@ -106,7 +106,7 @@ describe('Task', () => {
it('Can add a task to favorites', () => { it('Can add a task to favorites', () => {
TaskFactory.create(1) TaskFactory.create(1)
cy.visit('/projects/1/list') cy.visit('/lists/1/list')
cy.get('.tasks .task .favorite') cy.get('.tasks .task .favorite')
.first() .first()
.click() .click()
@ -134,7 +134,7 @@ describe('Task', () => {
.should('contain', '#1') .should('contain', '#1')
cy.get('.task-view h6.subtitle') cy.get('.task-view h6.subtitle')
.should('contain', namespaces[0].title) .should('contain', namespaces[0].title)
.should('contain', projects[0].title) .should('contain', lists[0].title)
cy.get('.task-view .details.content.description') cy.get('.task-view .details.content.description')
.should('contain', tasks[0].description) .should('contain', tasks[0].description)
cy.get('.task-view .action-buttons p.created') cy.get('.task-view .action-buttons p.created')
@ -179,21 +179,21 @@ describe('Task', () => {
.should('contain', 'Mark as undone') .should('contain', 'Mark as undone')
}) })
it('Shows a task identifier since the project has one', () => { it('Shows a task identifier since the list has one', () => {
const projects = ProjectFactory.create(1, { const lists = ListFactory.create(1, {
id: 1, id: 1,
identifier: 'TEST', identifier: 'TEST',
}) })
const tasks = TaskFactory.create(1, { const tasks = TaskFactory.create(1, {
id: 1, id: 1,
project_id: projects[0].id, list_id: lists[0].id,
index: 1, index: 1,
}) })
cy.visit(`/tasks/${tasks[0].id}`) cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view h1.title.task-id') cy.get('.task-view h1.title.task-id')
.should('contain', `${projects[0].identifier}-${tasks[0].index}`) .should('contain', `${lists[0].identifier}-${tasks[0].index}`)
}) })
it('Can edit the description', () => { it('Can edit the description', () => {
@ -236,14 +236,14 @@ describe('Task', () => {
.should('contain', 'Success') .should('contain', 'Success')
}) })
it('Can move a task to another project', () => { it('Can move a task to another list', () => {
const projects = ProjectFactory.create(2) const lists = ListFactory.create(2)
BucketFactory.create(2, { BucketFactory.create(2, {
project_id: '{increment}' list_id: '{increment}'
}) })
const tasks = TaskFactory.create(1, { const tasks = TaskFactory.create(1, {
id: 1, id: 1,
project_id: projects[0].id, list_id: lists[0].id,
}) })
cy.visit(`/tasks/${tasks[0].id}`) cy.visit(`/tasks/${tasks[0].id}`)
@ -251,7 +251,7 @@ describe('Task', () => {
.contains('Move') .contains('Move')
.click() .click()
cy.get('.task-view .content.details .field .multiselect.control .input-wrapper input') cy.get('.task-view .content.details .field .multiselect.control .input-wrapper input')
.type(`${projects[1].title}{enter}`) .type(`${lists[1].title}{enter}`)
// The requests happen with a 200ms timeout. Because of that, the results are not yet there when cypress // The requests happen with a 200ms timeout. Because of that, the results are not yet there when cypress
// presses enter and we can't simulate pressing on enter to select the item. // presses enter and we can't simulate pressing on enter to select the item.
cy.get('.task-view .content.details .field .multiselect.control .search-results') cy.get('.task-view .content.details .field .multiselect.control .search-results')
@ -261,7 +261,7 @@ describe('Task', () => {
cy.get('.task-view h6.subtitle') cy.get('.task-view h6.subtitle')
.should('contain', namespaces[0].title) .should('contain', namespaces[0].title)
.should('contain', projects[1].title) .should('contain', lists[1].title)
cy.get('.global-notification') cy.get('.global-notification')
.should('contain', 'Success') .should('contain', 'Success')
}) })
@ -269,7 +269,7 @@ describe('Task', () => {
it('Can delete a task', () => { it('Can delete a task', () => {
const tasks = TaskFactory.create(1, { const tasks = TaskFactory.create(1, {
id: 1, id: 1,
project_id: 1, list_id: 1,
}) })
cy.visit(`/tasks/${tasks[0].id}`) cy.visit(`/tasks/${tasks[0].id}`)
@ -286,17 +286,17 @@ describe('Task', () => {
cy.get('.global-notification') cy.get('.global-notification')
.should('contain', 'Success') .should('contain', 'Success')
cy.url() cy.url()
.should('contain', `/projects/${tasks[0].project_id}/`) .should('contain', `/lists/${tasks[0].list_id}/`)
}) })
it('Can add an assignee to a task', () => { it('Can add an assignee to a task', () => {
const users = UserFactory.create(5) const users = UserFactory.create(5)
const tasks = TaskFactory.create(1, { const tasks = TaskFactory.create(1, {
id: 1, id: 1,
project_id: 1, list_id: 1,
}) })
UserProjectFactory.create(5, { UserListFactory.create(5, {
project_id: 1, list_id: 1,
user_id: '{increment}', user_id: '{increment}',
}) })
@ -321,10 +321,10 @@ describe('Task', () => {
const users = UserFactory.create(2) const users = UserFactory.create(2)
const tasks = TaskFactory.create(1, { const tasks = TaskFactory.create(1, {
id: 1, id: 1,
project_id: 1, list_id: 1,
}) })
UserProjectFactory.create(5, { UserListFactory.create(5, {
project_id: 1, list_id: 1,
user_id: '{increment}', user_id: '{increment}',
}) })
TaskAssigneeFactory.create(1, { TaskAssigneeFactory.create(1, {
@ -347,7 +347,7 @@ describe('Task', () => {
it('Can add a new label to a task', () => { it('Can add a new label to a task', () => {
const tasks = TaskFactory.create(1, { const tasks = TaskFactory.create(1, {
id: 1, id: 1,
project_id: 1, list_id: 1,
}) })
LabelFactory.truncate() LabelFactory.truncate()
const newLabelText = 'some new label' const newLabelText = 'some new label'
@ -375,7 +375,7 @@ describe('Task', () => {
it('Can add an existing label to a task', () => { it('Can add an existing label to a task', () => {
const tasks = TaskFactory.create(1, { const tasks = TaskFactory.create(1, {
id: 1, id: 1,
project_id: 1, list_id: 1,
}) })
const labels = LabelFactory.create(1) const labels = LabelFactory.create(1)
LabelTaskFactory.truncate() LabelTaskFactory.truncate()
@ -388,13 +388,13 @@ describe('Task', () => {
it('Can add a label to a task and it shows up on the kanban board afterwards', () => { it('Can add a label to a task and it shows up on the kanban board afterwards', () => {
const tasks = TaskFactory.create(1, { const tasks = TaskFactory.create(1, {
id: 1, id: 1,
project_id: projects[0].id, list_id: lists[0].id,
bucket_id: buckets[0].id, bucket_id: buckets[0].id,
}) })
const labels = LabelFactory.create(1) const labels = LabelFactory.create(1)
LabelTaskFactory.truncate() LabelTaskFactory.truncate()
cy.visit(`/projects/${projects[0].id}/kanban`) cy.visit(`/lists/${lists[0].id}/kanban`)
cy.get('.bucket .task') cy.get('.bucket .task')
.contains(tasks[0].title) .contains(tasks[0].title)
@ -412,7 +412,7 @@ describe('Task', () => {
it('Can remove a label from a task', () => { it('Can remove a label from a task', () => {
const tasks = TaskFactory.create(1, { const tasks = TaskFactory.create(1, {
id: 1, id: 1,
project_id: 1, list_id: 1,
}) })
const labels = LabelFactory.create(1) const labels = LabelFactory.create(1)
LabelTaskFactory.create(1, { LabelTaskFactory.create(1, {
@ -527,13 +527,13 @@ describe('Task', () => {
TaskAttachmentFactory.truncate() TaskAttachmentFactory.truncate()
const tasks = TaskFactory.create(1, { const tasks = TaskFactory.create(1, {
id: 1, id: 1,
project_id: projects[0].id, list_id: lists[0].id,
bucket_id: buckets[0].id, bucket_id: buckets[0].id,
}) })
const labels = LabelFactory.create(1) const labels = LabelFactory.create(1)
LabelTaskFactory.truncate() LabelTaskFactory.truncate()
cy.visit(`/projects/${projects[0].id}/kanban`) cy.visit(`/lists/${lists[0].id}/kanban`)
cy.get('.bucket .task') cy.get('.bucket .task')
.contains(tasks[0].title) .contains(tasks[0].title)

View File

@ -1,14 +1,12 @@
import {UserFactory} from '../../factories/user' import {UserFactory} from '../../factories/user'
const testAndAssertFailed = fixture => { const testAndAssertFailed = fixture => {
cy.intercept(Cypress.env('API_URL') + '/login*').as('login')
cy.visit('/login') cy.visit('/login')
cy.get('input[id=username]').type(fixture.username) cy.get('input[id=username]').type(fixture.username)
cy.get('input[id=password]').type(fixture.password) cy.get('input[id=password]').type(fixture.password)
cy.get('.button').contains('Login').click() cy.get('.button').contains('Login').click()
cy.wait('@login') cy.wait(5000) // It can take waaaayy too long to log the user in
cy.url().should('include', '/') cy.url().should('include', '/')
cy.get('div.message.danger').contains('Wrong username or password.') cy.get('div.message.danger').contains('Wrong username or password.')
} }

View File

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

View File

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

View File

@ -10,7 +10,7 @@ export class LinkShareFactory extends Factory {
return { return {
id: '{increment}', id: '{increment}',
hash: faker.random.word(32), hash: faker.random.word(32),
project_id: 1, list_id: 1,
right: 0, right: 0,
sharing_type: 0, sharing_type: 0,
shared_by_id: 1, shared_by_id: 1,

View File

@ -1,8 +1,8 @@
import {Factory} from '../support/factory' import {Factory} from '../support/factory'
import {faker} from '@faker-js/faker' import {faker} from '@faker-js/faker'
export class ProjectFactory extends Factory { export class ListFactory extends Factory {
static table = 'projects' static table = 'lists'
static factory() { static factory() {
const now = new Date() const now = new Date()

View File

@ -11,7 +11,7 @@ export class TaskFactory extends Factory {
id: '{increment}', id: '{increment}',
title: faker.lorem.words(3), title: faker.lorem.words(3),
done: false, done: false,
project_id: 1, list_id: 1,
created_by_id: 1, created_by_id: 1,
index: '{increment}', index: '{increment}',
position: '{increment}', position: '{increment}',

View File

@ -1,14 +1,14 @@
import {Factory} from '../support/factory' import {Factory} from '../support/factory'
export class UserProjectFactory extends Factory { export class UserListFactory extends Factory {
static table = 'users_projects' static table = 'users_lists'
static factory() { static factory() {
const now = new Date() const now = new Date()
return { return {
id: '{increment}', id: '{increment}',
project_id: 1, list_id: 1,
user_id: 1, user_id: 1,
right: 0, right: 0,
created: now.toISOString(), created: now.toISOString(),

View File

@ -28,20 +28,6 @@ server {
add_header Cache-Control "public, max-age=0, s-maxage=0, must-revalidate" always; add_header Cache-Control "public, max-age=0, s-maxage=0, must-revalidate" always;
try_files $uri /index.html =404; try_files $uri /index.html =404;
} }
# Disable caching for sw
location = /sw.js {
autoindex off;
expires off;
add_header Cache-Control "public, max-age=0, s-maxage=0, must-revalidate" always;
}
# Disable caching for webmanifest
location = /manifest.webmanifest {
autoindex off;
expires off;
add_header Cache-Control "public, max-age=0, s-maxage=0, must-revalidate" always;
}
# favicon.ico # favicon.ico
location = /favicon.ico { location = /favicon.ico {

View File

@ -30,21 +30,21 @@ A basic service can look like this:
```javascript ```javascript
import AbstractService from './abstractService' import AbstractService from './abstractService'
import ProjectModel from '../models/project' import ListModel from '../models/list'
export default class ProjectService extends AbstractService { export default class ListService extends AbstractService {
constructor() { constructor() {
super({ super({
getAll: '/projects', getAll: '/lists',
get: '/projects/{id}', get: '/lists/{id}',
create: '/namespaces/{namespaceID}/projects', create: '/namespaces/{namespaceID}/lists',
update: '/projects/{id}', update: '/lists/{id}',
delete: '/projects/{id}', delete: '/lists/{id}',
}) })
} }
modelFactory(data) { modelFactory(data) {
return new ProjectModel(data) return new ListModel(data)
} }
} }
``` ```
@ -132,7 +132,7 @@ import AbstractModel from './abstractModel'
import TaskModel from './task' import TaskModel from './task'
import UserModel from './user' import UserModel from './user'
export default class ProjectModel extends AbstractModel { export default class ListModel extends AbstractModel {
constructor(data) { constructor(data) {
// The constructor of AbstractModel handles all the default parsing. // The constructor of AbstractModel handles all the default parsing.

View File

@ -28,7 +28,7 @@ export default defineConfig({
// light: './img/light.png', // light: './img/light.png',
// dark: './img/dark.png', // dark: './img/dark.png',
// }, // },
logoHref: 'https://vikunja.io', // logoHref: 'https://acme.com',
// favicon: './favicon.ico', // favicon: './favicon.ico',
}, },
}) })

View File

@ -13,7 +13,7 @@
}, },
"homepage": "https://vikunja.io/", "homepage": "https://vikunja.io/",
"funding": "https://opencollective.com/vikunja", "funding": "https://opencollective.com/vikunja",
"packageManager": "pnpm@7.29.1", "packageManager": "pnpm@7.27.0",
"keywords": [ "keywords": [
"todo", "todo",
"productivity", "productivity",
@ -51,22 +51,22 @@
"@fortawesome/vue-fontawesome": "3.0.3", "@fortawesome/vue-fontawesome": "3.0.3",
"@github/hotkey": "2.0.1", "@github/hotkey": "2.0.1",
"@infectoone/vue-ganttastic": "2.1.4", "@infectoone/vue-ganttastic": "2.1.4",
"@intlify/unplugin-vue-i18n": "0.9.2", "@intlify/unplugin-vue-i18n": "0.8.2",
"@kyvg/vue3-notification": "2.9.0", "@kyvg/vue3-notification": "2.9.0",
"@sentry/tracing": "7.43.0", "@sentry/tracing": "7.37.2",
"@sentry/vue": "7.43.0", "@sentry/vue": "7.37.2",
"@types/is-touch-device": "1.0.0", "@types/is-touch-device": "1.0.0",
"@types/lodash.clonedeep": "4.5.7", "@types/lodash.clonedeep": "4.5.7",
"@types/sortablejs": "1.15.0", "@types/sortablejs": "1.15.0",
"@vueuse/core": "9.13.0", "@vueuse/core": "9.12.0",
"axios": "1.3.4", "axios": "1.3.3",
"blurhash": "2.0.5", "blurhash": "2.0.4",
"bulma-css-variables": "0.9.33", "bulma-css-variables": "0.9.33",
"camel-case": "4.1.2", "camel-case": "4.1.2",
"codemirror": "5.65.12", "codemirror": "5.65.11",
"date-fns": "2.29.3", "date-fns": "2.29.3",
"dayjs": "1.11.7", "dayjs": "1.11.7",
"dompurify": "3.0.1", "dompurify": "3.0.0",
"easymde": "2.18.0", "easymde": "2.18.0",
"fast-deep-equal": "3.1.3", "fast-deep-equal": "3.1.3",
"flatpickr": "4.6.13", "flatpickr": "4.6.13",
@ -78,11 +78,11 @@
"klona": "2.0.6", "klona": "2.0.6",
"lodash.debounce": "4.0.8", "lodash.debounce": "4.0.8",
"marked": "4.2.12", "marked": "4.2.12",
"pinia": "2.0.33", "pinia": "2.0.30",
"register-service-worker": "1.7.2", "register-service-worker": "1.7.2",
"snake-case": "3.0.4", "snake-case": "3.0.4",
"sortablejs": "1.15.0", "sortablejs": "1.15.0",
"ufo": "1.1.1", "ufo": "1.0.1",
"vue": "3.2.47", "vue": "3.2.47",
"vue-advanced-cropper": "2.8.8", "vue-advanced-cropper": "2.8.8",
"vue-flatpickr-component": "11.0.2", "vue-flatpickr-component": "11.0.2",
@ -93,59 +93,54 @@
}, },
"devDependencies": { "devDependencies": {
"@4tw/cypress-drag-drop": "2.2.3", "@4tw/cypress-drag-drop": "2.2.3",
"@cypress/vite-dev-server": "5.0.4", "@cypress/vite-dev-server": "5.0.2",
"@cypress/vue": "5.0.4", "@cypress/vue": "5.0.4",
"@faker-js/faker": "7.6.0", "@faker-js/faker": "7.6.0",
"@histoire/plugin-screenshot": "0.15.8", "@histoire/plugin-screenshot": "0.15.3",
"@histoire/plugin-vue": "0.15.8", "@histoire/plugin-vue": "0.15.3",
"@rushstack/eslint-patch": "1.2.0", "@rushstack/eslint-patch": "1.2.0",
"@types/codemirror": "5.60.7", "@types/codemirror": "5.60.7",
"@types/dompurify": "3.0.0", "@types/dompurify": "2.4.0",
"@types/flexsearch": "0.7.3", "@types/flexsearch": "0.7.3",
"@types/focus-within": "1.0.1", "@types/focus-within": "1.0.1",
"@types/lodash.debounce": "4.0.7", "@types/lodash.debounce": "4.0.7",
"@types/marked": "4.0.8", "@types/marked": "4.0.8",
"@types/node": "18.15.3", "@types/node": "18.13.0",
"@types/postcss-preset-env": "7.7.0", "@types/postcss-preset-env": "7.7.0",
"@typescript-eslint/eslint-plugin": "5.55.0", "@typescript-eslint/eslint-plugin": "5.52.0",
"@typescript-eslint/parser": "5.55.0", "@typescript-eslint/parser": "5.52.0",
"@vitejs/plugin-legacy": "4.0.1", "@vitejs/plugin-legacy": "4.0.1",
"@vitejs/plugin-vue": "4.0.0", "@vitejs/plugin-vue": "4.0.0",
"@vue/eslint-config-typescript": "11.0.2", "@vue/eslint-config-typescript": "11.0.2",
"@vue/test-utils": "2.3.1", "@vue/test-utils": "2.2.10",
"@vue/tsconfig": "0.1.3", "@vue/tsconfig": "0.1.3",
"autoprefixer": "10.4.14", "autoprefixer": "10.4.13",
"browserslist": "4.21.5", "browserslist": "4.21.5",
"caniuse-lite": "1.0.30001465", "caniuse-lite": "1.0.30001451",
"csstype": "3.1.1", "csstype": "3.1.1",
"cypress": "12.7.0", "cypress": "12.5.1",
"esbuild": "0.17.11", "esbuild": "0.17.8",
"eslint": "8.36.0", "eslint": "8.34.0",
"eslint-plugin-vue": "9.9.0", "eslint-plugin-vue": "9.9.0",
"happy-dom": "8.9.0", "happy-dom": "8.2.6",
"histoire": "0.15.8", "histoire": "0.15.3",
"netlify-cli": "13.1.2", "netlify-cli": "12.12.0",
"postcss": "8.4.21", "postcss": "8.4.21",
"postcss-easing-gradients": "3.0.1", "postcss-easing-gradients": "3.0.1",
"postcss-easings": "3.0.1", "postcss-easings": "3.0.1",
"postcss-preset-env": "8.0.1", "postcss-preset-env": "8.0.1",
"rollup": "3.19.1", "rollup": "3.15.0",
"rollup-plugin-visualizer": "5.9.0", "rollup-plugin-visualizer": "5.9.0",
"sass": "1.59.2", "sass": "1.58.1",
"start-server-and-test": "2.0.0", "start-server-and-test": "1.15.4",
"typescript": "4.9.5", "typescript": "4.9.5",
"vite": "4.1.4", "vite": "4.1.1",
"vite-plugin-inject-preload": "1.3.1", "vite-plugin-inject-preload": "1.3.0",
"vite-plugin-pwa": "0.14.4", "vite-plugin-pwa": "0.14.4",
"vite-svg-loader": "4.0.0", "vite-svg-loader": "4.0.0",
"vitest": "0.29.2", "vitest": "0.28.5",
"vue-tsc": "1.2.0", "vue-tsc": "1.1.0",
"wait-on": "7.0.1", "wait-on": "7.0.1",
"workbox-cli": "6.5.4" "workbox-cli": "6.5.4"
},
"pnpm": {
"patchedDependencies": {
"flexsearch@0.7.31": "patches/flexsearch@0.7.31.patch"
}
} }
} }

View File

@ -1,16 +0,0 @@
diff --git a/index.d.ts b/index.d.ts
deleted file mode 100644
index 9f39f41073864b83968bdaa242ac4e3c3149685a..0000000000000000000000000000000000000000
diff --git a/package.json b/package.json
index 8968f5bf8010ff194240591c8b83299f7328e79d..6d84b6f590a841b129ed8b3860cb786df5a185c0 100644
--- a/package.json
+++ b/package.json
@@ -22,8 +22,6 @@
},
"main": "dist/flexsearch.bundle.js",
"browser": "dist/flexsearch.bundle.js",
- "module": "dist/module/index.js",
- "types": "./index.d.ts",
"preferGlobal": false,
"repository": {
"type": "git",

File diff suppressed because it is too large Load Diff

View File

@ -1,50 +1,67 @@
<template> <template>
<header :class="{ 'has-background': background, 'menu-active': menuActive }" aria-label="main navigation" <header
class="navbar d-print-none"> :class="{'has-background': background, 'menu-active': menuActive}"
<router-link :to="{ name: 'home' }" class="logo-link"> aria-label="main navigation"
<Logo width="164" height="48" /> class="navbar d-print-none"
>
<router-link :to="{name: 'home'}" class="logo-link">
<Logo width="164" height="48"/>
</router-link> </router-link>
<MenuButton class="menu-button" /> <MenuButton class="menu-button"/>
<div v-if="currentProject.id" class="project-title-wrapper"> <div
<h1 class="project-title">{{ currentProject.title === '' ? $t('misc.loading') : getProjectTitle(currentProject) }} v-if="currentList.id"
</h1> class="list-title-wrapper"
>
<BaseButton :to="{ name: 'project.info', params: { projectId: currentProject.id } }" class="project-title-button"> <h1 class="list-title">{{ currentList.title === '' ? $t('misc.loading') : getListTitle(currentList) }}</h1>
<icon icon="circle-info" />
<BaseButton :to="{name: 'list.info', params: {listId: currentList.id}}" class="list-title-button">
<icon icon="circle-info"/>
</BaseButton> </BaseButton>
<project-settings-dropdown v-if="canWriteCurrentProject && currentProject.id !== -1" <list-settings-dropdown
class="project-title-dropdown" :project="currentProject"> v-if="canWriteCurrentList && currentList.id !== -1"
<template #trigger="{ toggleOpen }"> class="list-title-dropdown"
<BaseButton class="project-title-button" @click="toggleOpen"> :list="currentList"
<icon icon="ellipsis-h" class="icon" /> >
<template #trigger="{toggleOpen}">
<BaseButton class="list-title-button" @click="toggleOpen">
<icon icon="ellipsis-h" class="icon"/>
</BaseButton> </BaseButton>
</template> </template>
</project-settings-dropdown> </list-settings-dropdown>
</div> </div>
<div class="navbar-end"> <div class="navbar-end">
<BaseButton @click="openQuickActions" class="trigger-button" v-shortcut="'Control+k'" <BaseButton
:title="$t('keyboardShortcuts.quickSearch')"> @click="openQuickActions"
<icon icon="search" /> class="trigger-button"
v-shortcut="'Control+k'"
:title="$t('keyboardShortcuts.quickSearch')"
>
<icon icon="search"/>
</BaseButton> </BaseButton>
<Notifications /> <Notifications />
<dropdown> <dropdown>
<template #trigger="{ toggleOpen, open }"> <template #trigger="{toggleOpen, open}">
<BaseButton class="username-dropdown-trigger" @click="toggleOpen" variant="secondary" :shadow="false"> <BaseButton
<img :src="authStore.avatarUrl" alt="" class="avatar" width="40" height="40" /> class="username-dropdown-trigger"
@click="toggleOpen"
variant="secondary"
:shadow="false"
>
<img :src="authStore.avatarUrl" alt="" class="avatar" width="40" height="40"/>
<span class="username">{{ authStore.userDisplayName }}</span> <span class="username">{{ authStore.userDisplayName }}</span>
<span class="icon is-small" :style="{ <span class="icon is-small" :style="{
transform: open ? 'rotate(180deg)' : 'rotate(0)', transform: open ? 'rotate(180deg)' : 'rotate(0)',
}"> }">
<icon icon="chevron-down" /> <icon icon="chevron-down"/>
</span> </span>
</BaseButton> </BaseButton>
</template> </template>
<dropdown-item :to="{ name: 'user.settings' }"> <dropdown-item :to="{name: 'user.settings'}">
{{ $t('user.settings.title') }} {{ $t('user.settings.title') }}
</dropdown-item> </dropdown-item>
<dropdown-item v-if="imprintUrl" :href="imprintUrl"> <dropdown-item v-if="imprintUrl" :href="imprintUrl">
@ -56,7 +73,7 @@
<dropdown-item @click="baseStore.setKeyboardShortcutsActive(true)"> <dropdown-item @click="baseStore.setKeyboardShortcutsActive(true)">
{{ $t('keyboardShortcuts.title') }} {{ $t('keyboardShortcuts.title') }}
</dropdown-item> </dropdown-item>
<dropdown-item :to="{ name: 'about' }"> <dropdown-item :to="{name: 'about'}">
{{ $t('about.title') }} {{ $t('about.title') }}
</dropdown-item> </dropdown-item>
<dropdown-item @click="authStore.logout()"> <dropdown-item @click="authStore.logout()">
@ -68,11 +85,11 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import {computed} from 'vue'
import { RIGHTS as Rights } from '@/constants/rights' import {RIGHTS as Rights} from '@/constants/rights'
import ProjectSettingsDropdown from '@/components/project/project-settings-dropdown.vue' import ListSettingsDropdown from '@/components/list/list-settings-dropdown.vue'
import Dropdown from '@/components/misc/dropdown.vue' import Dropdown from '@/components/misc/dropdown.vue'
import DropdownItem from '@/components/misc/dropdown-item.vue' import DropdownItem from '@/components/misc/dropdown-item.vue'
import Notifications from '@/components/notifications/notifications.vue' import Notifications from '@/components/notifications/notifications.vue'
@ -80,16 +97,16 @@ import Logo from '@/components/home/Logo.vue'
import BaseButton from '@/components/base/BaseButton.vue' import BaseButton from '@/components/base/BaseButton.vue'
import MenuButton from '@/components/home/MenuButton.vue' import MenuButton from '@/components/home/MenuButton.vue'
import { getProjectTitle } from '@/helpers/getProjectTitle' import {getListTitle} from '@/helpers/getListTitle'
import { useBaseStore } from '@/stores/base' import {useBaseStore} from '@/stores/base'
import { useConfigStore } from '@/stores/config' import {useConfigStore} from '@/stores/config'
import { useAuthStore } from '@/stores/auth' import {useAuthStore} from '@/stores/auth'
const baseStore = useBaseStore() const baseStore = useBaseStore()
const currentProject = computed(() => baseStore.currentProject) const currentList = computed(() => baseStore.currentList)
const background = computed(() => baseStore.background) const background = computed(() => baseStore.background)
const canWriteCurrentProject = computed(() => baseStore.currentProject.maxRight > Rights.READ) const canWriteCurrentList = computed(() => baseStore.currentList.maxRight > Rights.READ)
const menuActive = computed(() => baseStore.menuActive) const menuActive = computed(() => baseStore.menuActive)
const authStore = useAuthStore() const authStore = useAuthStore()
@ -149,7 +166,7 @@ $user-dropdown-width-mobile: 5rem;
.logo-link { .logo-link {
display: none; display: none;
@media screen and (min-width: $tablet) { @media screen and (min-width: $tablet) {
align-self: stretch; align-self: stretch;
display: flex; display: flex;
@ -168,12 +185,12 @@ $user-dropdown-width-mobile: 5rem;
} }
} }
.project-title-wrapper { .list-title-wrapper {
margin-inline: auto; margin-inline: auto;
display: flex; display: flex;
align-items: center; align-items: center;
// this makes the truncated text of the project title work // this makes the truncated text of the list title work
// inside the flexbox parent // inside the flexbox parent
min-width: 0; min-width: 0;
@ -182,7 +199,7 @@ $user-dropdown-width-mobile: 5rem;
} }
} }
.project-title { .list-title {
font-size: 1rem; font-size: 1rem;
// We need the following for overflowing ellipsis to work // We need the following for overflowing ellipsis to work
text-overflow: ellipsis; text-overflow: ellipsis;
@ -194,15 +211,15 @@ $user-dropdown-width-mobile: 5rem;
} }
} }
.project-title-dropdown { .list-title-dropdown {
align-self: stretch; align-self: stretch;
.project-title-button { .list-title-button {
flex-grow: 1; flex-grow: 1;
} }
} }
.project-title-button { .list-title-button {
align-self: stretch; align-self: stretch;
min-width: var(--navbar-button-min-width); min-width: var(--navbar-button-min-width);
display: flex; display: flex;
@ -218,7 +235,7 @@ $user-dropdown-width-mobile: 5rem;
display: flex; display: flex;
align-items: stretch; align-items: stretch;
>* { > * {
min-width: var(--navbar-button-min-width); min-width: var(--navbar-button-min-width);
} }
} }

View File

@ -33,7 +33,7 @@
<quick-actions/> <quick-actions/>
<router-view :route="routeWithModal" v-slot="{ Component }"> <router-view :route="routeWithModal" v-slot="{ Component }">
<keep-alive :include="['project.list', 'project.gantt', 'project.table', 'project.kanban']"> <keep-alive :include="['list.list', 'list.gantt', 'list.table', 'list.kanban']">
<component :is="Component"/> <component :is="Component"/>
</keep-alive> </keep-alive>
</router-view> </router-view>
@ -87,7 +87,7 @@ function showKeyboardShortcuts() {
const route = useRoute() const route = useRoute()
// FIXME: this is really error prone // FIXME: this is really error prone
// Reset the current project highlight in menu if the current route is not project related. // Reset the current list highlight in menu if the current route is not list related.
watch(() => route.name as string, (routeName) => { watch(() => route.name as string, (routeName) => {
if ( if (
routeName && routeName &&
@ -106,7 +106,7 @@ watch(() => route.name as string, (routeName) => {
routeName.startsWith('user.settings') routeName.startsWith('user.settings')
) )
) { ) {
baseStore.handleSetCurrentProject({project: null}) baseStore.handleSetCurrentList({list: null})
} }
}) })
@ -156,8 +156,6 @@ labelStore.loadAllLabels()
z-index: 10; z-index: 10;
position: relative; position: relative;
padding: 1.5rem 0.5rem 1rem; padding: 1.5rem 0.5rem 1rem;
// TODO refactor: DRY `transition-timing-function` with `./navigation.vue`.
transition: margin-left $transition-duration;
@media screen and (max-width: $tablet) { @media screen and (max-width: $tablet) {
margin-left: 0; margin-left: 0;

View File

@ -9,9 +9,9 @@
<Logo class="logo" v-if="logoVisible"/> <Logo class="logo" v-if="logoVisible"/>
<h1 <h1
:class="{'m-0': !logoVisible}" :class="{'m-0': !logoVisible}"
:style="{ 'opacity': currentProject.title === '' ? '0': '1' }" :style="{ 'opacity': currentList.title === '' ? '0': '1' }"
class="title"> class="title">
{{ currentProject.title === '' ? $t('misc.loading') : currentProject.title }} {{ currentList.title === '' ? $t('misc.loading') : currentList.title }}
</h1> </h1>
<div class="box has-text-left view"> <div class="box has-text-left view">
<router-view/> <router-view/>
@ -31,7 +31,7 @@ import Logo from '@/components/home/Logo.vue'
import PoweredByLink from './PoweredByLink.vue' import PoweredByLink from './PoweredByLink.vue'
const baseStore = useBaseStore() const baseStore = useBaseStore()
const currentProject = computed(() => baseStore.currentProject) const currentList = computed(() => baseStore.currentList)
const background = computed(() => baseStore.background) const background = computed(() => baseStore.background)
const logoVisible = computed(() => baseStore.logoVisible) const logoVisible = computed(() => baseStore.logoVisible)
</script> </script>

View File

@ -52,37 +52,37 @@
<template v-for="(n, nk) in namespaces" :key="n.id"> <template v-for="(n, nk) in namespaces" :key="n.id">
<div class="namespace-title" :class="{'has-menu': n.id > 0}"> <div class="namespace-title" :class="{'has-menu': n.id > 0}">
<BaseButton <BaseButton
@click="toggleProjects(n.id)" @click="toggleLists(n.id)"
class="menu-label" class="menu-label"
v-tooltip="namespaceTitles[nk]" v-tooltip="namespaceTitles[nk]"
> >
<ColorBubble <ColorBubble
v-if="n.hexColor !== ''" v-if="n.hexColor !== ''"
:color="n.hexColor" :color="n.hexColor"
class="mr-1" class="mr-1"
/> />
<span class="name">{{ namespaceTitles[nk] }}</span> <span class="name">{{ namespaceTitles[nk] }}</span>
<div <div
class="icon menu-item-icon is-small toggle-lists-icon pl-2" class="icon menu-item-icon is-small toggle-lists-icon pl-2"
:class="{'active': typeof projectsVisible[n.id] !== 'undefined' ? projectsVisible[n.id] : true}" :class="{'active': typeof listsVisible[n.id] !== 'undefined' ? listsVisible[n.id] : true}"
> >
<icon icon="chevron-down"/> <icon icon="chevron-down"/>
</div> </div>
<span class="count" :class="{'ml-2 mr-0': n.id > 0}"> <span class="count" :class="{'ml-2 mr-0': n.id > 0}">
({{ namespaceProjectsCount[nk] }}) ({{ namespaceListsCount[nk] }})
</span> </span>
</BaseButton> </BaseButton>
<namespace-settings-dropdown class="menu-list-dropdown" :namespace="n" v-if="n.id > 0"/> <namespace-settings-dropdown class="menu-list-dropdown" :namespace="n" v-if="n.id > 0"/>
</div> </div>
<!-- <!--
NOTE: a v-model / computed setter is not possible, since the updateActiveProjects function NOTE: a v-model / computed setter is not possible, since the updateActiveLists function
triggered by the change needs to have access to the current namespace triggered by the change needs to have access to the current namespace
--> -->
<draggable <draggable
v-if="projectsVisible[n.id] ?? true" v-if="listsVisible[n.id] ?? true"
v-bind="dragOptions" v-bind="dragOptions"
:modelValue="activeProjects[nk]" :modelValue="activeLists[nk]"
@update:modelValue="(projects) => updateActiveProjects(n, projects)" @update:modelValue="(lists) => updateActiveLists(n, lists)"
group="namespace-lists" group="namespace-lists"
@start="() => drag = true" @start="() => drag = true"
@end="saveListPosition" @end="saveListPosition"
@ -100,46 +100,45 @@
{ 'dragging-disabled': n.id < 0 } { 'dragging-disabled': n.id < 0 }
] ]
}" }"
> >
<template #item="{element: l}"> <template #item="{element: l}">
<li <li
class="list-menu loader-container is-loading-small" class="list-menu loader-container is-loading-small"
:class="{'is-loading': projectUpdating[l.id]}" :class="{'is-loading': listUpdating[l.id]}"
>
<BaseButton
:to="{ name: 'project.index', params: { projectId: l.id} }"
class="list-menu-link"
:class="{'router-link-exact-active': currentProject.id === l.id}"
> >
<span class="icon menu-item-icon handle"> <BaseButton
<icon icon="grip-lines"/> :to="{ name: 'list.index', params: { listId: l.id} }"
</span> class="list-menu-link"
<ColorBubble :class="{'router-link-exact-active': currentList.id === l.id}"
>
<span class="icon menu-item-icon handle">
<icon icon="grip-lines"/>
</span>
<ColorBubble
v-if="l.hexColor !== ''" v-if="l.hexColor !== ''"
:color="l.hexColor" :color="l.hexColor"
class="mr-1" class="mr-1"
/> />
<span class="list-menu-title">{{ getProjectTitle(l) }}</span> <span class="list-menu-title">{{ getListTitle(l) }}</span>
</BaseButton> </BaseButton>
<BaseButton <BaseButton
v-if="l.id > 0" class="favorite"
class="favorite" :class="{'is-favorite': l.isFavorite}"
:class="{'is-favorite': l.isFavorite}" @click="listStore.toggleListFavorite(l)"
@click="projectStore.toggleProjectFavorite(l)" >
> <icon :icon="l.isFavorite ? 'star' : ['far', 'star']"/>
<icon :icon="l.isFavorite ? 'star' : ['far', 'star']"/> </BaseButton>
</BaseButton> <list-settings-dropdown class="menu-list-dropdown" :list="l" v-if="l.id > 0">
<ProjectSettingsDropdown class="menu-list-dropdown" :project="l" v-if="l.id > 0"> <template #trigger="{toggleOpen}">
<template #trigger="{toggleOpen}"> <BaseButton class="menu-list-dropdown-trigger" @click="toggleOpen">
<BaseButton class="menu-list-dropdown-trigger" @click="toggleOpen"> <icon icon="ellipsis-h" class="icon"/>
<icon icon="ellipsis-h" class="icon"/> </BaseButton>
</BaseButton> </template>
</template> </list-settings-dropdown>
</ProjectSettingsDropdown> <span class="list-setting-spacer" v-else></span>
<span class="list-setting-spacer" v-else></span> </li>
</li> </template>
</template> </draggable>
</draggable>
</template> </template>
</nav> </nav>
<PoweredByLink/> <PoweredByLink/>
@ -152,20 +151,20 @@ import draggable from 'zhyswan-vuedraggable'
import type {SortableEvent} from 'sortablejs' import type {SortableEvent} from 'sortablejs'
import BaseButton from '@/components/base/BaseButton.vue' import BaseButton from '@/components/base/BaseButton.vue'
import ProjectSettingsDropdown from '@/components/project/project-settings-dropdown.vue' import ListSettingsDropdown from '@/components/list/list-settings-dropdown.vue'
import NamespaceSettingsDropdown from '@/components/namespace/namespace-settings-dropdown.vue' import NamespaceSettingsDropdown from '@/components/namespace/namespace-settings-dropdown.vue'
import PoweredByLink from '@/components/home/PoweredByLink.vue' import PoweredByLink from '@/components/home/PoweredByLink.vue'
import Logo from '@/components/home/Logo.vue' import Logo from '@/components/home/Logo.vue'
import {calculateItemPosition} from '@/helpers/calculateItemPosition' import {calculateItemPosition} from '@/helpers/calculateItemPosition'
import {getNamespaceTitle} from '@/helpers/getNamespaceTitle' import {getNamespaceTitle} from '@/helpers/getNamespaceTitle'
import {getProjectTitle} from '@/helpers/getProjectTitle' import {getListTitle} from '@/helpers/getListTitle'
import type {IProject} from '@/modelTypes/IProject' import type {IList} from '@/modelTypes/IList'
import type {INamespace} from '@/modelTypes/INamespace' import type {INamespace} from '@/modelTypes/INamespace'
import ColorBubble from '@/components/misc/colorBubble.vue' import ColorBubble from '@/components/misc/colorBubble.vue'
import {useBaseStore} from '@/stores/base' import {useBaseStore} from '@/stores/base'
import {useProjectStore} from '@/stores/projects' import {useListStore} from '@/stores/lists'
import {useNamespaceStore} from '@/stores/namespaces' import {useNamespaceStore} from '@/stores/namespaces'
const drag = ref(false) const drag = ref(false)
@ -176,7 +175,7 @@ const dragOptions = {
const baseStore = useBaseStore() const baseStore = useBaseStore()
const namespaceStore = useNamespaceStore() const namespaceStore = useNamespaceStore()
const currentProject = computed(() => baseStore.currentProject) const currentList = computed(() => baseStore.currentList)
const menuActive = computed(() => baseStore.menuActive) const menuActive = computed(() => baseStore.menuActive)
const loading = computed(() => namespaceStore.isLoading) const loading = computed(() => namespaceStore.isLoading)
@ -184,9 +183,9 @@ const loading = computed(() => namespaceStore.isLoading)
const namespaces = computed(() => { const namespaces = computed(() => {
return namespaceStore.namespaces.filter(n => !n.isArchived) return namespaceStore.namespaces.filter(n => !n.isArchived)
}) })
const activeProjects = computed(() => { const activeLists = computed(() => {
return namespaces.value.map(({projects}) => { return namespaces.value.map(({lists}) => {
return projects?.filter(item => { return lists?.filter(item => {
return typeof item !== 'undefined' && !item.isArchived return typeof item !== 'undefined' && !item.isArchived
}) })
}) })
@ -196,45 +195,45 @@ const namespaceTitles = computed(() => {
return namespaces.value.map((namespace) => getNamespaceTitle(namespace)) return namespaces.value.map((namespace) => getNamespaceTitle(namespace))
}) })
const namespaceProjectsCount = computed(() => { const namespaceListsCount = computed(() => {
return namespaces.value.map((_, index) => activeProjects.value[index]?.length ?? 0) return namespaces.value.map((_, index) => activeLists.value[index]?.length ?? 0)
}) })
const projectStore = useProjectStore() const listStore = useListStore()
function toggleProjects(namespaceId: INamespace['id']) { function toggleLists(namespaceId: INamespace['id']) {
projectsVisible.value[namespaceId] = !projectsVisible.value[namespaceId] listsVisible.value[namespaceId] = !listsVisible.value[namespaceId]
} }
const projectsVisible = ref<{ [id: INamespace['id']]: boolean }>({}) const listsVisible = ref<{ [id: INamespace['id']]: boolean }>({})
// FIXME: async action will be unfinished when component mounts // FIXME: async action will be unfinished when component mounts
onBeforeMount(async () => { onBeforeMount(async () => {
const namespaces = await namespaceStore.loadNamespaces() const namespaces = await namespaceStore.loadNamespaces()
namespaces.forEach(n => { namespaces.forEach(n => {
if (typeof projectsVisible.value[n.id] === 'undefined') { if (typeof listsVisible.value[n.id] === 'undefined') {
projectsVisible.value[n.id] = true listsVisible.value[n.id] = true
} }
}) })
}) })
function updateActiveProjects(namespace: INamespace, activeProjects: IProject[]) { function updateActiveLists(namespace: INamespace, activeLists: IList[]) {
// This is a bit hacky: since we do have to filter out the archived items from the list // This is a bit hacky: since we do have to filter out the archived items from the list
// for vue draggable updating it is not as simple as replacing it. // for vue draggable updating it is not as simple as replacing it.
// To work around this, we merge the active projects with the archived ones. Doing so breaks the order // To work around this, we merge the active lists with the archived ones. Doing so breaks the order
// because now all archived projects are sorted after the active ones. This is fine because they are sorted // because now all archived lists are sorted after the active ones. This is fine because they are sorted
// later when showing them anyway, and it makes the merging happening here a lot easier. // later when showing them anyway, and it makes the merging happening here a lot easier.
const projects = [ const lists = [
...activeProjects, ...activeLists,
...namespace.projects.filter(l => l.isArchived), ...namespace.lists.filter(l => l.isArchived),
] ]
namespaceStore.setNamespaceById({ namespaceStore.setNamespaceById({
...namespace, ...namespace,
projects, lists,
}) })
} }
const projectUpdating = ref<{ [id: INamespace['id']]: boolean }>({}) const listUpdating = ref<{ [id: INamespace['id']]: boolean }>({})
async function saveListPosition(e: SortableEvent) { async function saveListPosition(e: SortableEvent) {
if (!e.newIndex && e.newIndex !== 0) return if (!e.newIndex && e.newIndex !== 0) return
@ -242,31 +241,31 @@ async function saveListPosition(e: SortableEvent) {
const namespaceId = parseInt(e.to.dataset.namespaceId as string) const namespaceId = parseInt(e.to.dataset.namespaceId as string)
const newNamespaceIndex = parseInt(e.to.dataset.namespaceIndex as string) const newNamespaceIndex = parseInt(e.to.dataset.namespaceIndex as string)
const projectsActive = activeProjects.value[newNamespaceIndex] const listsActive = activeLists.value[newNamespaceIndex]
// If the project was dragged to the last position, Safari will report e.newIndex as the size of the projectsActive // If the list was dragged to the last position, Safari will report e.newIndex as the size of the listsActive
// array instead of using the position. Because the index is wrong in that case, dragging the project will fail. // array instead of using the position. Because the index is wrong in that case, dragging the list will fail.
// To work around that we're explicitly checking that case here and decrease the index. // To work around that we're explicitly checking that case here and decrease the index.
const newIndex = e.newIndex === projectsActive.length ? e.newIndex - 1 : e.newIndex const newIndex = e.newIndex === listsActive.length ? e.newIndex - 1 : e.newIndex
const project = projectsActive[newIndex] const list = listsActive[newIndex]
const projectBefore = projectsActive[newIndex - 1] ?? null const listBefore = listsActive[newIndex - 1] ?? null
const projectAfter = projectsActive[newIndex + 1] ?? null const listAfter = listsActive[newIndex + 1] ?? null
projectUpdating.value[project.id] = true listUpdating.value[list.id] = true
const position = calculateItemPosition( const position = calculateItemPosition(
projectBefore !== null ? projectBefore.position : null, listBefore !== null ? listBefore.position : null,
projectAfter !== null ? projectAfter.position : null, listAfter !== null ? listAfter.position : null,
) )
try { try {
// create a copy of the project in order to not violate pinia manipulation // create a copy of the list in order to not violate pinia manipulation
await projectStore.updateProject({ await listStore.updateList({
...project, ...list,
position, position,
namespaceId, namespaceId,
}) })
} finally { } finally {
projectUpdating.value[project.id] = false listUpdating.value[list.id] = false
} }
} }
</script> </script>
@ -342,20 +341,13 @@ $vikunja-nav-selected-width: 0.4rem;
} }
.menu-list-dropdown { .menu-list-dropdown {
opacity: 1; opacity: 0;
transition: $transition; transition: $transition;
} }
@media(hover: hover) and (pointer: fine) { &:hover .menu-list-dropdown {
.menu-list-dropdown { opacity: 1;
opacity: 0;
}
&:hover .menu-list-dropdown {
opacity: 1;
}
} }
} }
.menu-item-icon { .menu-item-icon {
@ -419,21 +411,18 @@ $vikunja-nav-selected-width: 0.4rem;
opacity: 0; opacity: 0;
transition: opacity $transition; transition: opacity $transition;
margin-right: .25rem; margin-right: .25rem;
cursor: grab;
} }
&:hover .handle { &:hover .handle {
opacity: 1; opacity: 1;
} }
} }
&:not(.dragging-disabled) .handle {
cursor: grab;
}
} }
} }
.top-menu { .top-menu {
margin-top: math.div($navbar-padding, 2); margin-top: math.div($navbar-padding, 2);
.menu-list { .menu-list {
li { li {
font-weight: 600; font-weight: 600;
@ -488,24 +477,17 @@ $vikunja-nav-selected-width: 0.4rem;
.favorite { .favorite {
margin-left: .25rem; margin-left: .25rem;
transition: opacity $transition, color $transition; transition: opacity $transition, color $transition;
opacity: 1; opacity: 0;
&:hover,
&.is-favorite { &.is-favorite {
color: var(--warning); color: var(--warning);
opacity: 1;
} }
} }
.favorite.is-favorite,
@media(hover: hover) and (pointer: fine) { .list-menu:hover .favorite {
.list-menu .favorite { opacity: 1;
opacity: 0;
}
.list-menu:hover .favorite,
.favorite.is-favorite {
opacity: 1;
}
} }
.list-menu-title { .list-menu-title {

View File

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

View File

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

View File

@ -286,11 +286,11 @@ function handleCheckboxClick(e: Event) {
console.debug('no index found') console.debug('no index found')
return return
} }
const projectPrefix = text.value.substring(index, index + 1) const listPrefix = text.value.substring(index, index + 1)
console.debug({index, projectPrefix, checked, text: text.value}) console.debug({index, listPrefix, checked, text: text.value})
text.value = replaceAt(text.value, index, `${projectPrefix} ${checked ? '[x]' : '[ ]'} `) text.value = replaceAt(text.value, index, `${listPrefix} ${checked ? '[x]' : '[ ]'} `)
bubble() bubble()
renderPreview() renderPreview()
} }

View File

@ -7,7 +7,7 @@
@change="(event: Event) => updateData((event.target as HTMLInputElement).checked)" @change="(event: Event) => updateData((event.target as HTMLInputElement).checked)"
type="checkbox" type="checkbox"
/> />
<label :for="checkBoxId" class="check" @click.prevent="check"> <label :for="checkBoxId" class="check">
<svg height="18px" viewBox="0 0 18 18" width="18px"> <svg height="18px" viewBox="0 0 18 18" width="18px">
<path <path
d="M1,9 L1,3.5 C1,2 2,1 3.5,1 L14.5,1 C16,1 17,2 17,3.5 L17,14.5 C17,16 16,17 14.5,17 L3.5,17 C2,17 1,16 1,14.5 L1,9 Z"></path> d="M1,9 L1,3.5 C1,2 2,1 3.5,1 L14.5,1 C16,1 17,2 17,3.5 L17,14.5 C17,16 16,17 14.5,17 L3.5,17 C2,17 1,16 1,14.5 L1,9 Z"></path>
@ -56,11 +56,6 @@ function updateData(newChecked: boolean) {
emit('update:modelValue', newChecked) emit('update:modelValue', newChecked)
emit('change', newChecked) emit('change', newChecked)
} }
function check() {
checked.value = !checked.value
updateData(checked.value)
}
</script> </script>

View File

@ -0,0 +1,200 @@
<template>
<div
:class="{ 'is-loading': listService.loading, 'is-archived': currentList.isArchived}"
class="loader-container"
>
<div class="switch-view-container">
<div class="switch-view">
<BaseButton
v-shortcut="'g l'"
:title="$t('keyboardShortcuts.list.switchToListView')"
class="switch-view-button"
:class="{'is-active': viewName === 'list'}"
:to="{ name: 'list.list', params: { listId } }"
>
{{ $t('list.list.title') }}
</BaseButton>
<BaseButton
v-shortcut="'g g'"
:title="$t('keyboardShortcuts.list.switchToGanttView')"
class="switch-view-button"
:class="{'is-active': viewName === 'gantt'}"
:to="{ name: 'list.gantt', params: { listId } }"
>
{{ $t('list.gantt.title') }}
</BaseButton>
<BaseButton
v-shortcut="'g t'"
:title="$t('keyboardShortcuts.list.switchToTableView')"
class="switch-view-button"
:class="{'is-active': viewName === 'table'}"
:to="{ name: 'list.table', params: { listId } }"
>
{{ $t('list.table.title') }}
</BaseButton>
<BaseButton
v-shortcut="'g k'"
:title="$t('keyboardShortcuts.list.switchToKanbanView')"
class="switch-view-button"
:class="{'is-active': viewName === 'kanban'}"
:to="{ name: 'list.kanban', params: { listId } }"
>
{{ $t('list.kanban.title') }}
</BaseButton>
</div>
<slot name="header" />
</div>
<CustomTransition name="fade">
<Message variant="warning" v-if="currentList.isArchived" class="mb-4">
{{ $t('list.archived') }}
</Message>
</CustomTransition>
<slot v-if="loadedListId"/>
</div>
</template>
<script setup lang="ts">
import {ref, computed, watch} from 'vue'
import {useRoute} from 'vue-router'
import BaseButton from '@/components/base/BaseButton.vue'
import Message from '@/components/misc/message.vue'
import CustomTransition from '@/components/misc/CustomTransition.vue'
import ListModel from '@/models/list'
import ListService from '@/services/list'
import {getListTitle} from '@/helpers/getListTitle'
import {saveListToHistory} from '@/modules/listHistory'
import {useTitle} from '@/composables/useTitle'
import {useBaseStore} from '@/stores/base'
import {useListStore} from '@/stores/lists'
const props = defineProps({
listId: {
type: Number,
required: true,
},
viewName: {
type: String,
required: true,
},
})
const route = useRoute()
const baseStore = useBaseStore()
const listStore = useListStore()
const listService = ref(new ListService())
const loadedListId = ref(0)
const currentList = computed(() => {
return typeof baseStore.currentList === 'undefined' ? {
id: 0,
title: '',
isArchived: false,
maxRight: null,
} : baseStore.currentList
})
useTitle(() => currentList.value.id ? getListTitle(currentList.value) : '')
// watchEffect would be called every time the prop would get a value assigned, even if that value was the same as before.
// This resulted in loading and setting the list multiple times, even when navigating away from it.
// This caused wired bugs where the list background would be set on the home page but only right after setting a new
// list background and then navigating to home. It also highlighted the list in the menu and didn't allow changing any
// of it, most likely due to the rights not being properly populated.
watch(
() => props.listId,
// loadList
async (listIdToLoad: number) => {
const listData = {id: listIdToLoad}
saveListToHistory(listData)
// Don't load the list if we either already loaded it or aren't dealing with a list at all currently and
// the currently loaded list has the right set.
if (
(
listIdToLoad === loadedListId.value ||
typeof listIdToLoad === 'undefined' ||
listIdToLoad === currentList.value.id
)
&& typeof currentList.value !== 'undefined' && currentList.value.maxRight !== null
) {
loadedListId.value = props.listId
return
}
console.debug(`Loading list, props.viewName = ${props.viewName}, $route.params =`, route.params, `, loadedListId = ${loadedListId.value}, currentList = `, currentList.value)
// Set the current list to the one we're about to load so that the title is already shown at the top
loadedListId.value = 0
const listFromStore = listStore.getListById(listData.id)
if (listFromStore !== null) {
baseStore.setBackground(null)
baseStore.setBlurHash(null)
baseStore.handleSetCurrentList({list: listFromStore})
}
// We create an extra list object instead of creating it in list.value because that would trigger a ui update which would result in bad ux.
const list = new ListModel(listData)
try {
const loadedList = await listService.value.get(list)
baseStore.handleSetCurrentList({list: loadedList})
} finally {
loadedListId.value = props.listId
}
},
{immediate: true},
)
</script>
<style lang="scss" scoped>
.switch-view-container {
@media screen and (max-width: $tablet) {
display: flex;
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;
}
.switch-view-button {
padding: .25rem .5rem;
display: block;
border-radius: $radius;
transition: all 100ms;
&:not(:last-child) {
margin-right: .5rem;
}
&:hover {
color: var(--switch-view-color);
background: var(--primary);
}
&.is-active {
color: var(--switch-view-color);
background: var(--primary);
font-weight: bold;
box-shadow: var(--shadow-xs);
}
}
// FIXME: this should be in notification and set via a prop
.is-archived .notification.is-warning {
margin-bottom: 1rem;
}
</style>

View File

@ -8,24 +8,24 @@
</slot> </slot>
</template> </template>
<template v-if="isSavedFilter(project)"> <template v-if="isSavedFilter(list)">
<dropdown-item <dropdown-item
:to="{ name: 'filter.settings.edit', params: { projectId: project.id } }" :to="{ name: 'filter.settings.edit', params: { listId: list.id } }"
icon="pen" icon="pen"
> >
{{ $t('menu.edit') }} {{ $t('menu.edit') }}
</dropdown-item> </dropdown-item>
<dropdown-item <dropdown-item
:to="{ name: 'filter.settings.delete', params: { projectId: project.id } }" :to="{ name: 'filter.settings.delete', params: { listId: list.id } }"
icon="trash-alt" icon="trash-alt"
> >
{{ $t('misc.delete') }} {{ $t('misc.delete') }}
</dropdown-item> </dropdown-item>
</template> </template>
<template v-else-if="project.isArchived"> <template v-else-if="list.isArchived">
<dropdown-item <dropdown-item
:to="{ name: 'project.settings.archive', params: { projectId: project.id } }" :to="{ name: 'list.settings.archive', params: { listId: list.id } }"
icon="archive" icon="archive"
> >
{{ $t('menu.unarchive') }} {{ $t('menu.unarchive') }}
@ -33,32 +33,32 @@
</template> </template>
<template v-else> <template v-else>
<dropdown-item <dropdown-item
:to="{ name: 'project.settings.edit', params: { projectId: project.id } }" :to="{ name: 'list.settings.edit', params: { listId: list.id } }"
icon="pen" icon="pen"
> >
{{ $t('menu.edit') }} {{ $t('menu.edit') }}
</dropdown-item> </dropdown-item>
<dropdown-item <dropdown-item
v-if="backgroundsEnabled" v-if="backgroundsEnabled"
:to="{ name: 'project.settings.background', params: { projectId: project.id } }" :to="{ name: 'list.settings.background', params: { listId: list.id } }"
icon="image" icon="image"
> >
{{ $t('menu.setBackground') }} {{ $t('menu.setBackground') }}
</dropdown-item> </dropdown-item>
<dropdown-item <dropdown-item
:to="{ name: 'project.settings.share', params: { projectId: project.id } }" :to="{ name: 'list.settings.share', params: { listId: list.id } }"
icon="share-alt" icon="share-alt"
> >
{{ $t('menu.share') }} {{ $t('menu.share') }}
</dropdown-item> </dropdown-item>
<dropdown-item <dropdown-item
:to="{ name: 'project.settings.duplicate', params: { projectId: project.id } }" :to="{ name: 'list.settings.duplicate', params: { listId: list.id } }"
icon="paste" icon="paste"
> >
{{ $t('menu.duplicate') }} {{ $t('menu.duplicate') }}
</dropdown-item> </dropdown-item>
<dropdown-item <dropdown-item
:to="{ name: 'project.settings.archive', params: { projectId: project.id } }" :to="{ name: 'list.settings.archive', params: { listId: list.id } }"
icon="archive" icon="archive"
> >
{{ $t('menu.archive') }} {{ $t('menu.archive') }}
@ -66,14 +66,14 @@
<Subscription <Subscription
class="has-no-shadow" class="has-no-shadow"
:is-button="false" :is-button="false"
entity="project" entity="list"
:entity-id="project.id" :entity-id="list.id"
:model-value="project.subscription" :model-value="list.subscription"
@update:model-value="setSubscriptionInStore" @update:model-value="setSubscriptionInStore"
type="dropdown" type="dropdown"
/> />
<dropdown-item <dropdown-item
:to="{ name: 'project.settings.delete', params: { projectId: project.id } }" :to="{ name: 'list.settings.delete', params: { listId: list.id } }"
icon="trash-alt" icon="trash-alt"
class="has-text-danger" class="has-text-danger"
> >
@ -90,26 +90,26 @@ import BaseButton from '@/components/base/BaseButton.vue'
import Dropdown from '@/components/misc/dropdown.vue' import Dropdown from '@/components/misc/dropdown.vue'
import DropdownItem from '@/components/misc/dropdown-item.vue' import DropdownItem from '@/components/misc/dropdown-item.vue'
import Subscription from '@/components/misc/subscription.vue' import Subscription from '@/components/misc/subscription.vue'
import type {IProject} from '@/modelTypes/IProject' import type {IList} from '@/modelTypes/IList'
import type {ISubscription} from '@/modelTypes/ISubscription' import type {ISubscription} from '@/modelTypes/ISubscription'
import {isSavedFilter} from '@/services/savedFilter' import {isSavedFilter} from '@/services/savedFilter'
import {useConfigStore} from '@/stores/config' import {useConfigStore} from '@/stores/config'
import {useProjectStore} from '@/stores/projects' import {useListStore} from '@/stores/lists'
import {useNamespaceStore} from '@/stores/namespaces' import {useNamespaceStore} from '@/stores/namespaces'
const props = defineProps({ const props = defineProps({
project: { list: {
type: Object as PropType<IProject>, type: Object as PropType<IList>,
required: true, required: true,
}, },
}) })
const projectStore = useProjectStore() const listStore = useListStore()
const namespaceStore = useNamespaceStore() const namespaceStore = useNamespaceStore()
const subscription = ref<ISubscription | null>(null) const subscription = ref<ISubscription | null>(null)
watchEffect(() => { watchEffect(() => {
subscription.value = props.project.subscription ?? null subscription.value = props.list.subscription ?? null
}) })
const configStore = useConfigStore() const configStore = useConfigStore()
@ -117,11 +117,11 @@ const backgroundsEnabled = computed(() => configStore.enabledBackgroundProviders
function setSubscriptionInStore(sub: ISubscription) { function setSubscriptionInStore(sub: ISubscription) {
subscription.value = sub subscription.value = sub
const updatedProject = { const updatedList = {
...props.project, ...props.list,
subscription: sub, subscription: sub,
} }
projectStore.setProject(updatedProject) listStore.setList(updatedList)
namespaceStore.setProjectInNamespaceById(updatedProject) namespaceStore.setListInNamespaceById(updatedList)
} }
</script> </script>

View File

@ -1,39 +1,39 @@
<template> <template>
<div <div
class="project-card" class="list-card"
:class="{ :class="{
'has-light-text': background !== null, 'has-light-text': background !== null,
'has-background': blurHashUrl !== '' || background !== null 'has-background': blurHashUrl !== '' || background !== null
}" }"
:style="{ :style="{
'border-left': project.hexColor ? `0.25rem solid ${project.hexColor}` : undefined, 'border-left': list.hexColor ? `0.25rem solid ${list.hexColor}` : undefined,
'background-image': blurHashUrl !== '' ? `url(${blurHashUrl})` : undefined, 'background-image': blurHashUrl !== '' ? `url(${blurHashUrl})` : undefined,
}" }"
> >
<div <div
class="project-background background-fade-in" class="list-background background-fade-in"
:class="{'is-visible': background}" :class="{'is-visible': background}"
:style="{'background-image': background !== null ? `url(${background})` : undefined}" :style="{'background-image': background !== null ? `url(${background})` : undefined}"
/> />
<span v-if="project.isArchived" class="is-archived" >{{ $t('namespace.archived') }}</span> <span v-if="list.isArchived" class="is-archived" >{{ $t('namespace.archived') }}</span>
<div class="project-title" aria-hidden="true">{{ project.title }}</div> <div class="list-title" aria-hidden="true">{{ list.title }}</div>
<BaseButton <BaseButton
class="project-button" class="list-button"
:aria-label="project.title" :aria-label="list.title"
:title="project.description" :title="list.description"
:to="{ :to="{
name: 'project.index', name: 'list.index',
params: { projectId: project.id} params: { listId: list.id}
}" }"
/> />
<BaseButton <BaseButton
v-if="!project.isArchived" v-if="!list.isArchived"
class="favorite" class="favorite"
:class="{'is-favorite': project.isFavorite}" :class="{'is-favorite': list.isFavorite}"
@click.prevent.stop="projectStore.toggleProjectFavorite(project)" @click.prevent.stop="listStore.toggleListFavorite(list)"
> >
<icon :icon="project.isFavorite ? 'star' : ['far', 'star']" /> <icon :icon="list.isFavorite ? 'star' : ['far', 'star']" />
</BaseButton> </BaseButton>
</div> </div>
</template> </template>
@ -41,30 +41,30 @@
<script lang="ts" setup> <script lang="ts" setup>
import {toRef, type PropType} from 'vue' import {toRef, type PropType} from 'vue'
import type {IProject} from '@/modelTypes/IProject' import type {IList} from '@/modelTypes/IList'
import BaseButton from '@/components/base/BaseButton.vue' import BaseButton from '@/components/base/BaseButton.vue'
import {useProjectBackground} from './useProjectBackground' import {useListBackground} from './useListBackground'
import {useProjectStore} from '@/stores/projects' import {useListStore} from '@/stores/lists'
const props = defineProps({ const props = defineProps({
project: { list: {
type: Object as PropType<IProject>, type: Object as PropType<IList>,
required: true, required: true,
}, },
}) })
const {background, blurHashUrl} = useProjectBackground(toRef(props, 'project')) const {background, blurHashUrl} = useListBackground(toRef(props, 'list'))
const projectStore = useProjectStore() const listStore = useListStore()
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.project-card { .list-card {
--project-card-padding: 1rem; --list-card-padding: 1rem;
background: var(--white); background: var(--white);
padding: var(--project-card-padding); padding: var(--list-card-padding);
border-radius: $radius; border-radius: $radius;
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
transition: box-shadow $transition; transition: box-shadow $transition;
@ -91,14 +91,14 @@ const projectStore = useProjectStore()
} }
.has-background, .has-background,
.project-background { .list-background {
background-size: cover; background-size: cover;
background-repeat: no-repeat; background-repeat: no-repeat;
background-position: center; background-position: center;
} }
.project-background, .list-background,
.project-button { .list-button {
position: absolute; position: absolute;
top: 0; top: 0;
right: 0; right: 0;
@ -111,7 +111,7 @@ const projectStore = useProjectStore()
float: left; float: left;
} }
.project-title { .list-title {
align-self: flex-end; align-self: flex-end;
font-family: $vikunja-font; font-family: $vikunja-font;
font-weight: 400; font-weight: 400;
@ -120,7 +120,7 @@ const projectStore = useProjectStore()
color: var(--text); color: var(--text);
width: 100%; width: 100%;
margin-bottom: 0; margin-bottom: 0;
max-height: calc(100% - (var(--project-card-padding) + 1rem)); // padding & height of the "is archived" badge max-height: calc(100% - (var(--list-card-padding) + 1rem)); // padding & height of the "is archived" badge
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
word-break: break-word; word-break: break-word;
@ -130,11 +130,11 @@ const projectStore = useProjectStore()
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
} }
.has-light-text .project-title { .has-light-text .list-title {
color: var(--grey-100); color: var(--grey-100);
} }
.has-background .project-title { .has-background .list-title {
text-shadow: text-shadow:
0 0 10px var(--black), 0 0 10px var(--black),
1px 1px 5px var(--grey-700), 1px 1px 5px var(--grey-700),
@ -144,10 +144,10 @@ const projectStore = useProjectStore()
.favorite { .favorite {
position: absolute; position: absolute;
top: var(--project-card-padding); top: var(--list-card-padding);
right: var(--project-card-padding); right: var(--list-card-padding);
transition: opacity $transition, color $transition; transition: opacity $transition, color $transition;
opacity: 1; opacity: 0;
&:hover { &:hover {
color: var(--warning); color: var(--warning);
@ -160,14 +160,8 @@ const projectStore = useProjectStore()
} }
} }
@media(hover: hover) and (pointer: fine) { .list-card:hover .favorite {
.list-card .favorite { opacity: 1;
opacity: 0;
}
.project-card:hover .favorite {
opacity: 1;
}
} }
.background-fade-in { .background-fade-in {
@ -179,4 +173,4 @@ const projectStore = useProjectStore()
opacity: 1; opacity: 1;
} }
} }
</style> </style>

View File

@ -0,0 +1,77 @@
<template>
<ul class="list-grid">
<li
v-for="(item, index) in filteredLists"
:key="`list_${item.id}_${index}`"
class="list-grid-item"
>
<ListCard :list="item" />
</li>
</ul>
</template>
<script lang="ts" setup>
import {computed, type PropType} from 'vue'
import type {IList} from '@/modelTypes/IList'
import ListCard from './ListCard.vue'
const props = defineProps({
lists: {
type: Array as PropType<IList[]>,
default: () => [],
},
showArchived: {
default: false,
type: Boolean,
},
itemLimit: {
type: Boolean,
default: false,
},
})
const filteredLists = computed(() => {
return props.showArchived
? props.lists
: props.lists.filter(l => !l.isArchived)
})
</script>
<style lang="scss" scoped>
$list-height: 150px;
$list-spacing: 1rem;
.list-grid {
margin: 0; // reset li
list-style-type: none;
display: grid;
grid-template-columns: repeat(var(--list-columns), 1fr);
grid-auto-rows: $list-height;
gap: $list-spacing;
@media screen and (min-width: $mobile) {
--list-rows: 4;
--list-columns: 1;
}
@media screen and (min-width: $mobile) and (max-width: $tablet) {
--list-columns: 2;
}
@media screen and (min-width: $tablet) and (max-width: $widescreen) {
--list-columns: 3;
--list-rows: 3;
}
@media screen and (min-width: $widescreen) {
--list-columns: 5;
--list-rows: 2;
}
}
.list-grid-item {
display: grid;
margin-top: 0; // remove padding coming form .content li + li
}
</style>

View File

@ -32,7 +32,7 @@
<script setup lang="ts"> <script setup lang="ts">
import {computed, ref, watch} from 'vue' import {computed, ref, watch} from 'vue'
import Filters from '@/components/project/partials/filters.vue' import Filters from '@/components/list/partials/filters.vue'
import {getDefaultParams} from '@/composables/useTaskList' import {getDefaultParams} from '@/composables/useTaskList'

View File

@ -20,7 +20,7 @@
{{ $t('filters.attributes.showDoneTasks') }} {{ $t('filters.attributes.showDoneTasks') }}
</fancycheckbox> </fancycheckbox>
<fancycheckbox <fancycheckbox
v-if="!['project.kanban', 'project.table'].includes($route.name as string)" v-if="!['list.kanban', 'list.table'].includes($route.name as string)"
v-model="sortAlphabetically" v-model="sortAlphabetically"
@update:model-value="change()" @update:model-value="change()"
> >
@ -154,14 +154,14 @@
</div> </div>
<template <template
v-if="['filters.create', 'project.edit', 'filter.settings.edit'].includes($route.name as string)"> v-if="['filters.create', 'list.edit', 'filter.settings.edit'].includes($route.name as string)">
<div class="field"> <div class="field">
<label class="label">{{ $t('project.lists') }}</label> <label class="label">{{ $t('list.lists') }}</label>
<div class="control"> <div class="control">
<SelectProject <SelectList
v-model="entities.projects" v-model="entities.lists"
@select="changeMultiselectFilter('projects', 'project_id')" @select="changeMultiselectFilter('lists', 'list_id')"
@remove="changeMultiselectFilter('projects', 'project_id')" @remove="changeMultiselectFilter('lists', 'list_id')"
/> />
</div> </div>
</div> </div>
@ -190,7 +190,7 @@ import {camelCase} from 'camel-case'
import type {ILabel} from '@/modelTypes/ILabel' import type {ILabel} from '@/modelTypes/ILabel'
import type {IUser} from '@/modelTypes/IUser' import type {IUser} from '@/modelTypes/IUser'
import type {INamespace} from '@/modelTypes/INamespace' import type {INamespace} from '@/modelTypes/INamespace'
import type {IProject} from '@/modelTypes/IProject' import type {IList} from '@/modelTypes/IList'
import {useLabelStore} from '@/stores/labels' import {useLabelStore} from '@/stores/labels'
@ -200,7 +200,7 @@ import PercentDoneSelect from '@/components/tasks/partials/percentDoneSelect.vue
import EditLabels from '@/components/tasks/partials/editLabels.vue' import EditLabels from '@/components/tasks/partials/editLabels.vue'
import Fancycheckbox from '@/components/input/fancycheckbox.vue' import Fancycheckbox from '@/components/input/fancycheckbox.vue'
import SelectUser from '@/components/input/SelectUser.vue' import SelectUser from '@/components/input/SelectUser.vue'
import SelectProject from '@/components/input/SelectProject.vue' import SelectList from '@/components/input/SelectList.vue'
import SelectNamespace from '@/components/input/SelectNamespace.vue' import SelectNamespace from '@/components/input/SelectNamespace.vue'
import {parseDateOrString} from '@/helpers/time/parseDateOrString' import {parseDateOrString} from '@/helpers/time/parseDateOrString'
@ -208,13 +208,13 @@ import {dateIsValid, formatISO} from '@/helpers/time/formatDate'
import {objectToSnakeCase} from '@/helpers/case' import {objectToSnakeCase} from '@/helpers/case'
import UserService from '@/services/user' import UserService from '@/services/user'
import ProjectService from '@/services/project' import ListService from '@/services/list'
import NamespaceService from '@/services/namespace' import NamespaceService from '@/services/namespace'
// FIXME: do not use this here for now. instead create new version from DEFAULT_PARAMS // FIXME: do not use this here for now. instead create new version from DEFAULT_PARAMS
import {getDefaultParams} from '@/composables/useTaskList' import {getDefaultParams} from '@/composables/useTaskList'
// FIXME: merge with DEFAULT_PARAMS in taskProject.js // FIXME: merge with DEFAULT_PARAMS in taskList.js
const DEFAULT_PARAMS = { const DEFAULT_PARAMS = {
sort_by: [], sort_by: [],
order_by: [], order_by: [],
@ -239,7 +239,7 @@ const DEFAULT_FILTERS = {
reminders: '', reminders: '',
assignees: '', assignees: '',
labels: '', labels: '',
project_id: '', list_id: '',
namespace: '', namespace: '',
} as const } as const
@ -264,23 +264,23 @@ const filters = ref({...DEFAULT_FILTERS})
const services = { const services = {
users: shallowReactive(new UserService()), users: shallowReactive(new UserService()),
projects: shallowReactive(new ProjectService()), lists: shallowReactive(new ListService()),
namespace: shallowReactive(new NamespaceService()), namespace: shallowReactive(new NamespaceService()),
} }
interface Entities { interface Entities {
users: IUser[] users: IUser[]
labels: ILabel[] labels: ILabel[]
projects: IProject[] lists: IList[]
namespace: INamespace[] namespace: INamespace[]
} }
type EntityType = 'users' | 'labels' | 'projects' | 'namespace' type EntityType = 'users' | 'labels' | 'lists' | 'namespace'
const entities: Entities = reactive({ const entities: Entities = reactive({
users: [], users: [],
labels: [], labels: [],
projects: [], lists: [],
namespace: [], namespace: [],
}) })
@ -327,7 +327,7 @@ function prepareFilters() {
prepareSingleValue('percent_done', 'percentDone', 'usePercentDone', true) prepareSingleValue('percent_done', 'percentDone', 'usePercentDone', true)
prepareDate('reminders') prepareDate('reminders')
prepareRelatedObjectFilter('users', 'assignees') prepareRelatedObjectFilter('users', 'assignees')
prepareRelatedObjectFilter('projects', 'project_id') prepareRelatedObjectFilter('lists', 'list_id')
prepareRelatedObjectFilter('namespace') prepareRelatedObjectFilter('namespace')
prepareSingleValue('labels') prepareSingleValue('labels')

View File

@ -1,30 +1,30 @@
import {ref, watch, type Ref} from 'vue' import {ref, watch, type Ref} from 'vue'
import ProjectService from '@/services/project' import ListService from '@/services/list'
import type {IProject} from '@/modelTypes/IProject' import type {IList} from '@/modelTypes/IList'
import {getBlobFromBlurHash} from '@/helpers/getBlobFromBlurHash' import {getBlobFromBlurHash} from '@/helpers/getBlobFromBlurHash'
export function useProjectBackground(project: Ref<IProject>) { export function useListBackground(list: Ref<IList>) {
const background = ref<string | null>(null) const background = ref<string | null>(null)
const backgroundLoading = ref(false) const backgroundLoading = ref(false)
const blurHashUrl = ref('') const blurHashUrl = ref('')
watch( watch(
() => [project.value.id, project.value.backgroundBlurHash] as [IProject['id'], IProject['backgroundBlurHash']], () => [list.value.id, list.value.backgroundBlurHash] as [IList['id'], IList['backgroundBlurHash']],
async ([projectId, blurHash], oldValue) => { async ([listId, blurHash], oldValue) => {
if ( if (
project.value === null || list.value === null ||
!project.value.backgroundInformation || !list.value.backgroundInformation ||
backgroundLoading.value backgroundLoading.value
) { ) {
return return
} }
const [oldProjectId, oldBlurHash] = oldValue || [] const [oldListId, oldBlurHash] = oldValue || []
if ( if (
oldValue !== undefined && oldValue !== undefined &&
projectId === oldProjectId && blurHash === oldBlurHash listId === oldListId && blurHash === oldBlurHash
) { ) {
// project hasn't changed // list hasn't changed
return return
} }
@ -35,8 +35,8 @@ export function useProjectBackground(project: Ref<IProject>) {
blurHashUrl.value = blurHash ? window.URL.createObjectURL(blurHash) : '' blurHashUrl.value = blurHash ? window.URL.createObjectURL(blurHash) : ''
}) })
const projectService = new ProjectService() const listService = new ListService()
const backgroundPromise = projectService.background(project.value).then((result) => { const backgroundPromise = listService.background(list.value).then((result) => {
background.value = result background.value = result
}) })
await Promise.all([blurHashPromise, backgroundPromise]) await Promise.all([blurHashPromise, backgroundPromise])
@ -44,7 +44,7 @@ export function useProjectBackground(project: Ref<IProject>) {
backgroundLoading.value = false backgroundLoading.value = false
} }
}, },
{immediate: true}, { immediate: true },
) )
return { return {
@ -52,4 +52,4 @@ export function useProjectBackground(project: Ref<IProject>) {
blurHashUrl, blurHashUrl,
backgroundLoading, backgroundLoading,
} }
} }

View File

@ -1,6 +1,6 @@
<template> <template>
<modal @close="close()"> <modal @close="close()">
<card class="has-background-white keyboard-shortcuts" :shadow="false" :title="$t('keyboardShortcuts.title')"> <card class="has-background-white has-no-shadow keyboard-shortcuts" :title="$t('keyboardShortcuts.title')">
<template v-for="(s, i) in shortcuts" :key="i"> <template v-for="(s, i) in shortcuts" :key="i">
<h3>{{ $t(s.title) }}</h3> <h3>{{ $t(s.title) }}</h3>

View File

@ -61,8 +61,8 @@ export const KEYBOARD_SHORTCUTS : ShortcutGroup[] = [
], ],
}, },
{ {
title: 'project.kanban.title', title: 'list.kanban.title',
available: (route) => route.name === 'project.kanban', available: (route) => route.name === 'list.kanban',
shortcuts: [ shortcuts: [
{ {
title: 'keyboardShortcuts.task.done', title: 'keyboardShortcuts.task.done',
@ -71,26 +71,26 @@ export const KEYBOARD_SHORTCUTS : ShortcutGroup[] = [
], ],
}, },
{ {
title: 'keyboardShortcuts.project.title', title: 'keyboardShortcuts.list.title',
available: (route) => (route.name as string)?.startsWith('project.'), available: (route) => (route.name as string)?.startsWith('list.'),
shortcuts: [ shortcuts: [
{ {
title: 'keyboardShortcuts.project.switchToListView', title: 'keyboardShortcuts.list.switchToListView',
keys: ['g', 'l'], keys: ['g', 'l'],
combination: 'then', combination: 'then',
}, },
{ {
title: 'keyboardShortcuts.project.switchToGanttView', title: 'keyboardShortcuts.list.switchToGanttView',
keys: ['g', 'g'], keys: ['g', 'g'],
combination: 'then', combination: 'then',
}, },
{ {
title: 'keyboardShortcuts.project.switchToTableView', title: 'keyboardShortcuts.list.switchToTableView',
keys: ['g', 't'], keys: ['g', 't'],
combination: 'then', combination: 'then',
}, },
{ {
title: 'keyboardShortcuts.project.switchToKanbanView', title: 'keyboardShortcuts.list.switchToKanbanView',
keys: ['g', 'k'], keys: ['g', 'k'],
combination: 'then', combination: 'then',
}, },

View File

@ -73,14 +73,14 @@ const {t} = useI18n({useScope: 'global'})
const tooltipText = computed(() => { const tooltipText = computed(() => {
if (disabled.value) { if (disabled.value) {
if (props.entity === 'project' && subscriptionEntity.value === 'namespace') { if (props.entity === 'list' && subscriptionEntity.value === 'namespace') {
return t('task.subscription.subscribedProjectThroughParentNamespace') return t('task.subscription.subscribedListThroughParentNamespace')
} }
if (props.entity === 'task' && subscriptionEntity.value === 'namespace') { if (props.entity === 'task' && subscriptionEntity.value === 'namespace') {
return t('task.subscription.subscribedTaskThroughParentNamespace') return t('task.subscription.subscribedTaskThroughParentNamespace')
} }
if (props.entity === 'task' && subscriptionEntity.value === 'project') { if (props.entity === 'task' && subscriptionEntity.value === 'list') {
return t('task.subscription.subscribedTaskThroughParentProject') return t('task.subscription.subscribedTaskThroughParentList')
} }
return '' return ''
@ -91,10 +91,10 @@ const tooltipText = computed(() => {
return props.modelValue !== null ? return props.modelValue !== null ?
t('task.subscription.subscribedNamespace') : t('task.subscription.subscribedNamespace') :
t('task.subscription.notSubscribedNamespace') t('task.subscription.notSubscribedNamespace')
case 'project': case 'list':
return props.modelValue !== null ? return props.modelValue !== null ?
t('task.subscription.subscribedProject') : t('task.subscription.subscribedList') :
t('task.subscription.notSubscribedProject') t('task.subscription.notSubscribedList')
case 'task': case 'task':
return props.modelValue !== null ? return props.modelValue !== null ?
t('task.subscription.subscribedTask') : t('task.subscription.subscribedTask') :
@ -133,8 +133,8 @@ async function subscribe() {
case 'namespace': case 'namespace':
message = t('task.subscription.subscribeSuccessNamespace') message = t('task.subscription.subscribeSuccessNamespace')
break break
case 'project': case 'list':
message = t('task.subscription.subscribeSuccessProject') message = t('task.subscription.subscribeSuccessList')
break break
case 'task': case 'task':
message = t('task.subscription.subscribeSuccessTask') message = t('task.subscription.subscribeSuccessTask')
@ -156,8 +156,8 @@ async function unsubscribe() {
case 'namespace': case 'namespace':
message = t('task.subscription.unsubscribeSuccessNamespace') message = t('task.subscription.unsubscribeSuccessNamespace')
break break
case 'project': case 'list':
message = t('task.subscription.unsubscribeSuccessProject') message = t('task.subscription.unsubscribeSuccessList')
break break
case 'task': case 'task':
message = t('task.subscription.unsubscribeSuccessTask') message = t('task.subscription.unsubscribeSuccessTask')

View File

@ -30,10 +30,10 @@
{{ $t('menu.share') }} {{ $t('menu.share') }}
</dropdown-item> </dropdown-item>
<dropdown-item <dropdown-item
:to="{ name: 'project.create', params: { namespaceId: namespace.id } }" :to="{ name: 'list.create', params: { namespaceId: namespace.id } }"
icon="plus" icon="plus"
> >
{{ $t('menu.newProject') }} {{ $t('menu.newList') }}
</dropdown-item> </dropdown-item>
<dropdown-item <dropdown-item
:to="{ name: 'namespace.settings.archive', params: { id: namespace.id } }" :to="{ name: 'namespace.settings.archive', params: { id: namespace.id } }"

View File

@ -117,9 +117,9 @@ function to(n, index) {
case names.TASK_DELETED: case names.TASK_DELETED:
// Nothing // Nothing
break break
case names.PROJECT_CREATED: case names.LIST_CREATED:
to.name = 'task.index' to.name = 'task.index'
to.params.projectId = n.notification.project.id to.params.listId = n.notification.list.id
break break
case names.TEAM_MEMBER_ADDED: case names.TEAM_MEMBER_ADDED:
to.name = 'teams.edit' to.name = 'teams.edit'

View File

@ -1,200 +0,0 @@
<template>
<div
:class="{ 'is-loading': projectService.loading, 'is-archived': currentProject.isArchived}"
class="loader-container"
>
<div class="switch-view-container">
<div class="switch-view">
<BaseButton
v-shortcut="'g l'"
:title="$t('keyboardShortcuts.project.switchToListView')"
class="switch-view-button"
:class="{'is-active': viewName === 'project'}"
:to="{ name: 'project.list', params: { projectId } }"
>
{{ $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') }}
</BaseButton>
</div>
<slot name="header" />
</div>
<CustomTransition name="fade">
<Message variant="warning" v-if="currentProject.isArchived" class="mb-4">
{{ $t('project.archived') }}
</Message>
</CustomTransition>
<slot v-if="loadedProjectId"/>
</div>
</template>
<script setup lang="ts">
import {ref, computed, watch} from 'vue'
import {useRoute} from 'vue-router'
import BaseButton from '@/components/base/BaseButton.vue'
import Message from '@/components/misc/message.vue'
import CustomTransition from '@/components/misc/CustomTransition.vue'
import ProjectModel from '@/models/project'
import ProjectService from '@/services/project'
import {getProjectTitle} from '@/helpers/getProjectTitle'
import {saveProjectToHistory} from '@/modules/projectHistory'
import {useTitle} from '@/composables/useTitle'
import {useBaseStore} from '@/stores/base'
import {useProjectStore} from '@/stores/projects'
const props = defineProps({
projectId: {
type: Number,
required: true,
},
viewName: {
type: String,
required: true,
},
})
const route = useRoute()
const baseStore = useBaseStore()
const projectStore = useProjectStore()
const projectService = ref(new ProjectService())
const loadedProjectId = ref(0)
const currentProject = computed(() => {
return typeof baseStore.currentProject === 'undefined' ? {
id: 0,
title: '',
isArchived: false,
maxRight: null,
} : baseStore.currentProject
})
useTitle(() => currentProject.value.id ? getProjectTitle(currentProject.value) : '')
// watchEffect would be called every time the prop would get a value assigned, even if that value was the same as before.
// This resulted in loading and setting the 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,
// loadProject
async (projectIdToLoad: number) => {
const projectData = {id: projectIdToLoad}
saveProjectToHistory(projectData)
// Don't load the project if we either already loaded it or aren't dealing with a project at all currently and
// the currently loaded project has the right set.
if (
(
projectIdToLoad === loadedProjectId.value ||
typeof projectIdToLoad === 'undefined' ||
projectIdToLoad === currentProject.value.id
)
&& typeof currentProject.value !== 'undefined' && currentProject.value.maxRight !== null
) {
loadedProjectId.value = props.projectId
return
}
console.debug(`Loading project, props.viewName = ${props.viewName}, $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
const projectFromStore = projectStore.getProjectById(projectData.id)
if (projectFromStore !== null) {
baseStore.setBackground(null)
baseStore.setBlurHash(null)
baseStore.handleSetCurrentProject({project: projectFromStore})
}
// We create an extra project object instead of creating it in project.value because that would trigger a ui update which would result in bad ux.
const project = new ProjectModel(projectData)
try {
const loadedProject = await projectService.value.get(project)
baseStore.handleSetCurrentProject({project: loadedProject})
} finally {
loadedProjectId.value = props.projectId
}
},
{immediate: true},
)
</script>
<style lang="scss" scoped>
.switch-view-container {
@media screen and (max-width: $tablet) {
display: flex;
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;
}
.switch-view-button {
padding: .25rem .5rem;
display: block;
border-radius: $radius;
transition: all 100ms;
&:not(:last-child) {
margin-right: .5rem;
}
&:hover {
color: var(--switch-view-color);
background: var(--primary);
}
&.is-active {
color: var(--switch-view-color);
background: var(--primary);
font-weight: bold;
box-shadow: var(--shadow-xs);
}
}
// FIXME: this should be in notification and set via a prop
.is-archived .notification.is-warning {
margin-bottom: 1rem;
}
</style>

View File

@ -1,73 +0,0 @@
<template>
<ul class="project-grid">
<li
v-for="(item, index) in filteredProjects"
:key="`project_${item.id}_${index}`"
class="project-grid-item"
>
<ProjectCard :project="item" />
</li>
</ul>
</template>
<script lang="ts" setup>
import {computed, type PropType} from 'vue'
import type {IProject} from '@/modelTypes/IProject'
import ProjectCard from './ProjectCard.vue'
const props = defineProps({
projects: {
type: Array as PropType<IProject[]>,
default: () => [],
},
showArchived: {
default: false,
type: Boolean,
},
itemLimit: {
type: Boolean,
default: false,
},
})
const filteredProjects = computed(() => {
return props.showArchived
? props.projects
: props.projects.filter(l => !l.isArchived)
})
</script>
<style lang="scss" scoped>
.project-grid {
--project-grid-item-height: 150px;
--project-grid-gap: 1rem;
margin: 0; // reset li
list-style-type: none;
display: grid;
grid-template-columns: repeat(var(--project-grid-columns), 1fr);
grid-auto-rows: var(--project-grid-item-height);
gap: var(--project-grid-gap);
@media screen and (min-width: $mobile) {
--project-grid-columns: 1;
}
@media screen and (min-width: $mobile) and (max-width: $tablet) {
--project-grid-columns: 2;
}
@media screen and (min-width: $tablet) and (max-width: $widescreen) {
--project-grid-columns: 3;
}
@media screen and (min-width: $widescreen) {
--project-grid-columns: 5;
}
}
.project-grid-item {
display: grid;
margin-top: 0; // remove padding coming form .content li + li
}
</style>

View File

@ -63,18 +63,18 @@ import TeamService from '@/services/team'
import NamespaceModel from '@/models/namespace' import NamespaceModel from '@/models/namespace'
import TeamModel from '@/models/team' import TeamModel from '@/models/team'
import ProjectModel from '@/models/project' import ListModel from '@/models/list'
import BaseButton from '@/components/base/BaseButton.vue' import BaseButton from '@/components/base/BaseButton.vue'
import QuickAddMagic from '@/components/tasks/partials/quick-add-magic.vue' import QuickAddMagic from '@/components/tasks/partials/quick-add-magic.vue'
import {useBaseStore} from '@/stores/base' import {useBaseStore} from '@/stores/base'
import {useProjectStore} from '@/stores/projects' import {useListStore} from '@/stores/lists'
import {useNamespaceStore} from '@/stores/namespaces' import {useNamespaceStore} from '@/stores/namespaces'
import {useLabelStore} from '@/stores/labels' import {useLabelStore} from '@/stores/labels'
import {useTaskStore} from '@/stores/tasks' import {useTaskStore} from '@/stores/tasks'
import {getHistory} from '@/modules/projectHistory' import {getHistory} from '@/modules/listHistory'
import {parseTaskText, PrefixMode, PREFIXES} from '@/modules/parseTaskText' import {parseTaskText, PrefixMode, PREFIXES} from '@/modules/parseTaskText'
import {getQuickAddMagicMode} from '@/helpers/quickAddMagicMode' import {getQuickAddMagicMode} from '@/helpers/quickAddMagicMode'
import {success} from '@/message' import {success} from '@/message'
@ -82,13 +82,13 @@ import {success} from '@/message'
import type {ITeam} from '@/modelTypes/ITeam' import type {ITeam} from '@/modelTypes/ITeam'
import type {ITask} from '@/modelTypes/ITask' import type {ITask} from '@/modelTypes/ITask'
import type {INamespace} from '@/modelTypes/INamespace' import type {INamespace} from '@/modelTypes/INamespace'
import type {IProject} from '@/modelTypes/IProject' import type {IList} from '@/modelTypes/IList'
const {t} = useI18n({useScope: 'global'}) const {t} = useI18n({useScope: 'global'})
const router = useRouter() const router = useRouter()
const baseStore = useBaseStore() const baseStore = useBaseStore()
const projectStore = useProjectStore() const listStore = useListStore()
const namespaceStore = useNamespaceStore() const namespaceStore = useNamespaceStore()
const labelStore = useLabelStore() const labelStore = useLabelStore()
const taskStore = useTaskStore() const taskStore = useTaskStore()
@ -98,13 +98,13 @@ type DoAction<Type = any> = { type: ACTION_TYPE } & Type
enum ACTION_TYPE { enum ACTION_TYPE {
CMD = 'cmd', CMD = 'cmd',
TASK = 'task', TASK = 'task',
PROJECT = 'project', LIST = 'list',
TEAM = 'team', TEAM = 'team',
} }
enum COMMAND_TYPE { enum COMMAND_TYPE {
NEW_TASK = 'newTask', NEW_TASK = 'newTask',
NEW_PROJECT = 'newProject', NEW_LIST = 'newList',
NEW_NAMESPACE = 'newNamespace', NEW_NAMESPACE = 'newNamespace',
NEW_TEAM = 'newTeam', NEW_TEAM = 'newTeam',
} }
@ -112,7 +112,7 @@ enum COMMAND_TYPE {
enum SEARCH_MODE { enum SEARCH_MODE {
ALL = 'all', ALL = 'all',
TASKS = 'tasks', TASKS = 'tasks',
PROJECTS = 'projects', LISTS = 'lists',
TEAMS = 'teams', TEAMS = 'teams',
} }
@ -137,26 +137,26 @@ function closeQuickActions() {
baseStore.setQuickActionsActive(false) baseStore.setQuickActionsActive(false)
} }
const foundProjects = computed(() => { const foundLists = computed(() => {
const { project } = parsedQuery.value const { list } = parsedQuery.value
if ( if (
searchMode.value === SEARCH_MODE.ALL || searchMode.value === SEARCH_MODE.ALL ||
searchMode.value === SEARCH_MODE.PROJECTS || searchMode.value === SEARCH_MODE.LISTS ||
project === null list === null
) { ) {
return [] return []
} }
const ncache: { [id: ProjectModel['id']]: INamespace } = {} const ncache: { [id: ListModel['id']]: INamespace } = {}
const history = getHistory() const history = getHistory()
const allProjects = [ const allLists = [
...new Set([ ...new Set([
...history.map((l) => projectStore.getProjectById(l.id)), ...history.map((l) => listStore.getListById(l.id)),
...projectStore.searchProject(project), ...listStore.searchList(list),
]), ]),
] ]
return allProjects.filter((l) => { return allLists.filter((l) => {
if (typeof l === 'undefined' || l === null) { if (typeof l === 'undefined' || l === null) {
return false return false
} }
@ -191,9 +191,9 @@ const results = computed<Result[]>(() => {
items: foundTasks.value, items: foundTasks.value,
}, },
{ {
type: ACTION_TYPE.PROJECT, type: ACTION_TYPE.LIST,
title: t('quickActions.projects'), title: t('quickActions.lists'),
items: foundProjects.value, items: foundLists.value,
}, },
{ {
type: ACTION_TYPE.TEAM, type: ACTION_TYPE.TEAM,
@ -206,7 +206,7 @@ const results = computed<Result[]>(() => {
const loading = computed(() => const loading = computed(() =>
taskService.loading || taskService.loading ||
namespaceStore.isLoading || namespaceStore.isLoading ||
projectStore.isLoading || listStore.isLoading ||
teamService.loading, teamService.loading,
) )
@ -224,11 +224,11 @@ const commands = computed<{ [key in COMMAND_TYPE]: Command }>(() => ({
placeholder: t('quickActions.newTask'), placeholder: t('quickActions.newTask'),
action: newTask, action: newTask,
}, },
newProject: { newList: {
type: COMMAND_TYPE.NEW_PROJECT, type: COMMAND_TYPE.NEW_LIST,
title: t('quickActions.cmds.newProject'), title: t('quickActions.cmds.newList'),
placeholder: t('quickActions.newProject'), placeholder: t('quickActions.newList'),
action: newProject, action: newList,
}, },
newNamespace: { newNamespace: {
type: COMMAND_TYPE.NEW_NAMESPACE, type: COMMAND_TYPE.NEW_NAMESPACE,
@ -246,24 +246,24 @@ const commands = computed<{ [key in COMMAND_TYPE]: Command }>(() => ({
const placeholder = computed(() => selectedCmd.value?.placeholder || t('quickActions.placeholder')) const placeholder = computed(() => selectedCmd.value?.placeholder || t('quickActions.placeholder'))
const currentProject = computed(() => Object.keys(baseStore.currentProject).length === 0 const currentList = computed(() => Object.keys(baseStore.currentList).length === 0
? null ? null
: baseStore.currentProject, : baseStore.currentList,
) )
const hintText = computed(() => { const hintText = computed(() => {
let namespace let namespace
if (selectedCmd.value !== null && currentProject.value !== null) { if (selectedCmd.value !== null && currentList.value !== null) {
switch (selectedCmd.value.type) { switch (selectedCmd.value.type) {
case COMMAND_TYPE.NEW_TASK: case COMMAND_TYPE.NEW_TASK:
return t('quickActions.createTask', { return t('quickActions.createTask', {
title: currentProject.value.title, title: currentList.value.title,
}) })
case COMMAND_TYPE.NEW_PROJECT: case COMMAND_TYPE.NEW_LIST:
namespace = namespaceStore.getNamespaceById( namespace = namespaceStore.getNamespaceById(
currentProject.value.namespaceId, currentList.value.namespaceId,
) )
return t('quickActions.createProject', { return t('quickActions.createList', {
title: namespace?.title, title: namespace?.title,
}) })
} }
@ -275,8 +275,8 @@ const hintText = computed(() => {
const availableCmds = computed(() => { const availableCmds = computed(() => {
const cmds = [] const cmds = []
if (currentProject.value !== null) { if (currentList.value !== null) {
cmds.push(commands.value.newTask, commands.value.newProject) cmds.push(commands.value.newTask, commands.value.newList)
} }
cmds.push(commands.value.newNamespace, commands.value.newTeam) cmds.push(commands.value.newNamespace, commands.value.newTeam)
return cmds return cmds
@ -288,21 +288,21 @@ const searchMode = computed(() => {
if (query.value === '') { if (query.value === '') {
return SEARCH_MODE.ALL return SEARCH_MODE.ALL
} }
const { text, project, labels, assignees } = parsedQuery.value const { text, list, labels, assignees } = parsedQuery.value
if (assignees.length === 0 && text !== '') { if (assignees.length === 0 && text !== '') {
return SEARCH_MODE.TASKS return SEARCH_MODE.TASKS
} }
if ( if (
assignees.length === 0 && assignees.length === 0 &&
project !== null && list !== null &&
text === '' && text === '' &&
labels.length === 0 labels.length === 0
) { ) {
return SEARCH_MODE.PROJECTS return SEARCH_MODE.LISTS
} }
if ( if (
assignees.length > 0 && assignees.length > 0 &&
project === null && list === null &&
text === '' && text === '' &&
labels.length === 0 labels.length === 0
) { ) {
@ -356,7 +356,7 @@ function searchTasks() {
taskSearchTimeout.value = null taskSearchTimeout.value = null
} }
const { text, project: projectName, labels } = parsedQuery.value const { text, list: listName, labels } = parsedQuery.value
const filters: Filter[] = [] const filters: Filter[] = []
@ -373,10 +373,10 @@ function searchTasks() {
}) })
} }
if (projectName !== null) { if (listName !== null) {
const project = projectStore.findProjectByExactname(projectName) const list = listStore.findListByExactname(listName)
if (project !== null) { if (list !== null) {
addFilter('projectId', project.id, 'equals') addFilter('listId', list.id, 'equals')
} }
} }
@ -396,9 +396,9 @@ function searchTasks() {
const r = await taskService.getAll({}, params) as DoAction<ITask>[] const r = await taskService.getAll({}, params) as DoAction<ITask>[]
foundTasks.value = r.map((t) => { foundTasks.value = r.map((t) => {
t.type = ACTION_TYPE.TASK t.type = ACTION_TYPE.TASK
const project = projectStore.getProjectById(t.projectId) const list = listStore.getListById(t.listId)
if (project !== null) { if (list !== null) {
t.title = `${t.title} (${project.title})` t.title = `${t.title} (${list.title})`
} }
return t return t
}) })
@ -428,7 +428,7 @@ function searchTeams() {
teamService.getAll({}, { s: t }), teamService.getAll({}, { s: t }),
) )
const teamsResult = await Promise.all(teamSearchPromises) const teamsResult = await Promise.all(teamSearchPromises)
foundTeams.value = teamsResult.flat().map((team) => { foundTeams.value = teamsResult.flatMap((team) => {
team.title = team.name team.title = team.name
return team return team
}) })
@ -444,11 +444,11 @@ const searchInput = ref<HTMLElement | null>(null)
async function doAction(type: ACTION_TYPE, item: DoAction) { async function doAction(type: ACTION_TYPE, item: DoAction) {
switch (type) { switch (type) {
case ACTION_TYPE.PROJECT: case ACTION_TYPE.LIST:
closeQuickActions() closeQuickActions()
await router.push({ await router.push({
name: 'project.index', name: 'list.index',
params: { projectId: (item as DoAction<IProject>).id }, params: { listId: (item as DoAction<IList>).id },
}) })
break break
case ACTION_TYPE.TASK: case ACTION_TYPE.TASK:
@ -458,13 +458,6 @@ async function doAction(type: ACTION_TYPE, item: DoAction) {
params: { id: (item as DoAction<ITask>).id }, params: { id: (item as DoAction<ITask>).id },
}) })
break break
case ACTION_TYPE.TEAM:
closeQuickActions()
await router.push({
name: 'teams.edit',
params: { id: (item as DoAction<ITeam>).id },
})
break
case ACTION_TYPE.CMD: case ACTION_TYPE.CMD:
query.value = '' query.value = ''
selectedCmd.value = item as DoAction<Command> selectedCmd.value = item as DoAction<Command>
@ -489,29 +482,29 @@ async function doCmd() {
} }
async function newTask() { async function newTask() {
if (currentProject.value === null) { if (currentList.value === null) {
return return
} }
const task = await taskStore.createNewTask({ const task = await taskStore.createNewTask({
title: query.value, title: query.value,
projectId: currentProject.value.id, listId: currentList.value.id,
}) })
success({ message: t('task.createSuccess') }) success({ message: t('task.createSuccess') })
await router.push({ name: 'task.detail', params: { id: task.id } }) await router.push({ name: 'task.detail', params: { id: task.id } })
} }
async function newProject() { async function newList() {
if (currentProject.value === null) { if (currentList.value === null) {
return return
} }
const newProject = await projectStore.createProject(new ProjectModel({ const newList = await listStore.createList(new ListModel({
title: query.value, title: query.value,
namespaceId: currentProject.value.namespaceId, namespaceId: currentList.value.namespaceId,
})) }))
success({ message: t('project.create.createdSuccess')}) success({ message: t('list.create.createdSuccess')})
await router.push({ await router.push({
name: 'project.index', name: 'list.index',
params: { projectId: newProject.id }, params: { listId: newList.id },
}) })
} }

View File

@ -1,39 +1,39 @@
<template> <template>
<div> <div>
<p class="has-text-weight-bold"> <p class="has-text-weight-bold">
{{ $t('project.share.links.title') }} {{ $t('list.share.links.title') }}
<span <span
class="is-size-7 has-text-grey is-italic ml-3" class="is-size-7 has-text-grey is-italic ml-3"
v-tooltip="$t('project.share.links.explanation')"> v-tooltip="$t('list.share.links.explanation')">
{{ $t('project.share.links.what') }} {{ $t('list.share.links.what') }}
</span> </span>
</p> </p>
<div class="sharables-project"> <div class="sharables-list">
<x-button <x-button
v-if="!(linkShares.length === 0 || showNewForm)" v-if="!(linkShares.length === 0 || showNewForm)"
@click="showNewForm = true" @click="showNewForm = true"
icon="plus" icon="plus"
class="mb-4"> class="mb-4">
{{ $t('project.share.links.create') }} {{ $t('list.share.links.create') }}
</x-button> </x-button>
<div class="p-4" v-if="linkShares.length === 0 || showNewForm"> <div class="p-4" v-if="linkShares.length === 0 || showNewForm">
<div class="field"> <div class="field">
<label class="label" for="linkShareRight"> <label class="label" for="linkShareRight">
{{ $t('project.share.right.title') }} {{ $t('list.share.right.title') }}
</label> </label>
<div class="control"> <div class="control">
<div class="select"> <div class="select">
<select v-model="selectedRight" id="linkShareRight"> <select v-model="selectedRight" id="linkShareRight">
<option :value="RIGHTS.READ"> <option :value="RIGHTS.READ">
{{ $t('project.share.right.read') }} {{ $t('list.share.right.read') }}
</option> </option>
<option :value="RIGHTS.READ_WRITE"> <option :value="RIGHTS.READ_WRITE">
{{ $t('project.share.right.readWrite') }} {{ $t('list.share.right.readWrite') }}
</option> </option>
<option :value="RIGHTS.ADMIN"> <option :value="RIGHTS.ADMIN">
{{ $t('project.share.right.admin') }} {{ $t('list.share.right.admin') }}
</option> </option>
</select> </select>
</div> </div>
@ -41,21 +41,21 @@
</div> </div>
<div class="field"> <div class="field">
<label class="label" for="linkShareName"> <label class="label" for="linkShareName">
{{ $t('project.share.links.name') }} {{ $t('list.share.links.name') }}
</label> </label>
<div class="control"> <div class="control">
<input <input
id="linkShareName" id="linkShareName"
class="input" class="input"
:placeholder="$t('project.share.links.namePlaceholder')" :placeholder="$t('list.share.links.namePlaceholder')"
v-tooltip="$t('project.share.links.nameExplanation')" v-tooltip="$t('list.share.links.nameExplanation')"
v-model="name" v-model="name"
/> />
</div> </div>
</div> </div>
<div class="field"> <div class="field">
<label class="label" for="linkSharePassword"> <label class="label" for="linkSharePassword">
{{ $t('project.share.links.password') }} {{ $t('list.share.links.password') }}
</label> </label>
<div class="control"> <div class="control">
<input <input
@ -63,25 +63,25 @@
type="password" type="password"
class="input" class="input"
:placeholder="$t('user.auth.passwordPlaceholder')" :placeholder="$t('user.auth.passwordPlaceholder')"
v-tooltip="$t('project.share.links.passwordExplanation')" v-tooltip="$t('list.share.links.passwordExplanation')"
v-model="password" v-model="password"
/> />
</div> </div>
</div> </div>
<x-button @click="add(projectId)" icon="plus"> <x-button @click="add(listId)" icon="plus">
{{ $t('project.share.share') }} {{ $t('list.share.share') }}
</x-button> </x-button>
</div> </div>
<table <table
class="table has-actions is-striped is-hoverable is-fullwidth" class="table has-actions is-striped is-hoverable is-fullwidth link-share-list"
v-if="linkShares.length > 0" v-if="linkShares.length > 0"
> >
<thead> <thead>
<tr> <tr>
<th></th> <th></th>
<th>{{ $t('project.share.links.view') }}</th> <th>{{ $t('list.share.links.view') }}</th>
<th>{{ $t('project.share.attributes.delete') }}</th> <th>{{ $t('list.share.attributes.delete') }}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -92,7 +92,7 @@
</p> </p>
<p class="mb-2"> <p class="mb-2">
<i18n-t keypath="project.share.links.sharedBy" scope="global"> <i18n-t keypath="list.share.links.sharedBy" scope="global">
<strong>{{ getDisplayName(s.sharedBy) }}</strong> <strong>{{ getDisplayName(s.sharedBy) }}</strong>
</i18n-t> </i18n-t>
</p> </p>
@ -102,19 +102,19 @@
<span class="icon is-small"> <span class="icon is-small">
<icon icon="lock"/> <icon icon="lock"/>
</span>&nbsp; </span>&nbsp;
{{ $t('project.share.right.admin') }} {{ $t('list.share.right.admin') }}
</template> </template>
<template v-else-if="s.right === RIGHTS.READ_WRITE"> <template v-else-if="s.right === RIGHTS.READ_WRITE">
<span class="icon is-small"> <span class="icon is-small">
<icon icon="pen"/> <icon icon="pen"/>
</span>&nbsp; </span>&nbsp;
{{ $t('project.share.right.readWrite') }} {{ $t('list.share.right.readWrite') }}
</template> </template>
<template v-else> <template v-else>
<span class="icon is-small"> <span class="icon is-small">
<icon icon="users"/> <icon icon="users"/>
</span>&nbsp; </span>&nbsp;
{{ $t('project.share.right.read') }} {{ $t('list.share.right.read') }}
</template> </template>
</p> </p>
@ -172,14 +172,14 @@
<modal <modal
:enabled="showDeleteModal" :enabled="showDeleteModal"
@close="showDeleteModal = false" @close="showDeleteModal = false"
@submit="remove(projectId)" @submit="remove(listId)"
> >
<template #header> <template #header>
<span>{{ $t('project.share.links.remove') }}</span> <span>{{ $t('list.share.links.remove') }}</span>
</template> </template>
<template #text> <template #text>
<p>{{ $t('project.share.links.removeText') }}</p> <p>{{ $t('list.share.links.removeText') }}</p>
</template> </template>
</modal> </modal>
</div> </div>
@ -193,19 +193,19 @@ import {RIGHTS} from '@/constants/rights'
import LinkShareModel from '@/models/linkShare' import LinkShareModel from '@/models/linkShare'
import type {ILinkShare} from '@/modelTypes/ILinkShare' import type {ILinkShare} from '@/modelTypes/ILinkShare'
import type {IProject} from '@/modelTypes/IProject' import type {IList} from '@/modelTypes/IList'
import LinkShareService from '@/services/linkShare' import LinkShareService from '@/services/linkShare'
import {useCopyToClipboard} from '@/composables/useCopyToClipboard' import {useCopyToClipboard} from '@/composables/useCopyToClipboard'
import {success} from '@/message' import {success} from '@/message'
import {getDisplayName} from '@/models/user' import {getDisplayName} from '@/models/user'
import type {ProjectView} from '@/types/ProjectView' import type {ListView} from '@/types/ListView'
import {PROJECT_VIEWS} from '@/types/ProjectView' import {LIST_VIEWS} from '@/types/ListView'
import {useConfigStore} from '@/stores/config' import {useConfigStore} from '@/stores/config'
const props = defineProps({ const props = defineProps({
projectId: { listId: {
default: 0, default: 0,
required: true, required: true,
}, },
@ -222,20 +222,20 @@ const showDeleteModal = ref(false)
const linkIdToDelete = ref(0) const linkIdToDelete = ref(0)
const showNewForm = ref(false) const showNewForm = ref(false)
type SelectedViewMapper = Record<IProject['id'], ProjectView> type SelectedViewMapper = Record<IList['id'], ListView>
const selectedView = ref<SelectedViewMapper>({}) const selectedView = ref<SelectedViewMapper>({})
const availableViews = computed<Record<ProjectView, string>>(() => ({ const availableViews = computed<Record<ListView, string>>(() => ({
list: t('project.list.title'), list: t('list.list.title'),
gantt: t('project.gantt.title'), gantt: t('list.gantt.title'),
table: t('project.table.title'), table: t('list.table.title'),
kanban: t('project.kanban.title'), kanban: t('list.kanban.title'),
})) }))
const copy = useCopyToClipboard() const copy = useCopyToClipboard()
watch( watch(
() => props.projectId, () => props.listId,
load, load,
{immediate: true}, {immediate: true},
) )
@ -243,23 +243,23 @@ watch(
const configStore = useConfigStore() const configStore = useConfigStore()
const frontendUrl = computed(() => configStore.frontendUrl) const frontendUrl = computed(() => configStore.frontendUrl)
async function load(projectId: IProject['id']) { async function load(listId: IList['id']) {
// If projectId == 0 the project on the calling component wasn't already loaded, so we just bail out here // If listId == 0 the list on the calling component wasn't already loaded, so we just bail out here
if (projectId === 0) { if (listId === 0) {
return return
} }
const links = await linkShareService.getAll({projectId}) const links = await linkShareService.getAll({listId})
links.forEach((l: ILinkShare) => { links.forEach((l: ILinkShare) => {
selectedView.value[l.id] = 'project' selectedView.value[l.id] = 'list'
}) })
linkShares.value = links linkShares.value = links
} }
async function add(projectId: IProject['id']) { async function add(listId: IList['id']) {
const newLinkShare = new LinkShareModel({ const newLinkShare = new LinkShareModel({
right: selectedRight.value, right: selectedRight.value,
projectId, listId,
name: name.value, name: name.value,
password: password.value, password: password.value,
}) })
@ -268,31 +268,31 @@ async function add(projectId: IProject['id']) {
name.value = '' name.value = ''
password.value = '' password.value = ''
showNewForm.value = false showNewForm.value = false
success({message: t('project.share.links.createSuccess')}) success({message: t('list.share.links.createSuccess')})
await load(projectId) await load(listId)
} }
async function remove(projectId: IProject['id']) { async function remove(listId: IList['id']) {
try { try {
await linkShareService.delete(new LinkShareModel({ await linkShareService.delete(new LinkShareModel({
id: linkIdToDelete.value, id: linkIdToDelete.value,
projectId, listId,
})) }))
success({message: t('project.share.links.deleteSuccess')}) success({message: t('list.share.links.deleteSuccess')})
await load(projectId) await load(listId)
} finally { } finally {
showDeleteModal.value = false showDeleteModal.value = false
} }
} }
function getShareLink(hash: string, view: ProjectView = PROJECT_VIEWS.LIST) { function getShareLink(hash: string, view: ListView = LIST_VIEWS.LIST) {
return frontendUrl.value + 'share/' + hash + '/auth?view=' + view return frontendUrl.value + 'share/' + hash + '/auth?view=' + view
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
// FIXME: I think this is not needed // FIXME: I think this is not needed
.sharables-project:not(.card-content) { .sharables-list:not(.card-content) {
overflow-y: auto overflow-y: auto
} }
</style> </style>

View File

@ -1,7 +1,7 @@
<template> <template>
<div> <div>
<p class="has-text-weight-bold"> <p class="has-text-weight-bold">
{{ $t('project.share.userTeam.shared', {type: shareTypeNames}) }} {{ $t('list.share.userTeam.shared', {type: shareTypeNames}) }}
</p> </p>
<div v-if="userIsAdmin"> <div v-if="userIsAdmin">
<div class="field has-addons"> <div class="field has-addons">
@ -19,7 +19,7 @@
/> />
</p> </p>
<p class="control"> <p class="control">
<x-button @click="add()">{{ $t('project.share.share') }}</x-button> <x-button @click="add()">{{ $t('list.share.share') }}</x-button>
</p> </p>
</div> </div>
</div> </div>
@ -31,7 +31,7 @@
<td>{{ getDisplayName(s) }}</td> <td>{{ getDisplayName(s) }}</td>
<td> <td>
<template v-if="s.id === userInfo.id"> <template v-if="s.id === userInfo.id">
<b class="is-success">{{ $t('project.share.userTeam.you') }}</b> <b class="is-success">{{ $t('list.share.userTeam.you') }}</b>
</template> </template>
</td> </td>
</template> </template>
@ -52,19 +52,19 @@
<span class="icon is-small"> <span class="icon is-small">
<icon icon="lock"/> <icon icon="lock"/>
</span> </span>
{{ $t('project.share.right.admin') }} {{ $t('list.share.right.admin') }}
</template> </template>
<template v-else-if="s.right === RIGHTS.READ_WRITE"> <template v-else-if="s.right === RIGHTS.READ_WRITE">
<span class="icon is-small"> <span class="icon is-small">
<icon icon="pen"/> <icon icon="pen"/>
</span> </span>
{{ $t('project.share.right.readWrite') }} {{ $t('list.share.right.readWrite') }}
</template> </template>
<template v-else> <template v-else>
<span class="icon is-small"> <span class="icon is-small">
<icon icon="users"/> <icon icon="users"/>
</span> </span>
{{ $t('project.share.right.read') }} {{ $t('list.share.right.read') }}
</template> </template>
</td> </td>
<td class="actions" v-if="userIsAdmin"> <td class="actions" v-if="userIsAdmin">
@ -78,19 +78,19 @@
:selected="s.right === RIGHTS.READ" :selected="s.right === RIGHTS.READ"
:value="RIGHTS.READ" :value="RIGHTS.READ"
> >
{{ $t('project.share.right.read') }} {{ $t('list.share.right.read') }}
</option> </option>
<option <option
:selected="s.right === RIGHTS.READ_WRITE" :selected="s.right === RIGHTS.READ_WRITE"
:value="RIGHTS.READ_WRITE" :value="RIGHTS.READ_WRITE"
> >
{{ $t('project.share.right.readWrite') }} {{ $t('list.share.right.readWrite') }}
</option> </option>
<option <option
:selected="s.right === RIGHTS.ADMIN" :selected="s.right === RIGHTS.ADMIN"
:value="RIGHTS.ADMIN" :value="RIGHTS.ADMIN"
> >
{{ $t('project.share.right.admin') }} {{ $t('list.share.right.admin') }}
</option> </option>
</select> </select>
</div> </div>
@ -110,7 +110,7 @@
</table> </table>
<nothing v-else> <nothing v-else>
{{ $t('project.share.userTeam.notShared', {type: shareTypeNames}) }} {{ $t('list.share.userTeam.notShared', {type: shareTypeNames}) }}
</nothing> </nothing>
<modal <modal
@ -120,11 +120,11 @@
> >
<template #header> <template #header>
<span>{{ <span>{{
$t('project.share.userTeam.removeHeader', {type: shareTypeName, sharable: sharableName}) $t('list.share.userTeam.removeHeader', {type: shareTypeName, sharable: sharableName})
}}</span> }}</span>
</template> </template>
<template #text> <template #text>
<p>{{ $t('project.share.userTeam.removeText', {type: shareTypeName, sharable: sharableName}) }}</p> <p>{{ $t('list.share.userTeam.removeText', {type: shareTypeName, sharable: sharableName}) }}</p>
</template> </template>
</modal> </modal>
</div> </div>
@ -143,9 +143,9 @@ import UserNamespaceService from '@/services/userNamespace'
import UserNamespaceModel from '@/models/userNamespace' import UserNamespaceModel from '@/models/userNamespace'
import type {IUserNamespace} from '@/modelTypes/IUserNamespace' import type {IUserNamespace} from '@/modelTypes/IUserNamespace'
import UserProjectService from '@/services/userProject' import UserListService from '@/services/userList'
import UserProjectModel from '@/models/userProject' import UserListModel from '@/models/userList'
import type {IUserProject} from '@/modelTypes/IUserProject' import type {IUserList} from '@/modelTypes/IUserList'
import UserService from '@/services/user' import UserService from '@/services/user'
import UserModel, { getDisplayName } from '@/models/user' import UserModel, { getDisplayName } from '@/models/user'
@ -155,9 +155,9 @@ import TeamNamespaceService from '@/services/teamNamespace'
import TeamNamespaceModel from '@/models/teamNamespace' import TeamNamespaceModel from '@/models/teamNamespace'
import type { ITeamNamespace } from '@/modelTypes/ITeamNamespace' import type { ITeamNamespace } from '@/modelTypes/ITeamNamespace'
import TeamProjectService from '@/services/teamProject' import TeamListService from '@/services/teamList'
import TeamProjectModel from '@/models/teamProject' import TeamListModel from '@/models/teamList'
import type { ITeamProject } from '@/modelTypes/ITeamProject' import type { ITeamList } from '@/modelTypes/ITeamList'
import TeamService from '@/services/team' import TeamService from '@/services/team'
import TeamModel from '@/models/team' import TeamModel from '@/models/team'
@ -172,7 +172,7 @@ import {useAuthStore} from '@/stores/auth'
const props = defineProps({ const props = defineProps({
type: { type: {
type: String as PropType<'project' | 'namespace'>, type: String as PropType<'list' | 'namespace'>,
default: '', default: '',
}, },
shareType: { shareType: {
@ -191,9 +191,9 @@ const props = defineProps({
const {t} = useI18n({useScope: 'global'}) const {t} = useI18n({useScope: 'global'})
// This user service is either a userNamespaceService or a userProjectService, depending on the type we are using // This user service is either a userNamespaceService or a userListService, depending on the type we are using
let stuffService: UserNamespaceService | UserProjectService | TeamProjectService | TeamNamespaceService let stuffService: UserNamespaceService | UserListService | TeamListService | TeamNamespaceService
let stuffModel: IUserNamespace | IUserProject | ITeamProject | ITeamNamespace let stuffModel: IUserNamespace | IUserList | ITeamList | ITeamNamespace
let searchService: UserService | TeamService let searchService: UserService | TeamService
let sharable: Ref<IUser | ITeam> let sharable: Ref<IUser | ITeam>
@ -201,7 +201,7 @@ const searchLabel = ref('')
const selectedRight = ref({}) const selectedRight = ref({})
// This holds either teams or users who this namepace or project is shared with // This holds either teams or users who this namepace or list is shared with
const sharables = ref([]) const sharables = ref([])
const showDeleteModal = ref(false) const showDeleteModal = ref(false)
@ -212,11 +212,11 @@ const userInfo = computed(() => authStore.info)
function createShareTypeNameComputed(count: number) { function createShareTypeNameComputed(count: number) {
return computed(() => { return computed(() => {
if (props.shareType === 'user') { if (props.shareType === 'user') {
return t('project.share.userTeam.typeUser', count) return t('list.share.userTeam.typeUser', count)
} }
if (props.shareType === 'team') { if (props.shareType === 'team') {
return t('project.share.userTeam.typeTeam', count) return t('list.share.userTeam.typeTeam', count)
} }
return '' return ''
@ -227,8 +227,8 @@ const shareTypeNames = createShareTypeNameComputed(2)
const shareTypeName = createShareTypeNameComputed(1) const shareTypeName = createShareTypeNameComputed(1)
const sharableName = computed(() => { const sharableName = computed(() => {
if (props.type === 'project') { if (props.type === 'list') {
return t('project.list.title') return t('list.list.title')
} }
if (props.shareType === 'namespace') { if (props.shareType === 'namespace') {
@ -244,9 +244,9 @@ if (props.shareType === 'user') {
sharable = ref(new UserModel()) sharable = ref(new UserModel())
searchLabel.value = 'username' searchLabel.value = 'username'
if (props.type === 'project') { if (props.type === 'list') {
stuffService = shallowReactive(new UserProjectService()) stuffService = shallowReactive(new UserListService())
stuffModel = reactive(new UserProjectModel({projectId: props.id})) stuffModel = reactive(new UserListModel({listId: props.id}))
} else if (props.type === 'namespace') { } else if (props.type === 'namespace') {
stuffService = shallowReactive(new UserNamespaceService()) stuffService = shallowReactive(new UserNamespaceService())
stuffModel = reactive(new UserNamespaceModel({ stuffModel = reactive(new UserNamespaceModel({
@ -261,9 +261,9 @@ if (props.shareType === 'user') {
sharable = ref(new TeamModel()) sharable = ref(new TeamModel())
searchLabel.value = 'name' searchLabel.value = 'name'
if (props.type === 'project') { if (props.type === 'list') {
stuffService = shallowReactive(new TeamProjectService()) stuffService = shallowReactive(new TeamListService())
stuffModel = reactive(new TeamProjectModel({projectId: props.id})) stuffModel = reactive(new TeamListModel({listId: props.id}))
} else if (props.type === 'namespace') { } else if (props.type === 'namespace') {
stuffService = shallowReactive(new TeamNamespaceService()) stuffService = shallowReactive(new TeamNamespaceService())
stuffModel = reactive(new TeamNamespaceModel({ stuffModel = reactive(new TeamNamespaceModel({
@ -303,7 +303,7 @@ async function deleteSharable() {
} }
} }
success({ success({
message: t('project.share.userTeam.removeSuccess', { message: t('list.share.userTeam.removeSuccess', {
type: shareTypeName.value, type: shareTypeName.value,
sharable: sharableName.value, sharable: sharableName.value,
}), }),
@ -326,7 +326,7 @@ async function add(admin) {
} }
await stuffService.create(stuffModel) await stuffService.create(stuffModel)
success({message: t('project.share.userTeam.addedSuccess', {type: shareTypeName.value})}) success({message: t('list.share.userTeam.addedSuccess', {type: shareTypeName.value})})
await load() await load()
} }
@ -358,7 +358,7 @@ async function toggleType(sharable) {
sharables.value[i].right = r.right sharables.value[i].right = r.right
} }
} }
success({message: t('project.share.userTeam.updatedSuccess', {type: shareTypeName.value})}) success({message: t('list.share.userTeam.updatedSuccess', {type: shareTypeName.value})})
} }
const found = ref([]) const found = ref([])

View File

@ -50,7 +50,7 @@ import {parseKebabDate} from '@/helpers/time/parseKebabDate'
import type {ITask, ITaskPartialWithId} from '@/modelTypes/ITask' import type {ITask, ITaskPartialWithId} from '@/modelTypes/ITask'
import type {DateISO} from '@/types/DateISO' import type {DateISO} from '@/types/DateISO'
import type {GanttFilters} from '@/views/project/helpers/useGanttFilters' import type {GanttFilters} from '@/views/list/helpers/useGanttFilters'
import { import {
extendDayjs, extendDayjs,

View File

@ -5,7 +5,7 @@
<textarea <textarea
class="add-task-textarea input" class="add-task-textarea input"
:class="{'textarea-empty': newTaskTitle === ''}" :class="{'textarea-empty': newTaskTitle === ''}"
:placeholder="$t('project.list.addPlaceholder')" :placeholder="$t('list.list.addPlaceholder')"
rows="1" rows="1"
v-focus v-focus
v-model="newTaskTitle" v-model="newTaskTitle"
@ -24,10 +24,10 @@
@click="addTask()" @click="addTask()"
icon="plus" icon="plus"
:loading="loading" :loading="loading"
:aria-label="$t('project.list.add')" :aria-label="$t('list.list.add')"
> >
<span class="button-text"> <span class="button-text">
{{ $t('project.list.add') }} {{ $t('list.list.add') }}
</span> </span>
</x-button> </x-button>
</p> </p>
@ -107,7 +107,7 @@ const loading = computed(() => taskStore.isLoading)
async function addTask() { async function addTask() {
if (newTaskTitle.value === '') { if (newTaskTitle.value === '') {
errorMessage.value = t('project.create.addTitleRequired') errorMessage.value = t('list.create.addTitleRequired')
return return
} }
errorMessage.value = '' errorMessage.value = ''
@ -128,20 +128,20 @@ async function addTask() {
const allLabels = tasksToCreate.map(({title}) => getLabelsFromPrefix(title) ?? []) const allLabels = tasksToCreate.map(({title}) => getLabelsFromPrefix(title) ?? [])
await taskStore.ensureLabelsExist(allLabels.flat()) await taskStore.ensureLabelsExist(allLabels.flat())
const newTasks = tasksToCreate.map(async ({title, project}) => { const newTasks = tasksToCreate.map(async ({title, list}) => {
if (title === '') { if (title === '') {
return return
} }
// If the task has a project specified, make sure to use it // If the task has a list specified, make sure to use it
let projectId = null let listId = null
if (project !== null) { if (list !== null) {
projectId = await taskStore.findProjectId({project, projectId: 0}) listId = await taskStore.findListId({list, listId: 0})
} }
const task = await taskStore.createNewTask({ const task = await taskStore.createNewTask({
title, title,
projectId: projectId || authStore.settings.defaultProjectId, listId: listId || authStore.settings.defaultListId,
position: props.defaultPosition, position: props.defaultPosition,
}) })
createdTasks[title] = task createdTasks[title] = task
@ -176,7 +176,7 @@ async function addTask() {
})) }))
createdTask.relatedTasks[RELATION_KIND.PARENTTASK] = [createdParentTask] createdTask.relatedTasks[RELATION_KIND.PARENTTASK] = [createdParentTask]
// we're only emitting here so that the relation shows up in the project // we're only emitting here so that the relation shows up in the task list
emit('taskAdded', createdTask) emit('taskAdded', createdTask)
return rel return rel
@ -184,8 +184,8 @@ async function addTask() {
await Promise.all(relations) await Promise.all(relations)
} catch (e: any) { } catch (e: any) {
newTaskTitle.value = taskTitleBackup newTaskTitle.value = taskTitleBackup
if (e?.message === 'NO_PROJECT') { if (e?.message === 'NO_LIST') {
errorMessage.value = t('project.create.addProjectRequired') errorMessage.value = t('list.create.addListRequired')
return return
} }
throw e throw e

View File

@ -1,6 +1,6 @@
<template> <template>
<Multiselect <Multiselect
:loading="projectUserService.loading" :loading="listUserService.loading"
:placeholder="$t('task.assignee.placeholder')" :placeholder="$t('task.assignee.placeholder')"
:multiple="true" :multiple="true"
@search="findUser" @search="findUser"
@ -30,7 +30,7 @@ import Multiselect from '@/components/input/multiselect.vue'
import BaseButton from '@/components/base/BaseButton.vue' import BaseButton from '@/components/base/BaseButton.vue'
import {includesById} from '@/helpers/utils' import {includesById} from '@/helpers/utils'
import ProjectUserService from '@/services/projectUsers' import ListUserService from '@/services/listUsers'
import {success} from '@/message' import {success} from '@/message'
import {useTaskStore} from '@/stores/tasks' import {useTaskStore} from '@/stores/tasks'
@ -42,7 +42,7 @@ const props = defineProps({
type: Number, type: Number,
required: true, required: true,
}, },
projectId: { listId: {
type: Number, type: Number,
required: true, required: true,
}, },
@ -59,7 +59,7 @@ const emit = defineEmits(['update:modelValue'])
const taskStore = useTaskStore() const taskStore = useTaskStore()
const {t} = useI18n({useScope: 'global'}) const {t} = useI18n({useScope: 'global'})
const projectUserService = shallowReactive(new ProjectUserService()) const listUserService = shallowReactive(new ListUserService())
const foundUsers = ref<IUser[]>([]) const foundUsers = ref<IUser[]>([])
const assignees = ref<IUser[]>([]) const assignees = ref<IUser[]>([])
let isAdding = false let isAdding = false
@ -94,7 +94,7 @@ async function addAssignee(user: IUser) {
async function removeAssignee(user: IUser) { async function removeAssignee(user: IUser) {
await taskStore.removeAssignee({user: user, taskId: props.taskId}) await taskStore.removeAssignee({user: user, taskId: props.taskId})
// Remove the assignee from the project // Remove the assignee from the list
for (const a in assignees.value) { for (const a in assignees.value) {
if (assignees.value[a].id === user.id) { if (assignees.value[a].id === user.id) {
assignees.value.splice(a, 1) assignees.value.splice(a, 1)
@ -109,7 +109,7 @@ async function findUser(query: string) {
return return
} }
const response = await projectUserService.getAll({projectId: props.projectId}, {s: query}) as IUser[] const response = await listUserService.getAll({listId: props.listId}, {s: query}) as IUser[]
// Filter the results to not include users who are already assigned // Filter the results to not include users who are already assigned
foundUsers.value = response foundUsers.value = response

View File

@ -1,18 +1,18 @@
<template> <template>
<Multiselect <Multiselect
class="control is-expanded" class="control is-expanded"
:placeholder="$t('project.search')" :placeholder="$t('list.search')"
:search-results="foundProjects" :search-results="foundLists"
label="title" label="title"
:select-placeholder="$t('project.searchSelect')" :select-placeholder="$t('list.searchSelect')"
:model-value="project" :model-value="list"
@update:model-value="Object.assign(project, $event)" @update:model-value="Object.assign(list, $event)"
@select="select" @select="select"
@search="findProjects" @search="findLists"
> >
<template #searchResult="{option}"> <template #searchResult="{option}">
<span class="project-namespace-title search-result">{{ namespace((option as IProject).namespaceId) }} ></span> <span class="list-namespace-title search-result">{{ namespace((option as IList).namespaceId) }} ></span>
{{ (option as IProject).title }} {{ (option as IList).title }}
</template> </template>
</Multiselect> </Multiselect>
</template> </template>
@ -22,19 +22,19 @@ import {reactive, ref, watch} from 'vue'
import type {PropType} from 'vue' import type {PropType} from 'vue'
import {useI18n} from 'vue-i18n' import {useI18n} from 'vue-i18n'
import type {IProject} from '@/modelTypes/IProject' import type {IList} from '@/modelTypes/IList'
import type {INamespace} from '@/modelTypes/INamespace' import type {INamespace} from '@/modelTypes/INamespace'
import {useProjectStore} from '@/stores/projects' import {useListStore} from '@/stores/lists'
import {useNamespaceStore} from '@/stores/namespaces' import {useNamespaceStore} from '@/stores/namespaces'
import ProjectModel from '@/models/project' import ListModel from '@/models/list'
import Multiselect from '@/components/input/multiselect.vue' import Multiselect from '@/components/input/multiselect.vue'
const props = defineProps({ const props = defineProps({
modelValue: { modelValue: {
type: Object as PropType<IProject>, type: Object as PropType<IList>,
required: false, required: false,
}, },
}) })
@ -42,45 +42,45 @@ const emit = defineEmits(['update:modelValue'])
const {t} = useI18n({useScope: 'global'}) const {t} = useI18n({useScope: 'global'})
const project: IProject = reactive(new ProjectModel()) const list: IList = reactive(new ListModel())
watch( watch(
() => props.modelValue, () => props.modelValue,
(newProject) => Object.assign(project, newProject), (newList) => Object.assign(list, newList),
{ {
immediate: true, immediate: true,
deep: true, deep: true,
}, },
) )
const projectStore = useProjectStore() const listStore = useListStore()
const namespaceStore = useNamespaceStore() const namespaceStore = useNamespaceStore()
const foundProjects = ref<IProject[]>([]) const foundLists = ref<IList[]>([])
function findProjects(query: string) { function findLists(query: string) {
if (query === '') { if (query === '') {
select(null) select(null)
} }
foundProjects.value = projectStore.searchProject(query) foundLists.value = listStore.searchList(query)
} }
function select(l: IProject | null) { function select(l: IList | null) {
if (l === null) { if (l === null) {
return return
} }
Object.assign(project, l) Object.assign(list, l)
emit('update:modelValue', project) emit('update:modelValue', list)
} }
function namespace(namespaceId: INamespace['id']) { function namespace(namespaceId: INamespace['id']) {
const namespace = namespaceStore.getNamespaceById(namespaceId) const namespace = namespaceStore.getNamespaceById(namespaceId)
return namespace !== null return namespace !== null
? namespace.title ? namespace.title
: t('project.shared') : t('list.shared')
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.project-namespace-title { .list-namespace-title {
color: var(--grey-500); color: var(--grey-500);
} }
</style> </style>

View File

@ -37,14 +37,14 @@
{{ $t('task.quickAddMagic.multiple') }} {{ $t('task.quickAddMagic.multiple') }}
</p> </p>
<h3>{{ $t('project.list.title') }}</h3> <h3>{{ $t('list.list.title') }}</h3>
<p> <p>
{{ $t('task.quickAddMagic.project1', {prefix: prefixes.project}) }} {{ $t('task.quickAddMagic.list1', {prefix: prefixes.list}) }}
{{ $t('task.quickAddMagic.project2') }} {{ $t('task.quickAddMagic.list2') }}
</p> </p>
<p> <p>
{{ $t('task.quickAddMagic.project3') }} {{ $t('task.quickAddMagic.list3') }}
{{ $t('task.quickAddMagic.project4', {prefix: prefixes.project}) }} {{ $t('task.quickAddMagic.list4', {prefix: prefixes.list}) }}
</p> </p>
<h3>{{ $t('task.quickAddMagic.dateAndTime') }}</h3> <h3>{{ $t('task.quickAddMagic.dateAndTime') }}</h3>

View File

@ -43,8 +43,8 @@
:class="{'is-strikethrough': task.done}" :class="{'is-strikethrough': task.done}"
> >
<span <span
class="different-project" class="different-list"
v-if="task.projectId !== projectId" v-if="task.listId !== listId"
> >
<span <span
v-if="task.differentNamespace !== null" v-if="task.differentNamespace !== null"
@ -52,9 +52,9 @@
{{ task.differentNamespace }} > {{ task.differentNamespace }} >
</span> </span>
<span <span
v-if="task.differentProject !== null" v-if="task.differentList !== null"
v-tooltip="$t('task.relation.differentProject')"> v-tooltip="$t('task.relation.differentList')">
{{ task.differentProject }} > {{ task.differentList }} >
</span> </span>
</span> </span>
{{ task.title }} {{ task.title }}
@ -98,8 +98,8 @@
:class="{ 'is-strikethrough': t.done}" :class="{ 'is-strikethrough': t.done}"
> >
<span <span
class="different-project" class="different-list"
v-if="t.projectId !== projectId" v-if="t.listId !== listId"
> >
<span <span
v-if="t.differentNamespace !== null" v-if="t.differentNamespace !== null"
@ -107,9 +107,9 @@
{{ t.differentNamespace }} > {{ t.differentNamespace }} >
</span> </span>
<span <span
v-if="t.differentProject !== null" v-if="t.differentList !== null"
v-tooltip="$t('task.relation.differentProject')"> v-tooltip="$t('task.relation.differentList')">
{{ t.differentProject }} > {{ t.differentList }} >
</span> </span>
</span> </span>
{{ t.title }} {{ t.title }}
@ -186,7 +186,7 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
projectId: { listId: {
type: Number, type: Number,
default: 0, default: 0,
}, },
@ -230,17 +230,17 @@ async function findTasks(newQuery: string) {
foundTasks.value = await taskService.getAll({}, {s: newQuery}) foundTasks.value = await taskService.getAll({}, {s: newQuery})
} }
const getProjectAndNamespaceById = (projectId: number) => namespaceStore.getProjectAndNamespaceById(projectId, true) const getListAndNamespaceById = (listId: number) => namespaceStore.getListAndNamespaceById(listId, true)
const namespace = computed(() => getProjectAndNamespaceById(props.projectId)?.namespace) const namespace = computed(() => getListAndNamespaceById(props.listId)?.namespace)
function mapRelatedTasks(tasks: ITask[]) { function mapRelatedTasks(tasks: ITask[]) {
return tasks.map(task => { return tasks.map(task => {
// by doing this here once we can save a lot of duplicate calls in the template // by doing this here once we can save a lot of duplicate calls in the template
const { const {
project, list,
namespace: taskNamespace, namespace: taskNamespace,
} = getProjectAndNamespaceById(task.projectId) || {project: null, namespace: null} } = getListAndNamespaceById(task.listId) || {list: null, namespace: null}
return { return {
...task, ...task,
@ -248,10 +248,10 @@ function mapRelatedTasks(tasks: ITask[]) {
(taskNamespace !== null && (taskNamespace !== null &&
taskNamespace.id !== namespace.value.id && taskNamespace.id !== namespace.value.id &&
taskNamespace?.title) || null, taskNamespace?.title) || null,
differentProject: differentList:
(project !== null && (list !== null &&
task.projectId !== props.projectId && task.listId !== props.listId &&
project?.title) || null, list?.title) || null,
} }
}) })
} }
@ -343,7 +343,7 @@ async function removeTaskRelation() {
} }
async function createAndRelateTask(title: string) { async function createAndRelateTask(title: string) {
const newTask = await taskService.create(new TaskModel({title, projectId: props.projectId})) const newTask = await taskService.create(new TaskModel({title, listId: props.listId}))
newTaskRelation.task = newTask newTaskRelation.task = newTask
await addTaskRelation() await addTaskRelation()
} }
@ -351,7 +351,7 @@ async function createAndRelateTask(title: string) {
async function toggleTaskDone(task: ITask) { async function toggleTaskDone(task: ITask) {
await taskStore.update(task) await taskStore.update(task)
// Find the task in the project and update it so that it is correctly strike through // Find the task in the list and update it so that it is correctly strike through
Object.entries(relatedTasks.value).some(([kind, tasks]) => { Object.entries(relatedTasks.value).some(([kind, tasks]) => {
return (tasks as ITask[]).some((t, key) => { return (tasks as ITask[]).some((t, key) => {
const found = t.id === task.id const found = t.id === task.id
@ -379,7 +379,7 @@ async function toggleTaskDone(task: ITask) {
} }
} }
.different-project { .different-list {
color: var(--grey-500); color: var(--grey-500);
width: auto; width: auto;
} }

View File

@ -1,33 +1,30 @@
<template> <template>
<router-link <div :class="{'is-loading': taskService.loading}" class="task loader-container">
:to="taskDetailRoute"
:class="{'is-loading': taskService.loading}"
class="task loader-container"
>
<fancycheckbox <fancycheckbox
:disabled="(isArchived || disabled) && !canMarkAsDone" :disabled="(isArchived || disabled) && !canMarkAsDone"
@change="markAsDone" @change="markAsDone"
v-model="task.done" v-model="task.done"
/> />
<ColorBubble <ColorBubble
v-if="showProjectColor && projectColor !== '' && currentProject.id !== task.projectId" v-if="showListColor && listColor !== '' && currentList.id !== task.listId"
:color="projectColor" :color="listColor"
class="mr-1" class="mr-1"
/> />
<div <router-link
:class="{ 'done': task.done, 'show-project': showProject && project !== null}" :to="taskDetailRoute"
:class="{ 'done': task.done, 'show-list': showList && taskList !== null}"
class="tasktext" class="tasktext"
> >
<span> <span>
<router-link <router-link
v-if="showProject && project !== null" v-if="showList && taskList !== null"
:to="{ name: 'project.list', params: { projectId: task.projectId } }" :to="{ name: 'list.list', params: { listId: task.listId } }"
class="task-project" class="task-list"
:class="{'mr-2': task.hexColor !== ''}" :class="{'mr-2': task.hexColor !== ''}"
v-tooltip="$t('task.detail.belongsToProject', {project: project.title})"> v-tooltip="$t('task.detail.belongsToList', {list: taskList.title})">
{{ project.title }} {{ taskList.title }}
</router-link> </router-link>
<ColorBubble <ColorBubble
@ -84,19 +81,19 @@
<priority-label :priority="task.priority" :done="task.done"/> <priority-label :priority="task.priority" :done="task.done"/>
<span> <span>
<span class="project-task-icon" v-if="task.attachments.length > 0"> <span class="list-task-icon" v-if="task.attachments.length > 0">
<icon icon="paperclip"/> <icon icon="paperclip"/>
</span> </span>
<span class="project-task-icon" v-if="task.description"> <span class="list-task-icon" v-if="task.description">
<icon icon="align-left"/> <icon icon="align-left"/>
</span> </span>
<span class="project-task-icon" v-if="task.repeatAfter.amount > 0"> <span class="list-task-icon" v-if="task.repeatAfter.amount > 0">
<icon icon="history"/> <icon icon="history"/>
</span> </span>
</span> </span>
<checklist-summary :task="task"/> <checklist-summary :task="task"/>
</div> </router-link>
<progress <progress
class="progress is-small" class="progress is-small"
@ -107,24 +104,24 @@
</progress> </progress>
<router-link <router-link
v-if="!showProject && currentProject.id !== task.projectId && project !== null" v-if="!showList && currentList.id !== task.listId && taskList !== null"
:to="{ name: 'project.list', params: { projectId: task.projectId } }" :to="{ name: 'list.list', params: { listId: task.listId } }"
class="task-project" class="task-list"
v-tooltip="$t('task.detail.belongsToProject', {project: project.title})" v-tooltip="$t('task.detail.belongsToList', {list: taskList.title})"
> >
{{ project.title }} {{ taskList.title }}
</router-link> </router-link>
<BaseButton <BaseButton
:class="{'is-favorite': task.isFavorite}" :class="{'is-favorite': task.isFavorite}"
@click.prevent="toggleFavorite" @click="toggleFavorite"
class="favorite" class="favorite"
> >
<icon icon="star" v-if="task.isFavorite"/> <icon icon="star" v-if="task.isFavorite"/>
<icon :icon="['far', 'star']" v-else/> <icon :icon="['far', 'star']" v-else/>
</BaseButton> </BaseButton>
<slot /> <slot />
</router-link> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@ -151,7 +148,7 @@ import {closeWhenClickedOutside} from '@/helpers/closeWhenClickedOutside'
import {formatDateSince, formatISO, formatDateLong} from '@/helpers/time/formatDate' import {formatDateSince, formatISO, formatDateLong} from '@/helpers/time/formatDate'
import {success} from '@/message' import {success} from '@/message'
import {useProjectStore} from '@/stores/projects' import {useListStore} from '@/stores/lists'
import {useNamespaceStore} from '@/stores/namespaces' import {useNamespaceStore} from '@/stores/namespaces'
import {useBaseStore} from '@/stores/base' import {useBaseStore} from '@/stores/base'
import {useTaskStore} from '@/stores/tasks' import {useTaskStore} from '@/stores/tasks'
@ -165,7 +162,7 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
showProject: { showList: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
@ -173,7 +170,7 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
showProjectColor: { showListColor: {
type: Boolean, type: Boolean,
default: true, default: true,
}, },
@ -210,18 +207,18 @@ onBeforeUnmount(() => {
}) })
const baseStore = useBaseStore() const baseStore = useBaseStore()
const projectStore = useProjectStore() const listStore = useListStore()
const taskStore = useTaskStore() const taskStore = useTaskStore()
const namespaceStore = useNamespaceStore() const namespaceStore = useNamespaceStore()
const project = computed(() => projectStore.getProjectById(task.value.projectId)) const taskList = computed(() => listStore.getListById(task.value.listId))
const projectColor = computed(() => project.value !== null ? project.value.hexColor : '') const listColor = computed(() => taskList.value !== null ? taskList.value.hexColor : '')
const currentProject = computed(() => { const currentList = computed(() => {
return typeof baseStore.currentProject === 'undefined' ? { return typeof baseStore.currentList === 'undefined' ? {
id: 0, id: 0,
title: '', title: '',
} : baseStore.currentProject } : baseStore.currentList
}) })
const taskDetailRoute = computed(() => ({ const taskDetailRoute = computed(() => ({
@ -242,7 +239,7 @@ async function markAsDone(checked: boolean) {
t('task.doneSuccess') : t('task.doneSuccess') :
t('task.undoneSuccess'), t('task.undoneSuccess'),
}, [{ }, [{
title: t('task.undo'), title: 'Undo',
callback: () => undoDone(checked), callback: () => undoDone(checked),
}]) }])
} }
@ -288,11 +285,7 @@ function hideDeferDueDatePopup(e) {
border-radius: $radius; border-radius: $radius;
border: 2px solid transparent; border: 2px solid transparent;
color: var(--text);
transition: color ease $transition-duration;
&:hover { &:hover {
color: var(--grey-900);
background-color: var(--grey-100); background-color: var(--grey-100);
} }
@ -314,7 +307,7 @@ function hideDeferDueDatePopup(e) {
} }
} }
.task-project { .task-list {
width: auto; width: auto;
color: var(--grey-400); color: var(--grey-400);
font-size: .9rem; font-size: .9rem;
@ -329,7 +322,7 @@ function hideDeferDueDatePopup(e) {
width: 27px; width: 27px;
} }
.project-task-icon { .list-task-icon {
margin-left: 6px; margin-left: 6px;
&:not(:first-of-type) { &:not(:first-of-type) {
@ -338,8 +331,17 @@ function hideDeferDueDatePopup(e) {
} }
a {
color: var(--text);
transition: color ease $transition-duration;
&:hover {
color: var(--grey-900);
}
}
.favorite { .favorite {
opacity: 1; opacity: 0;
text-align: center; text-align: center;
width: 27px; width: 27px;
transition: opacity $transition, color $transition; transition: opacity $transition, color $transition;
@ -354,26 +356,21 @@ function hideDeferDueDatePopup(e) {
} }
} }
.handle { &:hover .favorite {
opacity: 1; opacity: 1;
}
.handle {
opacity: 0;
transition: opacity $transition; transition: opacity $transition;
margin-right: .25rem; margin-right: .25rem;
cursor: grab; cursor: grab;
} }
@media(hover: hover) and (pointer: fine) { &:hover .handle {
& .favorite, opacity: 1;
& .handle {
opacity: 0;
}
&:hover .favorite,
&:hover .handle {
opacity: 1;
}
} }
:deep(.fancycheckbox) { :deep(.fancycheckbox) {
height: 18px; height: 18px;
padding-top: 0; padding-top: 0;
@ -394,7 +391,7 @@ function hideDeferDueDatePopup(e) {
width: auto; width: auto;
} }
.show-project .parent-tasks { .show-list .parent-tasks {
padding-left: .25rem; padding-left: .25rem;
} }
@ -425,4 +422,4 @@ function hideDeferDueDatePopup(e) {
margin-bottom: 0; margin-bottom: 0;
} }
} }
</style> </style>

View File

@ -1,6 +1,5 @@
import {ref, watch, readonly} from 'vue' import {ref, watch, readonly} from 'vue'
import {useLocalStorage, useMediaQuery} from '@vueuse/core' import {useLocalStorage, useMediaQuery} from '@vueuse/core'
import {useRoute} from 'vue-router'
const BULMA_MOBILE_BREAKPOINT = 768 const BULMA_MOBILE_BREAKPOINT = 768
@ -16,8 +15,7 @@ export function useMenuActive() {
) )
const menuActive = ref(false) const menuActive = ref(false)
const route = useRoute()
// set to prefered value // set to prefered value
watch(isMobile, (current) => { watch(isMobile, (current) => {
menuActive.value = current menuActive.value = current
@ -33,9 +31,6 @@ export function useMenuActive() {
} }
}) })
// Hide the menu on mobile when the route changes (e.g. when the user taps a menu item)
watch(() => route.fullPath, () => isMobile.value && setMenuActive(false))
function setMenuActive(newMenuActive: boolean) { function setMenuActive(newMenuActive: boolean) {
menuActive.value = newMenuActive menuActive.value = newMenuActive
} }
@ -49,4 +44,4 @@ export function useMenuActive() {
setMenuActive, setMenuActive,
toggleMenu, toggleMenu,
} }
} }

View File

@ -5,22 +5,6 @@ import TaskCollectionService from '@/services/taskCollection'
import type {ITask} from '@/modelTypes/ITask' import type {ITask} from '@/modelTypes/ITask'
import {error} from '@/message' import {error} from '@/message'
export type Order = 'asc' | 'desc' | 'none'
export interface SortBy {
id?: Order
index?: Order
done?: Order
title?: Order
priority?: Order
due_date?: Order
start_date?: Order
end_date?: Order
percent_done?: Order
created?: Order
updated?: Order
}
// FIXME: merge with DEFAULT_PARAMS in filters.vue // FIXME: merge with DEFAULT_PARAMS in filters.vue
export const getDefaultParams = () => ({ export const getDefaultParams = () => ({
sort_by: ['position', 'id'], sort_by: ['position', 'id'],
@ -31,7 +15,7 @@ export const getDefaultParams = () => ({
filter_concat: 'and', filter_concat: 'and',
}) })
const SORT_BY_DEFAULT: SortBy = { const SORT_BY_DEFAULT = {
id: 'desc', id: 'desc',
} }
@ -58,9 +42,9 @@ const SORT_BY_DEFAULT: SortBy = {
} }
/** /**
* This mixin provides a base set of methods and properties to get tasks. * This mixin provides a base set of methods and properties to get tasks on a list.
*/ */
export function useTaskList(projectId, sortByDefault: SortBy = SORT_BY_DEFAULT) { export function useTaskList(listId, sortByDefault = SORT_BY_DEFAULT) {
const params = ref({...getDefaultParams()}) const params = ref({...getDefaultParams()})
const search = ref('') const search = ref('')
@ -80,7 +64,7 @@ export function useTaskList(projectId, sortByDefault: SortBy = SORT_BY_DEFAULT)
loadParams = formatSortOrder(sortBy.value, loadParams) loadParams = formatSortOrder(sortBy.value, loadParams)
return [ return [
{projectId: projectId.value}, {listId: listId.value},
loadParams, loadParams,
page.value || 1, page.value || 1,
] ]

View File

@ -113,8 +113,8 @@ export const checkAndSetApiUrl = (url: string): Promise<string> => {
window.API_URL = oldUrl window.API_URL = oldUrl
throw e throw e
}) })
.then(success => { .then(r => {
if (success) { if (typeof r !== 'undefined') {
localStorage.setItem('API_URL', window.API_URL) localStorage.setItem('API_URL', window.API_URL)
return window.API_URL return window.API_URL
} }

View File

@ -0,0 +1,9 @@
import {i18n} from '@/i18n'
import type {IList} from '@/modelTypes/IList'
export function getListTitle(l: IList) {
if (l.id === -1) {
return i18n.global.t('list.pseudo.favorites.title')
}
return l.title
}

View File

@ -3,7 +3,7 @@ import type {INamespace} from '@/modelTypes/INamespace'
export const getNamespaceTitle = (n: INamespace) => { export const getNamespaceTitle = (n: INamespace) => {
if (n.id === -1) { if (n.id === -1) {
return i18n.global.t('namespace.pseudo.sharedProjects.title') return i18n.global.t('namespace.pseudo.sharedLists.title')
} }
if (n.id === -2) { if (n.id === -2) {
return i18n.global.t('namespace.pseudo.favorites.title') return i18n.global.t('namespace.pseudo.favorites.title')

View File

@ -1,9 +0,0 @@
import {i18n} from '@/i18n'
import type {IProject} from '@/modelTypes/IProject'
export function getProjectTitle(l: IProject) {
if (l.id === -1) {
return i18n.global.t('project.pseudo.favorites.title')
}
return l.title
}

View File

@ -1,4 +1,4 @@
import {getProjectFromPrefix} from '@/modules/parseTaskText' import {getListFromPrefix} from '@/modules/parseTaskText'
export interface TaskWithParent { export interface TaskWithParent {
title: string, title: string,
@ -26,7 +26,7 @@ export function parseSubtasksViaIndention(taskTitles: string): TaskWithParent[]
list: null, list: null,
} }
task.list = getProjectFromPrefix(task.title) task.list = getListFromPrefix(task.title)
if (index === 0) { if (index === 0) {
return task return task
@ -49,7 +49,7 @@ export function parseSubtasksViaIndention(taskTitles: string): TaskWithParent[]
task.parent = task.parent.replace(spaceRegex, '') task.parent = task.parent.replace(spaceRegex, '')
if (task.list === null) { if (task.list === null) {
// This allows to specify a list once for the parent task and inherit it to all subtasks // This allows to specify a list once for the parent task and inherit it to all subtasks
task.list = getProjectFromPrefix(task.parent) task.list = getListFromPrefix(task.parent)
} }
} }

View File

@ -1,100 +0,0 @@
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>
>
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.')) {
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
if (savedProjectView !== null) {
savedProjectViewSettings = JSON.parse(savedProjectView) as ProjectViewSettings
}
let projectViewSettings: ProjectViewSettings = {}
if (savedProjectViewSettings) {
projectViewSettings = savedProjectViewSettings
}
projectViewSettings[projectId] = routeName
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
}
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
}
}

View File

@ -1,5 +1,5 @@
import type {IBucket} from '@/modelTypes/IBucket' import type {IBucket} from '@/modelTypes/IBucket'
import type {IProject} from '@/modelTypes/IProject' import type {IList} from '@/modelTypes/IList'
const key = 'collapsedBuckets' const key = 'collapsedBuckets'
@ -13,22 +13,22 @@ function getAllState() {
} }
export const saveCollapsedBucketState = ( export const saveCollapsedBucketState = (
projectId: IProject['id'], listId: IList['id'],
collapsedBuckets: CollapsedBuckets, collapsedBuckets: CollapsedBuckets,
) => { ) => {
const state = getAllState() const state = getAllState()
state[projectId] = collapsedBuckets state[listId] = collapsedBuckets
for (const bucketId in state[projectId]) { for (const bucketId in state[listId]) {
if (!state[projectId][bucketId]) { if (!state[listId][bucketId]) {
delete state[projectId][bucketId] delete state[listId][bucketId]
} }
} }
localStorage.setItem(key, JSON.stringify(state)) localStorage.setItem(key, JSON.stringify(state))
} }
export function getCollapsedBucketState(projectId : IProject['id']) { export function getCollapsedBucketState(listId : IList['id']) {
const state = getAllState() const state = getAllState()
return typeof state[projectId] !== 'undefined' return typeof state[listId] !== 'undefined'
? state[projectId] ? state[listId]
: {} : {}
} }

View File

@ -0,0 +1,53 @@
import type { IList } from '@/modelTypes/IList'
type ListView = Record<IList['id'], string>
const DEFAULT_LIST_VIEW = 'list.list' as const
/**
* Save the current list view to local storage
*/
export function saveListView(listId: IList['id'], routeName: string) {
if (routeName.includes('settings.')) {
return
}
if (!listId) {
return
}
// We use local storage and not the store here to make it persistent across reloads.
const savedListView = localStorage.getItem('listView')
let savedListViewJson: ListView | false = false
if (savedListView !== null) {
savedListViewJson = JSON.parse(savedListView) as ListView
}
let listView: ListView = {}
if (savedListViewJson) {
listView = savedListViewJson
}
listView[listId] = routeName
localStorage.setItem('listView', JSON.stringify(listView))
}
export const getListView = (listId: IList['id']) => {
// Remove old stored settings
const savedListView = localStorage.getItem('listView')
if (savedListView !== null && savedListView.startsWith('list.')) {
localStorage.removeItem('listView')
}
if (!savedListView) {
return DEFAULT_LIST_VIEW
}
const savedListViewJson: ListView = JSON.parse(savedListView)
if (!savedListViewJson[listId]) {
return DEFAULT_LIST_VIEW
}
return savedListViewJson[listId]
}

View File

@ -169,6 +169,14 @@
"title": "List Title", "title": "List Title",
"color": "Color", "color": "Color",
"lists": "Lists", "lists": "Lists",
"list": {
"title": "List",
"add": "Add",
"addPlaceholder": "Add a new task…",
"empty": "This list is currently empty.",
"newTaskCta": "Create a new task.",
"editTask": "Edit Task"
},
"search": "Type to search for a list…", "search": "Type to search for a list…",
"searchSelect": "Click or press enter to select this list", "searchSelect": "Click or press enter to select this list",
"shared": "Shared Lists", "shared": "Shared Lists",
@ -270,14 +278,6 @@
"delete": "Delete" "delete": "Delete"
} }
}, },
"list": {
"title": "List",
"add": "Add",
"addPlaceholder": "Add a new task…",
"empty": "This list is currently empty.",
"newTaskCta": "Create a new task.",
"editTask": "Edit Task"
},
"gantt": { "gantt": {
"title": "Gantt", "title": "Gantt",
"showTasksWithoutDates": "Show tasks which don't have dates set", "showTasksWithoutDates": "Show tasks which don't have dates set",
@ -404,8 +404,7 @@
"create": { "create": {
"title": "New Saved Filter", "title": "New Saved Filter",
"description": "A saved filter is a virtual list which is computed from a set of filters each time it is accessed. Once created, it will appear in a special namespace.", "description": "A saved filter is a virtual list which is computed from a set of filters each time it is accessed. Once created, it will appear in a special namespace.",
"action": "Create new saved filter", "action": "Create new saved filter"
"titleRequired": "Please provide a title for the filter."
}, },
"delete": { "delete": {
"header": "Delete this saved filter", "header": "Delete this saved filter",
@ -602,7 +601,6 @@
"addReminder": "Add a new reminder…", "addReminder": "Add a new reminder…",
"doneSuccess": "The task was successfully marked as done.", "doneSuccess": "The task was successfully marked as done.",
"undoneSuccess": "The task was successfully un-marked as done.", "undoneSuccess": "The task was successfully un-marked as done.",
"undo": "Undo",
"openDetail": "Open task detail view", "openDetail": "Open task detail view",
"checklistTotal": "{checked} of {total} tasks", "checklistTotal": "{checked} of {total} tasks",
"checklistAllDone": "{total} tasks", "checklistAllDone": "{total} tasks",

View File

@ -169,6 +169,14 @@
"title": "Název seznamu", "title": "Název seznamu",
"color": "Barva", "color": "Barva",
"lists": "Seznamy", "lists": "Seznamy",
"list": {
"title": "Seznam",
"add": "Přidat",
"addPlaceholder": "Přidat nový úkol…",
"empty": "Tento seznam je nyní prázdný.",
"newTaskCta": "Vytvořit nový úkol.",
"editTask": "Upravit úkol"
},
"search": "Začni psát pro vyhledání seznamu…", "search": "Začni psát pro vyhledání seznamu…",
"searchSelect": "Klikněte nebo stiskněte Enter pro výběr tohoto seznamu", "searchSelect": "Klikněte nebo stiskněte Enter pro výběr tohoto seznamu",
"shared": "Sdílené seznamy", "shared": "Sdílené seznamy",
@ -270,14 +278,6 @@
"delete": "Smazat" "delete": "Smazat"
} }
}, },
"list": {
"title": "Seznam",
"add": "Přidat",
"addPlaceholder": "Přidat nový úkol…",
"empty": "Tento seznam je nyní prázdný.",
"newTaskCta": "Vytvořit nový úkol.",
"editTask": "Upravit úkol"
},
"gantt": { "gantt": {
"title": "Gantt", "title": "Gantt",
"showTasksWithoutDates": "Zobrazit úkoly, které nemají nastavené datum", "showTasksWithoutDates": "Zobrazit úkoly, které nemají nastavené datum",
@ -404,8 +404,7 @@
"create": { "create": {
"title": "Nový uložený filtr", "title": "Nový uložený filtr",
"description": "Uložený filtr je virtuální seznam, který se počítá ze sady filtrů pokaždé, když je přístupný. Jakmile bude vytvořen, objeví se ve speciálním prostoru.", "description": "Uložený filtr je virtuální seznam, který se počítá ze sady filtrů pokaždé, když je přístupný. Jakmile bude vytvořen, objeví se ve speciálním prostoru.",
"action": "Vytvořit uložený filtr", "action": "Vytvořit uložený filtr"
"titleRequired": "Please provide a title for the filter."
}, },
"delete": { "delete": {
"header": "Smazat tento uložený filtr", "header": "Smazat tento uložený filtr",
@ -602,7 +601,6 @@
"addReminder": "Přidat novou připomínku…", "addReminder": "Přidat novou připomínku…",
"doneSuccess": "Úkol byl úspěšně označen jako dokončený.", "doneSuccess": "Úkol byl úspěšně označen jako dokončený.",
"undoneSuccess": "Úkol byl úspěšně znovu otevřen.", "undoneSuccess": "Úkol byl úspěšně znovu otevřen.",
"undo": "Undo",
"openDetail": "Otevřít zobrazení detailu úkolu", "openDetail": "Otevřít zobrazení detailu úkolu",
"checklistTotal": "{checked} z {total} úkolů", "checklistTotal": "{checked} z {total} úkolů",
"checklistAllDone": "{total} úkolů", "checklistAllDone": "{total} úkolů",

View File

@ -169,6 +169,14 @@
"title": "Listetitel", "title": "Listetitel",
"color": "Farve", "color": "Farve",
"lists": "Lister", "lists": "Lister",
"list": {
"title": "Liste",
"add": "Tilføj",
"addPlaceholder": "Tilføj en ny opgave…",
"empty": "Denne liste er i øjeblikket tom.",
"newTaskCta": "Opret en ny opgave.",
"editTask": "Rediger opgave"
},
"search": "Skriv for at søge efter en liste…", "search": "Skriv for at søge efter en liste…",
"searchSelect": "Klik eller tryk på Enter for at vælge denne liste", "searchSelect": "Klik eller tryk på Enter for at vælge denne liste",
"shared": "Delte Lister", "shared": "Delte Lister",
@ -270,14 +278,6 @@
"delete": "Slet" "delete": "Slet"
} }
}, },
"list": {
"title": "Liste",
"add": "Tilføj",
"addPlaceholder": "Tilføj en ny opgave…",
"empty": "Denne liste er i øjeblikket tom.",
"newTaskCta": "Opret en ny opgave.",
"editTask": "Rediger opgave"
},
"gantt": { "gantt": {
"title": "Gantt", "title": "Gantt",
"showTasksWithoutDates": "Vis opgaver som ikke har angivet datoer", "showTasksWithoutDates": "Vis opgaver som ikke har angivet datoer",
@ -404,8 +404,7 @@
"create": { "create": {
"title": "Nyt Gemt Filter", "title": "Nyt Gemt Filter",
"description": "Et gemt filter er en virtuel liste, som beregnes ud fra et sæt filtre, hver gang det er tilgået. Når den er oprettet, vises den i et særligt navneområde.", "description": "Et gemt filter er en virtuel liste, som beregnes ud fra et sæt filtre, hver gang det er tilgået. Når den er oprettet, vises den i et særligt navneområde.",
"action": "Opret nyt gemt filter", "action": "Opret nyt gemt filter"
"titleRequired": "Please provide a title for the filter."
}, },
"delete": { "delete": {
"header": "Slet dette gemte filter", "header": "Slet dette gemte filter",
@ -602,7 +601,6 @@
"addReminder": "Tilføj en ny påmindelse…", "addReminder": "Tilføj en ny påmindelse…",
"doneSuccess": "Opgaven blev markeret som udført.", "doneSuccess": "Opgaven blev markeret som udført.",
"undoneSuccess": "Opgaven fik fjernet sin udført-markering.", "undoneSuccess": "Opgaven fik fjernet sin udført-markering.",
"undo": "Undo",
"openDetail": "Åbn detaljeret opgavevisning", "openDetail": "Åbn detaljeret opgavevisning",
"checklistTotal": "{checked} af {total} opgaver", "checklistTotal": "{checked} af {total} opgaver",
"checklistAllDone": "{total} opgaver", "checklistAllDone": "{total} opgaver",

View File

@ -169,6 +169,14 @@
"title": "Listentitel", "title": "Listentitel",
"color": "Farbe", "color": "Farbe",
"lists": "Listen", "lists": "Listen",
"list": {
"title": "Liste",
"add": "Hinzufügen",
"addPlaceholder": "Neue Aufgabe hinzufügen …",
"empty": "Diese Liste ist derzeit leer.",
"newTaskCta": "Eine neue Aufgabe erstellen.",
"editTask": "Aufgabe bearbeiten"
},
"search": "Tippe, um nach einer Liste zu suchen…", "search": "Tippe, um nach einer Liste zu suchen…",
"searchSelect": "Klicke auf oder drücke die Eingabetaste, um diese Liste auszuwählen", "searchSelect": "Klicke auf oder drücke die Eingabetaste, um diese Liste auszuwählen",
"shared": "Geteilte Listen", "shared": "Geteilte Listen",
@ -270,14 +278,6 @@
"delete": "Löschen" "delete": "Löschen"
} }
}, },
"list": {
"title": "Liste",
"add": "Hinzufügen",
"addPlaceholder": "Neue Aufgabe hinzufügen …",
"empty": "Diese Liste ist derzeit leer.",
"newTaskCta": "Eine neue Aufgabe erstellen.",
"editTask": "Aufgabe bearbeiten"
},
"gantt": { "gantt": {
"title": "Gantt", "title": "Gantt",
"showTasksWithoutDates": "Aufgaben anzeigen, für die keine Daten festgelegt sind", "showTasksWithoutDates": "Aufgaben anzeigen, für die keine Daten festgelegt sind",
@ -404,8 +404,7 @@
"create": { "create": {
"title": "Neuer gespeicherter Filter", "title": "Neuer gespeicherter Filter",
"description": "Ein gespeicherter Filter ist eine virtuelle Liste, die bei jedem Zugriff aus einem Satz von Filtern errechnet wird. Einmal erstellt, erscheint diese in einem speziellen Namespace.", "description": "Ein gespeicherter Filter ist eine virtuelle Liste, die bei jedem Zugriff aus einem Satz von Filtern errechnet wird. Einmal erstellt, erscheint diese in einem speziellen Namespace.",
"action": "Neuen gespeicherten Filter erstellen", "action": "Neuen gespeicherten Filter erstellen"
"titleRequired": "Bitte gib den Titel für den Filter an."
}, },
"delete": { "delete": {
"header": "Diesen gespeicherten Filter löschen", "header": "Diesen gespeicherten Filter löschen",
@ -602,7 +601,6 @@
"addReminder": "Eine Erinnerung hinzufügen…", "addReminder": "Eine Erinnerung hinzufügen…",
"doneSuccess": "Die Aufgabe wurde erfolgreich als erledigt markiert.", "doneSuccess": "Die Aufgabe wurde erfolgreich als erledigt markiert.",
"undoneSuccess": "Die Aufgabe wurde erfolgreich als nicht-erledigt markiert.", "undoneSuccess": "Die Aufgabe wurde erfolgreich als nicht-erledigt markiert.",
"undo": "Rückgängig",
"openDetail": "Aufgabe in der Detailansicht anzeigen", "openDetail": "Aufgabe in der Detailansicht anzeigen",
"checklistTotal": "{checked} von {total} Aufgaben", "checklistTotal": "{checked} von {total} Aufgaben",
"checklistAllDone": "{total} Aufgaben", "checklistAllDone": "{total} Aufgaben",

View File

@ -169,6 +169,14 @@
"title": "Liste Titl", "title": "Liste Titl",
"color": "Farb", "color": "Farb",
"lists": "Listene", "lists": "Listene",
"list": {
"title": "Liste",
"add": "Hinzuefüege",
"addPlaceholder": "E neui Uufgab erstelle…",
"empty": "D'Liste isch momentan leer.",
"newTaskCta": "Neui Uufgab erstelle.",
"editTask": "Uufgab bearbeite"
},
"search": "Schriib, um nachere Liste z'sueche…", "search": "Schriib, um nachere Liste z'sueche…",
"searchSelect": "Druck uf Enter um die Liste uuszwähle", "searchSelect": "Druck uf Enter um die Liste uuszwähle",
"shared": "Teilti Liste", "shared": "Teilti Liste",
@ -270,14 +278,6 @@
"delete": "Chüble" "delete": "Chüble"
} }
}, },
"list": {
"title": "Liste",
"add": "Hinzuefüege",
"addPlaceholder": "E neui Uufgab erstelle…",
"empty": "D'Liste isch momentan leer.",
"newTaskCta": "Neui Uufgab erstelle.",
"editTask": "Uufgab bearbeite"
},
"gantt": { "gantt": {
"title": "Gantt", "title": "Gantt",
"showTasksWithoutDates": "Zeig Uufgabe, wo kei Date hend", "showTasksWithoutDates": "Zeig Uufgabe, wo kei Date hend",
@ -404,8 +404,7 @@
"create": { "create": {
"title": "Neuer gespeicherter Filter", "title": "Neuer gespeicherter Filter",
"description": "En gspeicherete Filter isch e virtuelli Liste, welche vomene Satz a Filter zemmegsetzt wird, sobald me uf sie zuegriift. Wenn sie mal erstellt worde isch, erhaltet si ihren eigene Namensruum.", "description": "En gspeicherete Filter isch e virtuelli Liste, welche vomene Satz a Filter zemmegsetzt wird, sobald me uf sie zuegriift. Wenn sie mal erstellt worde isch, erhaltet si ihren eigene Namensruum.",
"action": "Neue gspeicherete Filter erstelle", "action": "Neue gspeicherete Filter erstelle"
"titleRequired": "Bitte gib den Titel für den Filter an."
}, },
"delete": { "delete": {
"header": "De g'speicheret Filter chüble", "header": "De g'speicheret Filter chüble",
@ -602,7 +601,6 @@
"addReminder": "Neui Errinnerig erstelle…", "addReminder": "Neui Errinnerig erstelle…",
"doneSuccess": "Die Uufgab isch erfolgriich als \"Fertig\" markiert wordä.", "doneSuccess": "Die Uufgab isch erfolgriich als \"Fertig\" markiert wordä.",
"undoneSuccess": "Die Uufgaab isch nüme als fertig markiert.", "undoneSuccess": "Die Uufgaab isch nüme als fertig markiert.",
"undo": "Rückgängig",
"openDetail": "Uufgab i de Detailaahsicht öffne", "openDetail": "Uufgab i de Detailaahsicht öffne",
"checklistTotal": "{checked} von {total} Aufgaben", "checklistTotal": "{checked} von {total} Aufgaben",
"checklistAllDone": "{total} Aufgaben", "checklistAllDone": "{total} Aufgaben",

View File

@ -5,10 +5,10 @@
"welcomeDay": "Hi {username}!", "welcomeDay": "Hi {username}!",
"welcomeEvening": "Good Evening {username}!", "welcomeEvening": "Good Evening {username}!",
"lastViewed": "Last viewed", "lastViewed": "Last viewed",
"project": { "list": {
"newText": "You can create a new project for your new tasks:", "newText": "You can create a new list for your new tasks:",
"new": "New project", "new": "New list",
"importText": "Or import your projects and tasks from other services into Vikunja:", "importText": "Or import your lists and tasks from other services into Vikunja:",
"import": "Import your data into Vikunja" "import": "Import your data into Vikunja"
} }
}, },
@ -85,7 +85,7 @@
"weekStartSunday": "Sunday", "weekStartSunday": "Sunday",
"weekStartMonday": "Monday", "weekStartMonday": "Monday",
"language": "Language", "language": "Language",
"defaultProject": "Default Project", "defaultList": "Default List",
"timezone": "Time Zone", "timezone": "Time Zone",
"overdueTasksRemindersTime": "Overdue tasks reminder email time" "overdueTasksRemindersTime": "Overdue tasks reminder email time"
}, },
@ -143,7 +143,7 @@
}, },
"deletion": { "deletion": {
"title": "Delete your Vikunja Account", "title": "Delete your Vikunja Account",
"text1": "The deletion of your account is permanent and cannot be undone. We will delete all your namespaces, projects, tasks and everything associated with it.", "text1": "The deletion of your account is permanent and cannot be undone. We will delete all your namespaces, lists, tasks and everything associated with it.",
"text2": "To proceed, please enter your password. You will receive an email with further instructions.", "text2": "To proceed, please enter your password. You will receive an email with further instructions.",
"confirm": "Delete my account", "confirm": "Delete my account",
"requestSuccess": "The request was successful. You'll receive an email with further instructions.", "requestSuccess": "The request was successful. You'll receive an email with further instructions.",
@ -157,39 +157,40 @@
}, },
"export": { "export": {
"title": "Export your Vikunja data", "title": "Export your Vikunja data",
"description": "You can request a copy of all your Vikunja data. This include Namespaces, Projects, Tasks and everything associated to them. You can import this data in any Vikunja instance through the migration function.", "description": "You can request a copy of all your Vikunja data. This include Namespaces, Lists, Tasks and everything associated to them. You can import this data in any Vikunja instance through the migration function.",
"descriptionPasswordRequired": "Please enter your password to proceed:", "descriptionPasswordRequired": "Please enter your password to proceed:",
"request": "Request a copy of my Vikunja Data", "request": "Request a copy of my Vikunja Data",
"success": "You've successfully requested your Vikunja Data! We will send you an email once it's ready to download.", "success": "You've successfully requested your Vikunja Data! We will send you an email once it's ready to download.",
"downloadTitle": "Download your exported Vikunja data" "downloadTitle": "Download your exported Vikunja data"
} }
}, },
"project": { "list": {
"archived": "This project is archived. It is not possible to create new or edit tasks for it.", "archived": "This list is archived. It is not possible to create new or edit tasks for it.",
"title": "Project Title", "title": "List Title",
"color": "Color", "color": "Color",
"projects": "Projects", "lists": "Lists",
"search": "Type to search for a project…", "list": "List",
"searchSelect": "Click or press enter to select this project", "search": "Type to search for a list…",
"shared": "Shared Projects", "searchSelect": "Click or press enter to select this list",
"noDescriptionAvailable": "No project description is available.", "shared": "Shared Lists",
"noDescriptionAvailable": "No list description is available.",
"create": { "create": {
"header": "New project", "header": "New list",
"titlePlaceholder": "The project's title goes here…", "titlePlaceholder": "The list's title goes here…",
"addTitleRequired": "Please specify a title.", "addTitleRequired": "Please specify a title.",
"createdSuccess": "The project was successfully created.", "createdSuccess": "The list was successfully created.",
"addProjectRequired": "Please specify a project or set a default project in the settings." "addListRequired": "Please specify a list or set a default list in the settings."
}, },
"archive": { "archive": {
"title": "Archive \"{project}\"", "title": "Archive \"{list}\"",
"archive": "Archive this project", "archive": "Archive this list",
"unarchive": "Un-Archive this project", "unarchive": "Un-Archive this list",
"unarchiveText": "You will be able to create new tasks or edit it.", "unarchiveText": "You will be able to create new tasks or edit it.",
"archiveText": "You won't be able to edit this project or create new tasks until you un-archive it.", "archiveText": "You won't be able to edit this list or create new tasks until you un-archive it.",
"success": "The project was successfully archived." "success": "The list was successfully archived."
}, },
"background": { "background": {
"title": "Set project background", "title": "Set list background",
"remove": "Remove Background", "remove": "Remove Background",
"upload": "Choose a background from your pc", "upload": "Choose a background from your pc",
"searchPlaceholder": "Search for a background…", "searchPlaceholder": "Search for a background…",
@ -199,40 +200,40 @@
"removeSuccess": "The background has been removed successfully!" "removeSuccess": "The background has been removed successfully!"
}, },
"delete": { "delete": {
"title": "Delete \"{project}\"", "title": "Delete \"{list}\"",
"header": "Delete this project", "header": "Delete this list",
"text1": "Are you sure you want to delete this project and all of its contents?", "text1": "Are you sure you want to delete this list and all of its contents?",
"text2": "This includes all tasks and CANNOT BE UNDONE!", "text2": "This includes all tasks and CANNOT BE UNDONE!",
"success": "The project was successfully deleted.", "success": "The list was successfully deleted.",
"tasksToDelete": "This will irrevocably remove approx. {count} tasks.", "tasksToDelete": "This will irrevocably remove approx. {count} tasks.",
"noTasksToDelete": "This project does not contain any tasks, it should be safe to delete." "noTasksToDelete": "This list does not contain any tasks, it should be safe to delete."
}, },
"duplicate": { "duplicate": {
"title": "Duplicate this project", "title": "Duplicate this list",
"label": "Duplicate", "label": "Duplicate",
"text": "Select a namespace which should hold the duplicated project:", "text": "Select a namespace which should hold the duplicated list:",
"success": "The project was successfully duplicated." "success": "The list was successfully duplicated."
}, },
"edit": { "edit": {
"header": "Edit This Project", "header": "Edit This List",
"title": "Edit \"{project}\"", "title": "Edit \"{list}\"",
"titlePlaceholder": "The project title goes here…", "titlePlaceholder": "The list title goes here…",
"identifierTooltip": "The project identifier can be used to uniquely identify a task across projects. You can set it to empty to disable it.", "identifierTooltip": "The list identifier can be used to uniquely identify a task across lists. You can set it to empty to disable it.",
"identifier": "Project Identifier", "identifier": "List Identifier",
"identifierPlaceholder": "The project identifier goes here…", "identifierPlaceholder": "The list identifier goes here…",
"description": "Description", "description": "Description",
"descriptionPlaceholder": "The projects description goes here…", "descriptionPlaceholder": "The lists description goes here…",
"color": "Color", "color": "Color",
"success": "The project was successfully updated." "success": "The list was successfully updated."
}, },
"share": { "share": {
"header": "Share this project", "header": "Share this list",
"title": "Share \"{project}\"", "title": "Share \"{list}\"",
"share": "Share", "share": "Share",
"links": { "links": {
"title": "Share Links", "title": "Share Links",
"what": "What is a share link?", "what": "What is a share link?",
"explanation": "Share Links allow you to easily share a project with other users who don't have an account on Vikunja.", "explanation": "Share Links allow you to easily share a list with other users who don't have an account on Vikunja.",
"create": "Create a new link share", "create": "Create a new link share",
"name": "Name (optional)", "name": "Name (optional)",
"namePlaceholder": "e.g. Lorem Ipsum", "namePlaceholder": "e.g. Lorem Ipsum",
@ -241,7 +242,7 @@
"passwordExplanation": "When authenticating, the user will be required to enter this password.", "passwordExplanation": "When authenticating, the user will be required to enter this password.",
"noName": "No name set", "noName": "No name set",
"remove": "Remove a link share", "remove": "Remove a link share",
"removeText": "Are you sure you want to remove this link share? It will no longer be possible to access this project with this link share. This cannot be undone!", "removeText": "Are you sure you want to remove this link share? It will no longer be possible to access this list with this link share. This cannot be undone!",
"createSuccess": "The link share was successfully created.", "createSuccess": "The link share was successfully created.",
"deleteSuccess": "The link share was successfully deleted", "deleteSuccess": "The link share was successfully deleted",
"view": "View", "view": "View",
@ -274,7 +275,7 @@
"title": "List", "title": "List",
"add": "Add", "add": "Add",
"addPlaceholder": "Add a new task…", "addPlaceholder": "Add a new task…",
"empty": "This project is currently empty.", "empty": "This list is currently empty.",
"newTaskCta": "Create a new task.", "newTaskCta": "Create a new task.",
"editTask": "Edit Task" "editTask": "Edit Task"
}, },
@ -322,36 +323,36 @@
} }
}, },
"namespace": { "namespace": {
"title": "Namespaces & Projects", "title": "Namespaces & Lists",
"namespace": "Namespace", "namespace": "Namespace",
"showArchived": "Show Archived", "showArchived": "Show Archived",
"noneAvailable": "You don't have any namespaces right now.", "noneAvailable": "You don't have any namespaces right now.",
"unarchive": "Un-Archive", "unarchive": "Un-Archive",
"archived": "Archived", "archived": "Archived",
"noProjects": "This namespace does not contain any projects.", "noLists": "This namespace does not contain any lists.",
"createProject": "Create a new project in this namespace.", "createList": "Create a new list in this namespace.",
"namespaces": "Namespaces", "namespaces": "Namespaces",
"search": "Type to search for a namespace…", "search": "Type to search for a namespace…",
"create": { "create": {
"title": "New namespace", "title": "New namespace",
"titleRequired": "Please specify a title.", "titleRequired": "Please specify a title.",
"explanation": "A namespace is a collection of projects you can share and use to organize your projects with. In fact, every project belongs to a namespace.", "explanation": "A namespace is a collection of lists you can share and use to organize your lists with. In fact, every list belongs to a namespace.",
"tooltip": "What's a namespace?", "tooltip": "What's a namespace?",
"success": "The namespace was successfully created." "success": "The namespace was successfully created."
}, },
"archive": { "archive": {
"titleArchive": "Archive \"{namespace}\"", "titleArchive": "Archive \"{namespace}\"",
"titleUnarchive": "Un-Archive \"{namespace}\"", "titleUnarchive": "Un-Archive \"{namespace}\"",
"archiveText": "You won't be able to edit this namespace or create new projects until you un-archive it. This will also archive all projects in this namespace.", "archiveText": "You won't be able to edit this namespace or create new lists until you un-archive it. This will also archive all lists in this namespace.",
"unarchiveText": "You will be able to create new projects or edit it.", "unarchiveText": "You will be able to create new lists or edit it.",
"success": "The namespace was successfully archived.", "success": "The namespace was successfully archived.",
"unarchiveSuccess": "The namespace was successfully un-archived.", "unarchiveSuccess": "The namespace was successfully un-archived.",
"description": "If a namespace is archived, you cannot create new projects or edit it." "description": "If a namespace is archived, you cannot create new lists or edit it."
}, },
"delete": { "delete": {
"title": "Delete \"{namespace}\"", "title": "Delete \"{namespace}\"",
"text1": "Are you sure you want to delete this namespace and all of its contents?", "text1": "Are you sure you want to delete this namespace and all of its contents?",
"text2": "This includes all projects and tasks and CANNOT BE UNDONE!", "text2": "This includes all lists and tasks and CANNOT BE UNDONE!",
"success": "The namespace was successfully deleted." "success": "The namespace was successfully deleted."
}, },
"edit": { "edit": {
@ -371,8 +372,8 @@
"isArchived": "This namespace is archived" "isArchived": "This namespace is archived"
}, },
"pseudo": { "pseudo": {
"sharedProjects": { "sharedLists": {
"title": "Shared Projects" "title": "Shared Lists"
}, },
"favorites": { "favorites": {
"title": "Favorites" "title": "Favorites"
@ -403,9 +404,8 @@
}, },
"create": { "create": {
"title": "New Saved Filter", "title": "New Saved Filter",
"description": "A saved filter is a virtual project which is computed from a set of filters each time it is accessed. Once created, it will appear in a special namespace.", "description": "A saved filter is a virtual list which is computed from a set of filters each time it is accessed. Once created, it will appear in a special namespace.",
"action": "Create new saved filter", "action": "Create new saved filter"
"titleRequired": "Please provide a title for the filter."
}, },
"delete": { "delete": {
"header": "Delete this saved filter", "header": "Delete this saved filter",
@ -435,7 +435,7 @@
"label": { "label": {
"title": "Labels", "title": "Labels",
"manage": "Manage labels", "manage": "Manage labels",
"description": "Click on a label to edit it. You can edit all labels you created, you can use all labels which are associated with a task to whose project you have access.", "description": "Click on a label to edit it. You can edit all labels you created, you can use all labels which are associated with a task to whose list you have access.",
"newCTA": "You currently do not have any labels.", "newCTA": "You currently do not have any labels.",
"search": "Type to search for a label…", "search": "Type to search for a label…",
"create": { "create": {
@ -460,7 +460,7 @@
}, },
"sharing": { "sharing": {
"authenticating": "Authenticating…", "authenticating": "Authenticating…",
"passwordRequired": "This shared project requires a password. Please enter it below:", "passwordRequired": "This shared list requires a password. Please enter it below:",
"error": "An error occured.", "error": "An error occured.",
"invalidPassword": "The password is invalid." "invalidPassword": "The password is invalid."
}, },
@ -529,7 +529,7 @@
"code": "Code", "code": "Code",
"quote": "Quote", "quote": "Quote",
"unorderedList": "Unordered List", "unorderedList": "Unordered List",
"orderedList ": "Ordered List", "orderedList": "Ordered List",
"cleanBlock": "Clean Block", "cleanBlock": "Clean Block",
"link": "Link", "link": "Link",
"image": "Image", "image": "Image",
@ -605,7 +605,6 @@
"addReminder": "Add a new reminder…", "addReminder": "Add a new reminder…",
"doneSuccess": "The task was successfully marked as done.", "doneSuccess": "The task was successfully marked as done.",
"undoneSuccess": "The task was successfully un-marked as done.", "undoneSuccess": "The task was successfully un-marked as done.",
"undo": "Undo",
"openDetail": "Open task detail view", "openDetail": "Open task detail view",
"checklistTotal": "{checked} of {total} tasks", "checklistTotal": "{checked} of {total} tasks",
"checklistAllDone": "{total} tasks", "checklistAllDone": "{total} tasks",
@ -622,7 +621,7 @@
"chooseDueDate": "Click here to set a due date", "chooseDueDate": "Click here to set a due date",
"chooseStartDate": "Click here to set a start date", "chooseStartDate": "Click here to set a start date",
"chooseEndDate": "Click here to set an end date", "chooseEndDate": "Click here to set an end date",
"move": "Move task to a different project", "move": "Move task to a different list",
"done": "Mark task done!", "done": "Mark task done!",
"undone": "Mark as undone", "undone": "Mark as undone",
"created": "Created {0} by {1}", "created": "Created {0} by {1}",
@ -630,7 +629,7 @@
"doneAt": "Done {0}", "doneAt": "Done {0}",
"updateSuccess": "The task was saved successfully.", "updateSuccess": "The task was saved successfully.",
"deleteSuccess": "The task has been deleted successfully.", "deleteSuccess": "The task has been deleted successfully.",
"belongsToProject": "This task belongs to project '{project}'", "belongsToList": "This task belongs to list '{list}'",
"due": "Due {at}", "due": "Due {at}",
"closePopup": "Close popup", "closePopup": "Close popup",
"delete": { "delete": {
@ -650,7 +649,7 @@
"percentDone": "Set Progress", "percentDone": "Set Progress",
"attachments": "Add Attachments", "attachments": "Add Attachments",
"relatedTasks": "Add Relation", "relatedTasks": "Add Relation",
"moveProject": "Move", "moveList": "Move",
"color": "Set Color", "color": "Set Color",
"delete": "Delete", "delete": "Delete",
"favorite": "Add to Favorites", "favorite": "Add to Favorites",
@ -677,21 +676,21 @@
"updated": "Updated" "updated": "Updated"
}, },
"subscription": { "subscription": {
"subscribedProjectThroughParentNamespace": "You can't unsubscribe here because you are subscribed to this project through its namespace.", "subscribedListThroughParentNamespace": "You can't unsubscribe here because you are subscribed to this list through its namespace.",
"subscribedTaskThroughParentNamespace": "You can't unsubscribe here because you are subscribed to this task through its namespace.", "subscribedTaskThroughParentNamespace": "You can't unsubscribe here because you are subscribed to this task through its namespace.",
"subscribedTaskThroughParentProject": "You can't unsubscribe here because you are subscribed to this task through its project.", "subscribedTaskThroughParentList": "You can't unsubscribe here because you are subscribed to this task through its list.",
"subscribedNamespace": "You are currently subscribed to this namespace and will receive notifications for changes.", "subscribedNamespace": "You are currently subscribed to this namespace and will receive notifications for changes.",
"notSubscribedNamespace": "You are not subscribed to this namespace and won't receive notifications for changes.", "notSubscribedNamespace": "You are not subscribed to this namespace and won't receive notifications for changes.",
"subscribedProject": "You are currently subscribed to this project and will receive notifications for changes.", "subscribedList": "You are currently subscribed to this list and will receive notifications for changes.",
"notSubscribedProject": "You are not subscribed to this project and won't receive notifications for changes.", "notSubscribedList": "You are not subscribed to this list and won't receive notifications for changes.",
"subscribedTask": "You are currently subscribed to this task and will receive notifications for changes.", "subscribedTask": "You are currently subscribed to this task and will receive notifications for changes.",
"notSubscribedTask": "You are not subscribed to this task and won't receive notifications for changes.", "notSubscribedTask": "You are not subscribed to this task and won't receive notifications for changes.",
"subscribe": "Subscribe", "subscribe": "Subscribe",
"unsubscribe": "Unsubscribe", "unsubscribe": "Unsubscribe",
"subscribeSuccessNamespace": "You are now subscribed to this namespace", "subscribeSuccessNamespace": "You are now subscribed to this namespace",
"unsubscribeSuccessNamespace": "You are now unsubscribed to this namespace", "unsubscribeSuccessNamespace": "You are now unsubscribed to this namespace",
"subscribeSuccessProject": "You are now subscribed to this project", "subscribeSuccessList": "You are now subscribed to this list",
"unsubscribeSuccessProject": "You are now unsubscribed to this project", "unsubscribeSuccessList": "You are now unsubscribed to this list",
"subscribeSuccessTask": "You are now subscribed to this task", "subscribeSuccessTask": "You are now subscribed to this task",
"unsubscribeSuccessTask": "You are now unsubscribed to this task" "unsubscribeSuccessTask": "You are now unsubscribed to this task"
}, },
@ -765,7 +764,7 @@
"new": "New Task Relation", "new": "New Task Relation",
"searchPlaceholder": "Type search for a new task to add as related…", "searchPlaceholder": "Type search for a new task to add as related…",
"createPlaceholder": "Add this as new related task", "createPlaceholder": "Add this as new related task",
"differentProject": "This task belongs to a different project.", "differentList": "This task belongs to a different list.",
"differentNamespace": "This task belongs to a different namespace.", "differentNamespace": "This task belongs to a different namespace.",
"noneYet": "No task relations yet.", "noneYet": "No task relations yet.",
"delete": "Delete Task Relation", "delete": "Delete Task Relation",
@ -815,10 +814,10 @@
"priority1": "To set a task's priority, add a number 1-5, prefixed with a {prefix}.", "priority1": "To set a task's priority, add a number 1-5, prefixed with a {prefix}.",
"priority2": "The higher the number, the higher the priority.", "priority2": "The higher the number, the higher the priority.",
"assignees": "To directly assign the task to a user, add their username prefixed with {prefix} to the task.", "assignees": "To directly assign the task to a user, add their username prefixed with {prefix} to the task.",
"project1": "To set a project for the task to appear in, enter its name prefixed with {prefix}.", "list1": "To set a list for the task to appear in, enter its name prefixed with {prefix}.",
"project2": "This will return an error if the project does not exist.", "list2": "This will return an error if the list does not exist.",
"project3": "To use spaces, simply add a \" or ' around the project name.", "list3": "To use spaces, simply add a \" or ' around the list name.",
"project4": "For example: {prefix}\"Project with spaces\".", "list4": "For example: {prefix}\"List with spaces\".",
"dateAndTime": "Date and time", "dateAndTime": "Date and time",
"date": "Any date will be used as the due date of the new task. You can use dates in any of these formats:", "date": "Any date will be used as the due date of the new task. You can use dates in any of these formats:",
"dateWeekday": "any weekday, will use the next date with that date", "dateWeekday": "any weekday, will use the next date with that date",
@ -851,19 +850,19 @@
"delete": { "delete": {
"header": "Delete the team", "header": "Delete the team",
"text1": "Are you sure you want to delete this team and all of its members?", "text1": "Are you sure you want to delete this team and all of its members?",
"text2": "All team members will lose access to projects and namespaces shared with this team. This CANNOT BE UNDONE!", "text2": "All team members will lose access to lists and namespaces shared with this team. This CANNOT BE UNDONE!",
"success": "The team was successfully deleted." "success": "The team was successfully deleted."
}, },
"deleteUser": { "deleteUser": {
"header": "Remove a user from the team", "header": "Remove a user from the team",
"text1": "Are you sure you want to remove this user from the team?", "text1": "Are you sure you want to remove this user from the team?",
"text2": "They will lose access to all projects and namespaces this team has access to. This CANNOT BE UNDONE!", "text2": "They will lose access to all lists and namespaces this team has access to. This CANNOT BE UNDONE!",
"success": "The user was successfully deleted from the team." "success": "The user was successfully deleted from the team."
}, },
"leave": { "leave": {
"title": "Leave team", "title": "Leave team",
"text1": "Are you sure you want to leave this team?", "text1": "Are you sure you want to leave this team?",
"text2": "You will lose access to all projects and namespaces this team has access to. If you change your mind you'll need a team admin to add you again.", "text2": "You will lose access to all lists and namespaces this team has access to. If you change your mind you'll need a team admin to add you again.",
"success": "You have successfully left the team." "success": "You have successfully left the team."
} }
}, },
@ -895,12 +894,12 @@
"attachment": "Add an attachment to this task", "attachment": "Add an attachment to this task",
"related": "Modify related tasks of this task", "related": "Modify related tasks of this task",
"color": "Change the color of this task", "color": "Change the color of this task",
"move": "Move this task to another project", "move": "Move this task to another list",
"reminder": "Manage reminders of this task", "reminder": "Manage reminders of this task",
"description": "Toggle editing of the task description" "description": "Toggle editing of the task description"
}, },
"project": { "list": {
"title": "Project Views", "title": "List Views",
"switchToListView": "Switch to list view", "switchToListView": "Switch to list view",
"switchToGanttView": "Switch to gantt view", "switchToGanttView": "Switch to gantt view",
"switchToKanbanView": "Switch to kanban view", "switchToKanbanView": "Switch to kanban view",
@ -910,7 +909,7 @@
"title": "Navigation", "title": "Navigation",
"overview": "Navigate to overview", "overview": "Navigate to overview",
"upcoming": "Navigate to upcoming tasks", "upcoming": "Navigate to upcoming tasks",
"namespaces": "Navigate to namespaces & projects", "namespaces": "Navigate to namespaces & lists",
"labels": "Navigate to labels", "labels": "Navigate to labels",
"teams": "Navigate to teams" "teams": "Navigate to teams"
} }
@ -927,7 +926,7 @@
"unarchive": "Un-Archive", "unarchive": "Un-Archive",
"setBackground": "Set background", "setBackground": "Set background",
"share": "Share", "share": "Share",
"newProject": "New project" "newList": "New list"
}, },
"apiConfig": { "apiConfig": {
"url": "Vikunja URL", "url": "Vikunja URL",
@ -946,24 +945,24 @@
"notification": { "notification": {
"title": "Notifications", "title": "Notifications",
"none": "You don't have any notifications. Have a nice day!", "none": "You don't have any notifications. Have a nice day!",
"explainer": "Notifications will appear here when actions on namespaces, projects or tasks you subscribed to happen." "explainer": "Notifications will appear here when actions on namespaces, lists or tasks you subscribed to happen."
}, },
"quickActions": { "quickActions": {
"commands": "Commands", "commands": "Commands",
"placeholder": "Type a command or search…", "placeholder": "Type a command or search…",
"hint": "You can use {project} to limit the search to a project. Combine {project} or {label} (labels) with a search query to search for a task with these labels or on that project. Use {assignee} to only search for teams.", "hint": "You can use {list} to limit the search to a list. Combine {list} or {label} (labels) with a search query to search for a task with these labels or on that list. Use {assignee} to only search for teams.",
"tasks": "Tasks", "tasks": "Tasks",
"projects": "Projects", "lists": "Lists",
"teams": "Teams", "teams": "Teams",
"newProject": "Enter the title of the new project…", "newList": "Enter the title of the new list…",
"newTask": "Enter the title of the new task…", "newTask": "Enter the title of the new task…",
"newNamespace": "Enter the title of the new namespace…", "newNamespace": "Enter the title of the new namespace…",
"newTeam": "Enter the name of the new team…", "newTeam": "Enter the name of the new team…",
"createTask": "Create a task in the current project ({title})", "createTask": "Create a task in the current list ({title})",
"createProject": "Create a project in the current namespace ({title})", "createList": "Create a list in the current namespace ({title})",
"cmds": { "cmds": {
"newTask": "New task", "newTask": "New task",
"newProject": "New project", "newList": "New list",
"newNamespace": "New namespace", "newNamespace": "New namespace",
"newTeam": "New team" "newTeam": "New team"
} }
@ -995,15 +994,15 @@
"1018": "The user avatar type setting is invalid.", "1018": "The user avatar type setting is invalid.",
"2001": "ID cannot be empty or 0.", "2001": "ID cannot be empty or 0.",
"2002": "Some of the request data was invalid.", "2002": "Some of the request data was invalid.",
"3001": "The project does not exist.", "3001": "The list does not exist.",
"3004": "You need to have read permissions on that project to perform that action.", "3004": "You need to have read permissions on that list to perform that action.",
"3005": "The project title cannot be empty.", "3005": "The list title cannot be empty.",
"3006": "The project share does not exist.", "3006": "The list share does not exist.",
"3007": "A project with this identifier already exists.", "3007": "A list with this identifier already exists.",
"3008": "The project is archived and can therefore only be accessed read only. This is also true for all tasks associated with this project.", "3008": "The list is archived and can therefore only be accessed read only. This is also true for all tasks associated with this list.",
"4001": "The project task text cannot be empty.", "4001": "The list task text cannot be empty.",
"4002": "The project task does not exist.", "4002": "The list task does not exist.",
"4003": "All bulk editing tasks must belong to the same project.", "4003": "All bulk editing tasks must belong to the same list.",
"4004": "Need at least one task when bulk editing tasks.", "4004": "Need at least one task when bulk editing tasks.",
"4005": "You do not have the right to see the task.", "4005": "You do not have the right to see the task.",
"4006": "You can't set a parent task as the task itself.", "4006": "You can't set a parent task as the task itself.",
@ -1029,21 +1028,21 @@
"5012": "The namespace is archived and can therefore only be accessed read only.", "5012": "The namespace is archived and can therefore only be accessed read only.",
"6001": "The team name cannot be empty.", "6001": "The team name cannot be empty.",
"6002": "The team does not exist.", "6002": "The team does not exist.",
"6004": "The team already has access to that namespace or project.", "6004": "The team already has access to that namespace or list.",
"6005": "The user is already a member of that team.", "6005": "The user is already a member of that team.",
"6006": "Cannot delete the last team member.", "6006": "Cannot delete the last team member.",
"6007": "The team does not have access to the project to perform that action.", "6007": "The team does not have access to the list to perform that action.",
"7002": "The user already has access to that project.", "7002": "The user already has access to that list.",
"7003": "You do not have access to that project.", "7003": "You do not have access to that list.",
"8001": "This label already exists on that task.", "8001": "This label already exists on that task.",
"8002": "The label does not exist.", "8002": "The label does not exist.",
"8003": "You do not have access to this label.", "8003": "You do not have access to this label.",
"9001": "The right is invalid.", "9001": "The right is invalid.",
"10001": "The bucket does not exist.", "10001": "The bucket does not exist.",
"10002": "The bucket does not belong to that project.", "10002": "The bucket does not belong to that list.",
"10003": "You cannot remove the last bucket on a project.", "10003": "You cannot remove the last bucket on a list.",
"10004": "You cannot add the task to this bucket as it already exceeded the limit of tasks it can hold.", "10004": "You cannot add the task to this bucket as it already exceeded the limit of tasks it can hold.",
"10005": "There can be only one done bucket per project.", "10005": "There can be only one done bucket per list.",
"11001": "The saved filter does not exist.", "11001": "The saved filter does not exist.",
"11002": "Saved filters are not available for link shares.", "11002": "Saved filters are not available for link shares.",
"12001": "The subscription entity type is invalid.", "12001": "The subscription entity type is invalid.",

View File

@ -169,6 +169,14 @@
"title": "Título de Lista", "title": "Título de Lista",
"color": "Color", "color": "Color",
"lists": "Listas", "lists": "Listas",
"list": {
"title": "Lista",
"add": "Añadir",
"addPlaceholder": "Añadir una nueva tarea…",
"empty": "This list is currently empty.",
"newTaskCta": "Crear una nueva tarea.",
"editTask": "Editar Tarea"
},
"search": "Escribe para buscar una lista…", "search": "Escribe para buscar una lista…",
"searchSelect": "Haga clic o presione enter para seleccionar esta lista", "searchSelect": "Haga clic o presione enter para seleccionar esta lista",
"shared": "Listas Compartidas", "shared": "Listas Compartidas",
@ -270,14 +278,6 @@
"delete": "Eliminar" "delete": "Eliminar"
} }
}, },
"list": {
"title": "Lista",
"add": "Añadir",
"addPlaceholder": "Añadir una nueva tarea…",
"empty": "This list is currently empty.",
"newTaskCta": "Crear una nueva tarea.",
"editTask": "Editar Tarea"
},
"gantt": { "gantt": {
"title": "Gantt", "title": "Gantt",
"showTasksWithoutDates": "Mostrar tareas que no tienen fechas establecidas", "showTasksWithoutDates": "Mostrar tareas que no tienen fechas establecidas",
@ -404,8 +404,7 @@
"create": { "create": {
"title": "New Saved Filter", "title": "New Saved Filter",
"description": "A saved filter is a virtual list which is computed from a set of filters each time it is accessed. Once created, it will appear in a special namespace.", "description": "A saved filter is a virtual list which is computed from a set of filters each time it is accessed. Once created, it will appear in a special namespace.",
"action": "Create new saved filter", "action": "Create new saved filter"
"titleRequired": "Please provide a title for the filter."
}, },
"delete": { "delete": {
"header": "Delete this saved filter", "header": "Delete this saved filter",
@ -602,7 +601,6 @@
"addReminder": "Add a new reminder…", "addReminder": "Add a new reminder…",
"doneSuccess": "La tarea fue marcada con éxito como realizada.", "doneSuccess": "La tarea fue marcada con éxito como realizada.",
"undoneSuccess": "La tarea fue marcada correctamente como incompleta.", "undoneSuccess": "La tarea fue marcada correctamente como incompleta.",
"undo": "Undo",
"openDetail": "Open task detail view", "openDetail": "Open task detail view",
"checklistTotal": "{checked} of {total} tasks", "checklistTotal": "{checked} of {total} tasks",
"checklistAllDone": "{total} tasks", "checklistAllDone": "{total} tasks",

View File

@ -169,6 +169,14 @@
"title": "Nom de la liste", "title": "Nom de la liste",
"color": "Couleur", "color": "Couleur",
"lists": "Listes", "lists": "Listes",
"list": {
"title": "Liste",
"add": "Ajouter",
"addPlaceholder": "Ajouter une nouvelle tâche…",
"empty": "Cette liste est actuellement vide.",
"newTaskCta": "Créer une nouvelle tâche.",
"editTask": "Modifier la tâche"
},
"search": "Écris pour rechercher une liste…", "search": "Écris pour rechercher une liste…",
"searchSelect": "Clique ou appuie sur la touche Entrée pour sélectionner cette liste", "searchSelect": "Clique ou appuie sur la touche Entrée pour sélectionner cette liste",
"shared": "Listes partagées", "shared": "Listes partagées",
@ -270,14 +278,6 @@
"delete": "Supprimer" "delete": "Supprimer"
} }
}, },
"list": {
"title": "Liste",
"add": "Ajouter",
"addPlaceholder": "Ajouter une nouvelle tâche…",
"empty": "Cette liste est actuellement vide.",
"newTaskCta": "Créer une nouvelle tâche.",
"editTask": "Modifier la tâche"
},
"gantt": { "gantt": {
"title": "Gantt", "title": "Gantt",
"showTasksWithoutDates": "Afficher les tâches pour lesquelles aucune date na été fixée", "showTasksWithoutDates": "Afficher les tâches pour lesquelles aucune date na été fixée",
@ -404,8 +404,7 @@
"create": { "create": {
"title": "Nouveau filtre enregistré", "title": "Nouveau filtre enregistré",
"description": "Un filtre enregistré est une liste virtuelle qui est calculée à partir dun ensemble de filtres à chaque fois quon y accède. Une fois créé, il apparaît dans un espace de noms spécial.", "description": "Un filtre enregistré est une liste virtuelle qui est calculée à partir dun ensemble de filtres à chaque fois quon y accède. Une fois créé, il apparaît dans un espace de noms spécial.",
"action": "Créer un nouveau filtre enregistré", "action": "Créer un nouveau filtre enregistré"
"titleRequired": "Please provide a title for the filter."
}, },
"delete": { "delete": {
"header": "Supprimer ce filtre enregistré", "header": "Supprimer ce filtre enregistré",
@ -602,7 +601,6 @@
"addReminder": "Ajouter un nouveau rappel…", "addReminder": "Ajouter un nouveau rappel…",
"doneSuccess": "Tâche marquée comme terminée.", "doneSuccess": "Tâche marquée comme terminée.",
"undoneSuccess": "Tâche marquée comme non terminée.", "undoneSuccess": "Tâche marquée comme non terminée.",
"undo": "Undo",
"openDetail": "Ouvrir la vue détaillée de la tâche", "openDetail": "Ouvrir la vue détaillée de la tâche",
"checklistTotal": "{checked} sur {total} tâches", "checklistTotal": "{checked} sur {total} tâches",
"checklistAllDone": "{total} tâches", "checklistAllDone": "{total} tâches",

View File

@ -169,6 +169,14 @@
"title": "Titolo della Lista", "title": "Titolo della Lista",
"color": "Colore", "color": "Colore",
"lists": "Liste", "lists": "Liste",
"list": {
"title": "Lista",
"add": "Aggiungi",
"addPlaceholder": "Aggiungi una nuova attività…",
"empty": "Questa lista è attualmente vuota.",
"newTaskCta": "Crea una nuova attività.",
"editTask": "Modifica Attività"
},
"search": "Digita per cercare una lista…", "search": "Digita per cercare una lista…",
"searchSelect": "Fare clic o premere invio per selezionare questa lista", "searchSelect": "Fare clic o premere invio per selezionare questa lista",
"shared": "Liste Condivise", "shared": "Liste Condivise",
@ -270,14 +278,6 @@
"delete": "Elimina" "delete": "Elimina"
} }
}, },
"list": {
"title": "Lista",
"add": "Aggiungi",
"addPlaceholder": "Aggiungi una nuova attività…",
"empty": "Questa lista è attualmente vuota.",
"newTaskCta": "Crea una nuova attività.",
"editTask": "Modifica Attività"
},
"gantt": { "gantt": {
"title": "Gantt", "title": "Gantt",
"showTasksWithoutDates": "Mostra attività che non hanno date impostate", "showTasksWithoutDates": "Mostra attività che non hanno date impostate",
@ -404,8 +404,7 @@
"create": { "create": {
"title": "Nuovo Filtro Salvato", "title": "Nuovo Filtro Salvato",
"description": "Un filtro salvato è una lista virtuale che viene calcolata da un insieme di filtri di volta in volta. Una volta creato, apparirà in un namespace speciale.", "description": "Un filtro salvato è una lista virtuale che viene calcolata da un insieme di filtri di volta in volta. Una volta creato, apparirà in un namespace speciale.",
"action": "Crea nuovo filtro salvato", "action": "Crea nuovo filtro salvato"
"titleRequired": "È necessario un titolo per il filtro."
}, },
"delete": { "delete": {
"header": "Elimina questo filtro salvato", "header": "Elimina questo filtro salvato",
@ -602,7 +601,6 @@
"addReminder": "Aggiungi un nuovo promemoria…", "addReminder": "Aggiungi un nuovo promemoria…",
"doneSuccess": "Attività segnata come completata.", "doneSuccess": "Attività segnata come completata.",
"undoneSuccess": "Attività segnata come non completata.", "undoneSuccess": "Attività segnata come non completata.",
"undo": "Undo",
"openDetail": "Apri vista dettagli attività", "openDetail": "Apri vista dettagli attività",
"checklistTotal": "{checked} di {total} attività", "checklistTotal": "{checked} di {total} attività",
"checklistAllDone": "{total} attività", "checklistAllDone": "{total} attività",
@ -913,7 +911,7 @@
} }
}, },
"update": { "update": {
"available": "È disponibile un aggiornamento!", "available": "There is an update available!",
"do": "Aggiorna Adesso" "do": "Aggiorna Adesso"
}, },
"menu": { "menu": {

View File

@ -169,6 +169,14 @@
"title": "Lijst titel", "title": "Lijst titel",
"color": "Kleur", "color": "Kleur",
"lists": "Lijsten", "lists": "Lijsten",
"list": {
"title": "Lijst",
"add": "Toevoegen",
"addPlaceholder": "Voeg een nieuwe taak toe…",
"empty": "Deze lijst is momenteel leeg.",
"newTaskCta": "Creëer een nieuwe taak.",
"editTask": "Taak bewerken"
},
"search": "Typ om naar een lijst te zoeken…", "search": "Typ om naar een lijst te zoeken…",
"searchSelect": "Klik of druk op enter om deze lijst te selecteren", "searchSelect": "Klik of druk op enter om deze lijst te selecteren",
"shared": "Gedeelde lijsten", "shared": "Gedeelde lijsten",
@ -270,14 +278,6 @@
"delete": "Verwijderen" "delete": "Verwijderen"
} }
}, },
"list": {
"title": "Lijst",
"add": "Toevoegen",
"addPlaceholder": "Voeg een nieuwe taak toe…",
"empty": "Deze lijst is momenteel leeg.",
"newTaskCta": "Creëer een nieuwe taak.",
"editTask": "Taak bewerken"
},
"gantt": { "gantt": {
"title": "Gantt", "title": "Gantt",
"showTasksWithoutDates": "Toon taken waarvoor geen datums zijn ingesteld", "showTasksWithoutDates": "Toon taken waarvoor geen datums zijn ingesteld",
@ -404,8 +404,7 @@
"create": { "create": {
"title": "New Saved Filter", "title": "New Saved Filter",
"description": "A saved filter is a virtual list which is computed from a set of filters each time it is accessed. Once created, it will appear in a special namespace.", "description": "A saved filter is a virtual list which is computed from a set of filters each time it is accessed. Once created, it will appear in a special namespace.",
"action": "Create new saved filter", "action": "Create new saved filter"
"titleRequired": "Please provide a title for the filter."
}, },
"delete": { "delete": {
"header": "Delete this saved filter", "header": "Delete this saved filter",
@ -602,7 +601,6 @@
"addReminder": "Nieuwe herinnering toevoegen…", "addReminder": "Nieuwe herinnering toevoegen…",
"doneSuccess": "The task was successfully marked as done.", "doneSuccess": "The task was successfully marked as done.",
"undoneSuccess": "The task was successfully un-marked as done.", "undoneSuccess": "The task was successfully un-marked as done.",
"undo": "Undo",
"openDetail": "Open task detail view", "openDetail": "Open task detail view",
"checklistTotal": "{checked} van {total} taken", "checklistTotal": "{checked} van {total} taken",
"checklistAllDone": "{total} taken", "checklistAllDone": "{total} taken",

View File

@ -169,6 +169,14 @@
"title": "Listenavn", "title": "Listenavn",
"color": "Farge", "color": "Farge",
"lists": "Lister", "lists": "Lister",
"list": {
"title": "Liste",
"add": "Legg til",
"addPlaceholder": "Legg til ny oppgave…",
"empty": "Denne listen er for øyeblikket tom.",
"newTaskCta": "Lage en ny oppgave.",
"editTask": "Endre oppgave"
},
"search": "Skriv for å søke etter en liste…", "search": "Skriv for å søke etter en liste…",
"searchSelect": "Klikk eller trykk enter for å velge denne listen", "searchSelect": "Klikk eller trykk enter for å velge denne listen",
"shared": "Delte lister", "shared": "Delte lister",
@ -270,14 +278,6 @@
"delete": "Slett" "delete": "Slett"
} }
}, },
"list": {
"title": "Liste",
"add": "Legg til",
"addPlaceholder": "Legg til ny oppgave…",
"empty": "Denne listen er for øyeblikket tom.",
"newTaskCta": "Lage en ny oppgave.",
"editTask": "Endre oppgave"
},
"gantt": { "gantt": {
"title": "Gantt", "title": "Gantt",
"showTasksWithoutDates": "Vis oppgaver som ikke har datoer angitt", "showTasksWithoutDates": "Vis oppgaver som ikke har datoer angitt",
@ -404,8 +404,7 @@
"create": { "create": {
"title": "Nytt lagret filter", "title": "Nytt lagret filter",
"description": "Et lagret filter er en virtuell liste som beregnes fra et sett med filtre hver gang det åpnes. Når du er opprettet, vil det vises i et eget navneområde.", "description": "Et lagret filter er en virtuell liste som beregnes fra et sett med filtre hver gang det åpnes. Når du er opprettet, vil det vises i et eget navneområde.",
"action": "Opprett nytt filter", "action": "Opprett nytt filter"
"titleRequired": "Please provide a title for the filter."
}, },
"delete": { "delete": {
"header": "Slett dette lagrede filteret", "header": "Slett dette lagrede filteret",
@ -602,7 +601,6 @@
"addReminder": "Legg til en ny påminnelse…", "addReminder": "Legg til en ny påminnelse…",
"doneSuccess": "Oppgaven ble markert som ferdig.", "doneSuccess": "Oppgaven ble markert som ferdig.",
"undoneSuccess": "Oppgaven ble fjernet som ferdig.", "undoneSuccess": "Oppgaven ble fjernet som ferdig.",
"undo": "Undo",
"openDetail": "Åpne detaljvisning", "openDetail": "Åpne detaljvisning",
"checklistTotal": "{checked} av {total} oppgaver", "checklistTotal": "{checked} av {total} oppgaver",
"checklistAllDone": "{total} oppgaver", "checklistAllDone": "{total} oppgaver",

View File

@ -169,6 +169,14 @@
"title": "Tytuł listy", "title": "Tytuł listy",
"color": "Kolor", "color": "Kolor",
"lists": "Listy", "lists": "Listy",
"list": {
"title": "Lista",
"add": "Dodaj",
"addPlaceholder": "Dodaj nowe zadanie…",
"empty": "Ta lista jest obecnie pusta.",
"newTaskCta": "Utwórz nowe zadanie.",
"editTask": "Edytuj zadanie"
},
"search": "Wpisz, aby wyszukać listę…", "search": "Wpisz, aby wyszukać listę…",
"searchSelect": "Kliknij lub naciśnij Enter, aby wybrać tę listę", "searchSelect": "Kliknij lub naciśnij Enter, aby wybrać tę listę",
"shared": "Współdzielone listy", "shared": "Współdzielone listy",
@ -270,14 +278,6 @@
"delete": "Usuń" "delete": "Usuń"
} }
}, },
"list": {
"title": "Lista",
"add": "Dodaj",
"addPlaceholder": "Dodaj nowe zadanie…",
"empty": "Ta lista jest obecnie pusta.",
"newTaskCta": "Utwórz nowe zadanie.",
"editTask": "Edytuj zadanie"
},
"gantt": { "gantt": {
"title": "Gantt", "title": "Gantt",
"showTasksWithoutDates": "Pokaż zadania, które nie mają ustawionych dat", "showTasksWithoutDates": "Pokaż zadania, które nie mają ustawionych dat",
@ -404,8 +404,7 @@
"create": { "create": {
"title": "Nowy filtr stały", "title": "Nowy filtr stały",
"description": "Filtr stały to wirtualna lista, która jest kalkulowana na podstawie zestawu filtrów przy każdym wejściu w nią. Po utworzeniu pojawi się w specjalnej sekcji.", "description": "Filtr stały to wirtualna lista, która jest kalkulowana na podstawie zestawu filtrów przy każdym wejściu w nią. Po utworzeniu pojawi się w specjalnej sekcji.",
"action": "Utwórz nowy filtr stały", "action": "Utwórz nowy filtr stały"
"titleRequired": "Please provide a title for the filter."
}, },
"delete": { "delete": {
"header": "Usuń ten filtr stały", "header": "Usuń ten filtr stały",
@ -602,7 +601,6 @@
"addReminder": "Dodaj nowe przypomnienie…", "addReminder": "Dodaj nowe przypomnienie…",
"doneSuccess": "Zadanie zostało pomyślnie oznaczone jako ukończone.", "doneSuccess": "Zadanie zostało pomyślnie oznaczone jako ukończone.",
"undoneSuccess": "Zadanie zostało pomyślnie otwarte ponownie.", "undoneSuccess": "Zadanie zostało pomyślnie otwarte ponownie.",
"undo": "Undo",
"openDetail": "Otwórz szczegółowy widok zadania", "openDetail": "Otwórz szczegółowy widok zadania",
"checklistTotal": "{checked} z {total} zadań", "checklistTotal": "{checked} z {total} zadań",
"checklistAllDone": "{total} zadań", "checklistAllDone": "{total} zadań",

View File

@ -169,6 +169,14 @@
"title": "List Title", "title": "List Title",
"color": "Color", "color": "Color",
"lists": "Lists", "lists": "Lists",
"list": {
"title": "List",
"add": "Add",
"addPlaceholder": "Add a new task…",
"empty": "Esta lista está atualmente vazia.",
"newTaskCta": "Criar uma nova tarefa.",
"editTask": "Editar Tarefa"
},
"search": "Type to search for a list…", "search": "Type to search for a list…",
"searchSelect": "Click or press enter to select this list", "searchSelect": "Click or press enter to select this list",
"shared": "Shared Lists", "shared": "Shared Lists",
@ -270,14 +278,6 @@
"delete": "Delete" "delete": "Delete"
} }
}, },
"list": {
"title": "List",
"add": "Add",
"addPlaceholder": "Add a new task…",
"empty": "Esta lista está atualmente vazia.",
"newTaskCta": "Criar uma nova tarefa.",
"editTask": "Editar Tarefa"
},
"gantt": { "gantt": {
"title": "Gantt", "title": "Gantt",
"showTasksWithoutDates": "Mostrar tarefas que não possuem datas definidas", "showTasksWithoutDates": "Mostrar tarefas que não possuem datas definidas",
@ -404,8 +404,7 @@
"create": { "create": {
"title": "Novo filtro salvo", "title": "Novo filtro salvo",
"description": "A saved filter is a virtual list which is computed from a set of filters each time it is accessed. Once created, it will appear in a special namespace.", "description": "A saved filter is a virtual list which is computed from a set of filters each time it is accessed. Once created, it will appear in a special namespace.",
"action": "Create new saved filter", "action": "Create new saved filter"
"titleRequired": "Please provide a title for the filter."
}, },
"delete": { "delete": {
"header": "Delete this saved filter", "header": "Delete this saved filter",
@ -602,7 +601,6 @@
"addReminder": "Adicionar um novo lembrete…", "addReminder": "Adicionar um novo lembrete…",
"doneSuccess": "A tarefa foi marcada como feita com sucesso.", "doneSuccess": "A tarefa foi marcada como feita com sucesso.",
"undoneSuccess": "A tarefa foi desmarcada como feita com sucesso.", "undoneSuccess": "A tarefa foi desmarcada como feita com sucesso.",
"undo": "Undo",
"openDetail": "Abrir detalhes da tarefa", "openDetail": "Abrir detalhes da tarefa",
"checklistTotal": "{checked} de {total} tarefas", "checklistTotal": "{checked} de {total} tarefas",
"checklistAllDone": "{total} tarefas", "checklistAllDone": "{total} tarefas",

View File

@ -169,6 +169,14 @@
"title": "Título da Lista", "title": "Título da Lista",
"color": "Cor", "color": "Cor",
"lists": "Listas", "lists": "Listas",
"list": {
"title": "Lista",
"add": "Adicionar",
"addPlaceholder": "Adicionar uma nova tarefa…",
"empty": "Esta lista está atualmente vazia.",
"newTaskCta": "Cria uma nova tarefa.",
"editTask": "Editar Tarefa"
},
"search": "Escreve para pesquisar por uma lista…", "search": "Escreve para pesquisar por uma lista…",
"searchSelect": "Clica ou pressiona Enter para selecionar esta lista", "searchSelect": "Clica ou pressiona Enter para selecionar esta lista",
"shared": "Listas Partilhadas", "shared": "Listas Partilhadas",
@ -270,14 +278,6 @@
"delete": "Eliminar" "delete": "Eliminar"
} }
}, },
"list": {
"title": "Lista",
"add": "Adicionar",
"addPlaceholder": "Adicionar uma nova tarefa…",
"empty": "Esta lista está atualmente vazia.",
"newTaskCta": "Cria uma nova tarefa.",
"editTask": "Editar Tarefa"
},
"gantt": { "gantt": {
"title": "Gantt", "title": "Gantt",
"showTasksWithoutDates": "Mostrar tarefas que não têm datas atríbuidas", "showTasksWithoutDates": "Mostrar tarefas que não têm datas atríbuidas",
@ -404,8 +404,7 @@
"create": { "create": {
"title": "Novo Filtro Memorizado", "title": "Novo Filtro Memorizado",
"description": "Um filtro memorizado é uma lista virtual que é compilada a partir de um conjunto de filtros de cada vez que é acedido. Uma vez criado, irá aparecer num espaço especial.", "description": "Um filtro memorizado é uma lista virtual que é compilada a partir de um conjunto de filtros de cada vez que é acedido. Uma vez criado, irá aparecer num espaço especial.",
"action": "Criar novo filtro memorizado", "action": "Criar novo filtro memorizado"
"titleRequired": "Por favor, insere um título para o filtro."
}, },
"delete": { "delete": {
"header": "Eliminar este filtro memorizado", "header": "Eliminar este filtro memorizado",
@ -602,7 +601,6 @@
"addReminder": "Adicionar um novo lembrete…", "addReminder": "Adicionar um novo lembrete…",
"doneSuccess": "A tarefa foi marcada como concluída.", "doneSuccess": "A tarefa foi marcada como concluída.",
"undoneSuccess": "A tarefa foi desmarcada como concluída.", "undoneSuccess": "A tarefa foi desmarcada como concluída.",
"undo": "Desfazer",
"openDetail": "Abrir vista detalhada da tarefa", "openDetail": "Abrir vista detalhada da tarefa",
"checklistTotal": "{checked} de {total} tarefas", "checklistTotal": "{checked} de {total} tarefas",
"checklistAllDone": "{total} tarefas", "checklistAllDone": "{total} tarefas",

View File

@ -169,6 +169,14 @@
"title": "List Title", "title": "List Title",
"color": "Color", "color": "Color",
"lists": "Lists", "lists": "Lists",
"list": {
"title": "List",
"add": "Add",
"addPlaceholder": "Add a new task…",
"empty": "This list is currently empty.",
"newTaskCta": "Create a new task.",
"editTask": "Edit Task"
},
"search": "Type to search for a list…", "search": "Type to search for a list…",
"searchSelect": "Click or press enter to select this list", "searchSelect": "Click or press enter to select this list",
"shared": "Shared Lists", "shared": "Shared Lists",
@ -270,14 +278,6 @@
"delete": "Delete" "delete": "Delete"
} }
}, },
"list": {
"title": "List",
"add": "Add",
"addPlaceholder": "Add a new task…",
"empty": "This list is currently empty.",
"newTaskCta": "Create a new task.",
"editTask": "Edit Task"
},
"gantt": { "gantt": {
"title": "Gantt", "title": "Gantt",
"showTasksWithoutDates": "Show tasks which don't have dates set", "showTasksWithoutDates": "Show tasks which don't have dates set",
@ -404,8 +404,7 @@
"create": { "create": {
"title": "New Saved Filter", "title": "New Saved Filter",
"description": "A saved filter is a virtual list which is computed from a set of filters each time it is accessed. Once created, it will appear in a special namespace.", "description": "A saved filter is a virtual list which is computed from a set of filters each time it is accessed. Once created, it will appear in a special namespace.",
"action": "Create new saved filter", "action": "Create new saved filter"
"titleRequired": "Please provide a title for the filter."
}, },
"delete": { "delete": {
"header": "Delete this saved filter", "header": "Delete this saved filter",
@ -602,7 +601,6 @@
"addReminder": "Add a new reminder…", "addReminder": "Add a new reminder…",
"doneSuccess": "The task was successfully marked as done.", "doneSuccess": "The task was successfully marked as done.",
"undoneSuccess": "The task was successfully un-marked as done.", "undoneSuccess": "The task was successfully un-marked as done.",
"undo": "Undo",
"openDetail": "Open task detail view", "openDetail": "Open task detail view",
"checklistTotal": "{checked} of {total} tasks", "checklistTotal": "{checked} of {total} tasks",
"checklistAllDone": "{total} tasks", "checklistAllDone": "{total} tasks",

View File

@ -169,6 +169,14 @@
"title": "Название списка", "title": "Название списка",
"color": "Цвет", "color": "Цвет",
"lists": "Списки", "lists": "Списки",
"list": {
"title": "Список",
"add": "Добавить",
"addPlaceholder": "Добавить новую задачу…",
"empty": "Список сейчас пуст.",
"newTaskCta": "Создать новую задачу.",
"editTask": "Изменить задачу"
},
"search": "Введите запрос для поиска списка…", "search": "Введите запрос для поиска списка…",
"searchSelect": "Кликните или нажмите Enter для выбора этого списка", "searchSelect": "Кликните или нажмите Enter для выбора этого списка",
"shared": "Общие списки", "shared": "Общие списки",
@ -270,14 +278,6 @@
"delete": "Удалить" "delete": "Удалить"
} }
}, },
"list": {
"title": "Список",
"add": "Добавить",
"addPlaceholder": "Добавить новую задачу…",
"empty": "Список сейчас пуст.",
"newTaskCta": "Создать новую задачу.",
"editTask": "Изменить задачу"
},
"gantt": { "gantt": {
"title": "Гант", "title": "Гант",
"showTasksWithoutDates": "Показать задачи без установленной даты", "showTasksWithoutDates": "Показать задачи без установленной даты",
@ -404,8 +404,7 @@
"create": { "create": {
"title": "Создать сохранённый фильтр", "title": "Создать сохранённый фильтр",
"description": "Сохранённый фильтр — это виртуальный список, построенный из набора фильтров. При создании отображается в специальном пространстве имён.", "description": "Сохранённый фильтр — это виртуальный список, построенный из набора фильтров. При создании отображается в специальном пространстве имён.",
"action": "Создать новый сохранённый фильтр", "action": "Создать новый сохранённый фильтр"
"titleRequired": "Укажите название фильтра."
}, },
"delete": { "delete": {
"header": "Удалить этот сохранённый фильтр", "header": "Удалить этот сохранённый фильтр",
@ -602,7 +601,6 @@
"addReminder": "Добавить напоминание…", "addReminder": "Добавить напоминание…",
"doneSuccess": "Задача отмечена как завершённая.", "doneSuccess": "Задача отмечена как завершённая.",
"undoneSuccess": "Задача отмечена как незавершённая.", "undoneSuccess": "Задача отмечена как незавершённая.",
"undo": "Undo",
"openDetail": "Открыть подробный просмотр задачи", "openDetail": "Открыть подробный просмотр задачи",
"checklistTotal": "{checked} of {total} tasks", "checklistTotal": "{checked} of {total} tasks",
"checklistAllDone": "{total} tasks", "checklistAllDone": "{total} tasks",

View File

@ -169,6 +169,14 @@
"title": "List Title", "title": "List Title",
"color": "Color", "color": "Color",
"lists": "Lists", "lists": "Lists",
"list": {
"title": "List",
"add": "Add",
"addPlaceholder": "Add a new task…",
"empty": "This list is currently empty.",
"newTaskCta": "Create a new task.",
"editTask": "Edit Task"
},
"search": "Type to search for a list…", "search": "Type to search for a list…",
"searchSelect": "Click or press enter to select this list", "searchSelect": "Click or press enter to select this list",
"shared": "Shared Lists", "shared": "Shared Lists",
@ -270,14 +278,6 @@
"delete": "Delete" "delete": "Delete"
} }
}, },
"list": {
"title": "List",
"add": "Add",
"addPlaceholder": "Add a new task…",
"empty": "This list is currently empty.",
"newTaskCta": "Create a new task.",
"editTask": "Edit Task"
},
"gantt": { "gantt": {
"title": "Gantt", "title": "Gantt",
"showTasksWithoutDates": "Show tasks which don't have dates set", "showTasksWithoutDates": "Show tasks which don't have dates set",
@ -404,8 +404,7 @@
"create": { "create": {
"title": "New Saved Filter", "title": "New Saved Filter",
"description": "A saved filter is a virtual list which is computed from a set of filters each time it is accessed. Once created, it will appear in a special namespace.", "description": "A saved filter is a virtual list which is computed from a set of filters each time it is accessed. Once created, it will appear in a special namespace.",
"action": "Create new saved filter", "action": "Create new saved filter"
"titleRequired": "Please provide a title for the filter."
}, },
"delete": { "delete": {
"header": "Delete this saved filter", "header": "Delete this saved filter",
@ -602,7 +601,6 @@
"addReminder": "Add a new reminder…", "addReminder": "Add a new reminder…",
"doneSuccess": "The task was successfully marked as done.", "doneSuccess": "The task was successfully marked as done.",
"undoneSuccess": "The task was successfully un-marked as done.", "undoneSuccess": "The task was successfully un-marked as done.",
"undo": "Undo",
"openDetail": "Open task detail view", "openDetail": "Open task detail view",
"checklistTotal": "{checked} of {total} tasks", "checklistTotal": "{checked} of {total} tasks",
"checklistAllDone": "{total} tasks", "checklistAllDone": "{total} tasks",

View File

@ -169,6 +169,14 @@
"title": "List Title", "title": "List Title",
"color": "Color", "color": "Color",
"lists": "Lists", "lists": "Lists",
"list": {
"title": "List",
"add": "Add",
"addPlaceholder": "Add a new task…",
"empty": "This list is currently empty.",
"newTaskCta": "Create a new task.",
"editTask": "Edit Task"
},
"search": "Type to search for a list…", "search": "Type to search for a list…",
"searchSelect": "Click or press enter to select this list", "searchSelect": "Click or press enter to select this list",
"shared": "Shared Lists", "shared": "Shared Lists",
@ -270,14 +278,6 @@
"delete": "Delete" "delete": "Delete"
} }
}, },
"list": {
"title": "List",
"add": "Add",
"addPlaceholder": "Add a new task…",
"empty": "This list is currently empty.",
"newTaskCta": "Create a new task.",
"editTask": "Edit Task"
},
"gantt": { "gantt": {
"title": "Gantt", "title": "Gantt",
"showTasksWithoutDates": "Show tasks which don't have dates set", "showTasksWithoutDates": "Show tasks which don't have dates set",
@ -404,8 +404,7 @@
"create": { "create": {
"title": "New Saved Filter", "title": "New Saved Filter",
"description": "A saved filter is a virtual list which is computed from a set of filters each time it is accessed. Once created, it will appear in a special namespace.", "description": "A saved filter is a virtual list which is computed from a set of filters each time it is accessed. Once created, it will appear in a special namespace.",
"action": "Create new saved filter", "action": "Create new saved filter"
"titleRequired": "Please provide a title for the filter."
}, },
"delete": { "delete": {
"header": "Delete this saved filter", "header": "Delete this saved filter",
@ -602,7 +601,6 @@
"addReminder": "Add a new reminder…", "addReminder": "Add a new reminder…",
"doneSuccess": "The task was successfully marked as done.", "doneSuccess": "The task was successfully marked as done.",
"undoneSuccess": "The task was successfully un-marked as done.", "undoneSuccess": "The task was successfully un-marked as done.",
"undo": "Undo",
"openDetail": "Open task detail view", "openDetail": "Open task detail view",
"checklistTotal": "{checked} of {total} tasks", "checklistTotal": "{checked} of {total} tasks",
"checklistAllDone": "{total} tasks", "checklistAllDone": "{total} tasks",

View File

@ -169,6 +169,14 @@
"title": "List Title", "title": "List Title",
"color": "Color", "color": "Color",
"lists": "Lists", "lists": "Lists",
"list": {
"title": "List",
"add": "Add",
"addPlaceholder": "Add a new task…",
"empty": "This list is currently empty.",
"newTaskCta": "Create a new task.",
"editTask": "Edit Task"
},
"search": "Type to search for a list…", "search": "Type to search for a list…",
"searchSelect": "Click or press enter to select this list", "searchSelect": "Click or press enter to select this list",
"shared": "Shared Lists", "shared": "Shared Lists",
@ -270,14 +278,6 @@
"delete": "Delete" "delete": "Delete"
} }
}, },
"list": {
"title": "List",
"add": "Add",
"addPlaceholder": "Add a new task…",
"empty": "This list is currently empty.",
"newTaskCta": "Create a new task.",
"editTask": "Edit Task"
},
"gantt": { "gantt": {
"title": "Gantt", "title": "Gantt",
"showTasksWithoutDates": "Show tasks which don't have dates set", "showTasksWithoutDates": "Show tasks which don't have dates set",
@ -404,8 +404,7 @@
"create": { "create": {
"title": "New Saved Filter", "title": "New Saved Filter",
"description": "A saved filter is a virtual list which is computed from a set of filters each time it is accessed. Once created, it will appear in a special namespace.", "description": "A saved filter is a virtual list which is computed from a set of filters each time it is accessed. Once created, it will appear in a special namespace.",
"action": "Create new saved filter", "action": "Create new saved filter"
"titleRequired": "Please provide a title for the filter."
}, },
"delete": { "delete": {
"header": "Delete this saved filter", "header": "Delete this saved filter",
@ -602,7 +601,6 @@
"addReminder": "Add a new reminder…", "addReminder": "Add a new reminder…",
"doneSuccess": "The task was successfully marked as done.", "doneSuccess": "The task was successfully marked as done.",
"undoneSuccess": "The task was successfully un-marked as done.", "undoneSuccess": "The task was successfully un-marked as done.",
"undo": "Undo",
"openDetail": "Open task detail view", "openDetail": "Open task detail view",
"checklistTotal": "{checked} of {total} tasks", "checklistTotal": "{checked} of {total} tasks",
"checklistAllDone": "{total} tasks", "checklistAllDone": "{total} tasks",

View File

@ -169,6 +169,14 @@
"title": "Tên Danh sách", "title": "Tên Danh sách",
"color": "Màu sắc", "color": "Màu sắc",
"lists": "Danh sách", "lists": "Danh sách",
"list": {
"title": "Danh sách",
"add": "Thêm",
"addPlaceholder": "Thêm việc cần làm…",
"empty": "Danh sách này đang trống trơn.",
"newTaskCta": "Thêm một công việc mới.",
"editTask": "Chỉnh sửa Công việc"
},
"search": "Gõ để tìm kiếm danh sách…", "search": "Gõ để tìm kiếm danh sách…",
"searchSelect": "Nhấp hoặc nhấn enter để chọn danh sách này", "searchSelect": "Nhấp hoặc nhấn enter để chọn danh sách này",
"shared": "Đang tham gia", "shared": "Đang tham gia",
@ -270,14 +278,6 @@
"delete": "Xóa" "delete": "Xóa"
} }
}, },
"list": {
"title": "Danh sách",
"add": "Thêm",
"addPlaceholder": "Thêm việc cần làm…",
"empty": "Danh sách này đang trống trơn.",
"newTaskCta": "Thêm một công việc mới.",
"editTask": "Chỉnh sửa Công việc"
},
"gantt": { "gantt": {
"title": "Biểu đồ Gantt", "title": "Biểu đồ Gantt",
"showTasksWithoutDates": "Hiển thị các nhiệm vụ không cài đặt ngày", "showTasksWithoutDates": "Hiển thị các nhiệm vụ không cài đặt ngày",
@ -404,8 +404,7 @@
"create": { "create": {
"title": "Bộ lọc đã lưu mới", "title": "Bộ lọc đã lưu mới",
"description": "Bộ lọc sẵn là một danh sách ảo được chọn từ một tập hợp các bộ lọc. Sau khi được tạo, nó sẽ xuất hiện trong một không gian làm việc đặc biệt.", "description": "Bộ lọc sẵn là một danh sách ảo được chọn từ một tập hợp các bộ lọc. Sau khi được tạo, nó sẽ xuất hiện trong một không gian làm việc đặc biệt.",
"action": "Tạo thêm bộ lọc sẵn", "action": "Tạo thêm bộ lọc sẵn"
"titleRequired": "Please provide a title for the filter."
}, },
"delete": { "delete": {
"header": "Xóa bộ lọc sẵn này", "header": "Xóa bộ lọc sẵn này",
@ -602,7 +601,6 @@
"addReminder": "Thêm lời nhắc mới…", "addReminder": "Thêm lời nhắc mới…",
"doneSuccess": "Công việc đã được đánh dấu Hoàn thành.", "doneSuccess": "Công việc đã được đánh dấu Hoàn thành.",
"undoneSuccess": "Công việc đã được bỏ đánh dấu Hoàn thành.", "undoneSuccess": "Công việc đã được bỏ đánh dấu Hoàn thành.",
"undo": "Undo",
"openDetail": "Xem chi tiết công việc", "openDetail": "Xem chi tiết công việc",
"checklistTotal": "{checked} trong số {total} công việc", "checklistTotal": "{checked} trong số {total} công việc",
"checklistAllDone": "{total} coogn việc", "checklistAllDone": "{total} coogn việc",

View File

@ -169,6 +169,14 @@
"title": "列表名", "title": "列表名",
"color": "列表颜色", "color": "列表颜色",
"lists": "列表", "lists": "列表",
"list": {
"title": "列表",
"add": "新建",
"addPlaceholder": "添加新任务",
"empty": "此列表目前为空。",
"newTaskCta": "新建任务。",
"editTask": "编辑任务"
},
"search": "输入以搜索列表…", "search": "输入以搜索列表…",
"searchSelect": "点击或按下回车键以选择", "searchSelect": "点击或按下回车键以选择",
"shared": "共享列表", "shared": "共享列表",
@ -270,14 +278,6 @@
"delete": "删除" "delete": "删除"
} }
}, },
"list": {
"title": "列表",
"add": "新建",
"addPlaceholder": "添加新任务",
"empty": "此列表目前为空。",
"newTaskCta": "新建任务。",
"editTask": "编辑任务"
},
"gantt": { "gantt": {
"title": "甘特图", "title": "甘特图",
"showTasksWithoutDates": "显示未设定日期的任务", "showTasksWithoutDates": "显示未设定日期的任务",
@ -404,8 +404,7 @@
"create": { "create": {
"title": "新保存的过滤器", "title": "新保存的过滤器",
"description": "保存的过滤器是一个虚拟列表,在每次访问时从一组过滤器中计算出来。 创建后,它将出现在一个特殊的命名空间里。", "description": "保存的过滤器是一个虚拟列表,在每次访问时从一组过滤器中计算出来。 创建后,它将出现在一个特殊的命名空间里。",
"action": "创建新保存的过滤器", "action": "创建新保存的过滤器"
"titleRequired": "Please provide a title for the filter."
}, },
"delete": { "delete": {
"header": "删除此保存的过滤器", "header": "删除此保存的过滤器",
@ -602,7 +601,6 @@
"addReminder": "添加一个新的提醒…", "addReminder": "添加一个新的提醒…",
"doneSuccess": "待办事项已标记为完成。", "doneSuccess": "待办事项已标记为完成。",
"undoneSuccess": "待办事项已标记为未完成。", "undoneSuccess": "待办事项已标记为未完成。",
"undo": "Undo",
"openDetail": "查看任务详细信息", "openDetail": "查看任务详细信息",
"checklistTotal": "{checked} 项任务,共 {total} 项。", "checklistTotal": "{checked} 项任务,共 {total} 项。",
"checklistAllDone": "一共 {total} 项任务", "checklistAllDone": "一共 {total} 项任务",

View File

@ -169,6 +169,14 @@
"title": "List Title", "title": "List Title",
"color": "Color", "color": "Color",
"lists": "Lists", "lists": "Lists",
"list": {
"title": "List",
"add": "Add",
"addPlaceholder": "Add a new task…",
"empty": "This list is currently empty.",
"newTaskCta": "Create a new task.",
"editTask": "Edit Task"
},
"search": "Type to search for a list…", "search": "Type to search for a list…",
"searchSelect": "Click or press enter to select this list", "searchSelect": "Click or press enter to select this list",
"shared": "Shared Lists", "shared": "Shared Lists",
@ -270,14 +278,6 @@
"delete": "Delete" "delete": "Delete"
} }
}, },
"list": {
"title": "List",
"add": "Add",
"addPlaceholder": "Add a new task…",
"empty": "This list is currently empty.",
"newTaskCta": "Create a new task.",
"editTask": "Edit Task"
},
"gantt": { "gantt": {
"title": "Gantt", "title": "Gantt",
"showTasksWithoutDates": "Show tasks which don't have dates set", "showTasksWithoutDates": "Show tasks which don't have dates set",
@ -404,8 +404,7 @@
"create": { "create": {
"title": "New Saved Filter", "title": "New Saved Filter",
"description": "A saved filter is a virtual list which is computed from a set of filters each time it is accessed. Once created, it will appear in a special namespace.", "description": "A saved filter is a virtual list which is computed from a set of filters each time it is accessed. Once created, it will appear in a special namespace.",
"action": "Create new saved filter", "action": "Create new saved filter"
"titleRequired": "Please provide a title for the filter."
}, },
"delete": { "delete": {
"header": "Delete this saved filter", "header": "Delete this saved filter",
@ -602,7 +601,6 @@
"addReminder": "Add a new reminder…", "addReminder": "Add a new reminder…",
"doneSuccess": "The task was successfully marked as done.", "doneSuccess": "The task was successfully marked as done.",
"undoneSuccess": "The task was successfully un-marked as done.", "undoneSuccess": "The task was successfully un-marked as done.",
"undo": "Undo",
"openDetail": "Open task detail view", "openDetail": "Open task detail view",
"checklistTotal": "{checked} of {total} tasks", "checklistTotal": "{checked} of {total} tasks",
"checklistAllDone": "{total} tasks", "checklistAllDone": "{total} tasks",

View File

@ -39,76 +39,74 @@ if (window.API_URL.slice(window.API_URL.length - 1, window.API_URL.length) === '
window.API_URL = window.API_URL.slice(0, window.API_URL.length - 1) window.API_URL = window.API_URL.slice(0, window.API_URL.length - 1)
} }
const app = createApp(App)
app.use(Notifications)
// directives // directives
import focus from '@/directives/focus' import focus from '@/directives/focus'
import {VTooltip} from 'floating-vue' import { VTooltip } from 'floating-vue'
import 'floating-vue/dist/style.css' import 'floating-vue/dist/style.css'
import shortcut from '@/directives/shortcut' import shortcut from '@/directives/shortcut'
import cypress from '@/directives/cypress' import cypress from '@/directives/cypress'
app.directive('focus', focus)
app.directive('tooltip', VTooltip)
app.directive('shortcut', shortcut)
app.directive('cy', cypress)
// global components // global components
import FontAwesomeIcon from '@/components/misc/Icon' import FontAwesomeIcon from '@/components/misc/Icon'
import Button from '@/components/input/button.vue' import Button from '@/components/input/button.vue'
import Modal from '@/components/misc/modal.vue' import Modal from '@/components/misc/modal.vue'
import Card from '@/components/misc/card.vue' import Card from '@/components/misc/card.vue'
// We're loading the language before creating the app so that it won't fail to load when the user's app.component('icon', FontAwesomeIcon)
// language file is not yet loaded. app.component('x-button', Button)
setLanguage().then(() => { app.component('modal', Modal)
const app = createApp(App) app.component('card', Card)
app.use(Notifications)
app.directive('focus', focus)
app.directive('tooltip', VTooltip)
app.directive('shortcut', shortcut)
app.directive('cy', cypress)
app.component('icon', FontAwesomeIcon)
app.component('x-button', Button)
app.component('modal', Modal)
app.component('card', Card)
app.config.errorHandler = (err, vm, info) => {
if (import.meta.env.DEV) {
console.error(err, vm, info)
}
error(err)
}
app.config.errorHandler = (err, vm, info) => {
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
app.config.warnHandler = (msg) => { console.error(err, vm, info)
error(msg) }
throw(msg) error(err)
} }
// https://stackoverflow.com/a/52076738/15522256 if (import.meta.env.DEV) {
window.addEventListener('error', (err) => { app.config.warnHandler = (msg) => {
error(err) error(msg)
throw err throw(msg)
})
window.addEventListener('unhandledrejection', (err) => {
// event.promise contains the promise object
// event.reason contains the reason for the rejection
error(err)
throw err
})
} }
app.config.globalProperties.$message = { // https://stackoverflow.com/a/52076738/15522256
error, window.addEventListener('error', (err) => {
success, error(err)
} throw err
})
if (window.SENTRY_ENABLED) {
import('./sentry').then(sentry => sentry.default(app, router))
}
app.use(pinia) window.addEventListener('unhandledrejection', (err) => {
app.use(router) // event.promise contains the promise object
app.use(i18n) // event.reason contains the reason for the rejection
error(err)
throw err
})
}
app.config.globalProperties.$message = {
error,
success,
}
if (window.SENTRY_ENABLED) {
import('./sentry').then(sentry => sentry.default(app, router))
}
app.use(pinia)
app.use(router)
app.use(i18n)
setLanguage().then(() => {
app.mount('#app') app.mount('#app')
}) })

View File

@ -17,12 +17,7 @@ export function getErrorText(r): string {
return data?.message || r.message return data?.message || r.message
} }
export interface Action { export function error(e, actions = []) {
title: string,
callback: () => void,
}
export function error(e, actions: Action[] = []) {
notify({ notify({
type: 'error', type: 'error',
title: i18n.global.t('error.error'), title: i18n.global.t('error.error'),
@ -31,7 +26,7 @@ export function error(e, actions: Action[] = []) {
}) })
} }
export function success(e, actions: Action[] = []) { export function success(e, actions = []) {
notify({ notify({
type: 'success', type: 'success',
title: i18n.global.t('error.success'), title: i18n.global.t('error.success'),

View File

@ -5,7 +5,7 @@ import type {ITask} from './ITask'
export interface IBucket extends IAbstract { export interface IBucket extends IAbstract {
id: number id: number
title: string title: string
projectId: number listId: number
limit: number limit: number
tasks: ITask[] tasks: ITask[]
isDoneBucket: boolean isDoneBucket: boolean

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