feat: replace our home-grown gantt implementation with ganttastic (#2180)
Reviewed-on: https://kolaente.dev/vikunja/frontend/pulls/2180 Reviewed-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
This commit is contained in:
commit
fd3e7e655d
@ -5,7 +5,7 @@ module.exports = {
|
|||||||
'root': true,
|
'root': true,
|
||||||
'env': {
|
'env': {
|
||||||
'browser': true,
|
'browser': true,
|
||||||
'es2021': true,
|
'es2022': true,
|
||||||
'node': true,
|
'node': true,
|
||||||
'vue/setup-compiler-macros': true,
|
'vue/setup-compiler-macros': true,
|
||||||
},
|
},
|
||||||
|
@ -11,7 +11,7 @@ describe('List View Gantt', () => {
|
|||||||
const tasks = TaskFactory.create(1)
|
const tasks = TaskFactory.create(1)
|
||||||
cy.visit('/lists/1/gantt')
|
cy.visit('/lists/1/gantt')
|
||||||
|
|
||||||
cy.get('.gantt-chart .tasks')
|
cy.get('.g-gantt-rows-container')
|
||||||
.should('not.contain', tasks[0].title)
|
.should('not.contain', tasks[0].title)
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -25,7 +25,7 @@ describe('List View Gantt', () => {
|
|||||||
|
|
||||||
cy.visit('/lists/1/gantt')
|
cy.visit('/lists/1/gantt')
|
||||||
|
|
||||||
cy.get('.gantt-chart .months')
|
cy.get('.g-timeunits-container')
|
||||||
.should('contain', format(now, 'MMMM'))
|
.should('contain', format(now, 'MMMM'))
|
||||||
.should('contain', format(nextMonth, 'MMMM'))
|
.should('contain', format(nextMonth, 'MMMM'))
|
||||||
})
|
})
|
||||||
@ -38,14 +38,13 @@ describe('List View Gantt', () => {
|
|||||||
})
|
})
|
||||||
cy.visit('/lists/1/gantt')
|
cy.visit('/lists/1/gantt')
|
||||||
|
|
||||||
cy.get('.gantt-chart .tasks')
|
cy.get('.g-gantt-rows-container')
|
||||||
.should('not.be.empty')
|
.should('not.be.empty')
|
||||||
cy.get('.gantt-chart .tasks')
|
|
||||||
.should('contain', tasks[0].title)
|
.should('contain', tasks[0].title)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Shows tasks with no dates after enabling them', () => {
|
it('Shows tasks with no dates after enabling them', () => {
|
||||||
TaskFactory.create(1, {
|
const tasks = TaskFactory.create(1, {
|
||||||
start_date: null,
|
start_date: null,
|
||||||
end_date: null,
|
end_date: null,
|
||||||
})
|
})
|
||||||
@ -55,13 +54,15 @@ describe('List View Gantt', () => {
|
|||||||
.contains('Show tasks which don\'t have dates set')
|
.contains('Show tasks which don\'t have dates set')
|
||||||
.click()
|
.click()
|
||||||
|
|
||||||
cy.get('.gantt-chart .tasks')
|
cy.get('.g-gantt-rows-container')
|
||||||
.should('not.be.empty')
|
.should('not.be.empty')
|
||||||
cy.get('.gantt-chart .tasks .task.nodate')
|
.should('contain', tasks[0].title)
|
||||||
.should('exist')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Drags a task around', () => {
|
it('Drags a task around', () => {
|
||||||
|
cy.intercept('**/api/v1/tasks/*')
|
||||||
|
.as('taskUpdate')
|
||||||
|
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
TaskFactory.create(1, {
|
TaskFactory.create(1, {
|
||||||
start_date: formatISO(now),
|
start_date: formatISO(now),
|
||||||
@ -69,10 +70,11 @@ describe('List View Gantt', () => {
|
|||||||
})
|
})
|
||||||
cy.visit('/lists/1/gantt')
|
cy.visit('/lists/1/gantt')
|
||||||
|
|
||||||
cy.get('.gantt-chart .tasks .task')
|
cy.get('.g-gantt-rows-container .g-gantt-row .g-gantt-row-bars-container div .g-gantt-bar')
|
||||||
.first()
|
.first()
|
||||||
.trigger('mousedown', {which: 1})
|
.trigger('mousedown', {which: 1})
|
||||||
.trigger('mousemove', {clientX: 500, clientY: 0})
|
.trigger('mousemove', {clientX: 500, clientY: 0})
|
||||||
.trigger('mouseup', {force: true})
|
.trigger('mouseup', {force: true})
|
||||||
|
cy.wait('@taskUpdate')
|
||||||
})
|
})
|
||||||
})
|
})
|
@ -23,6 +23,7 @@
|
|||||||
"@fortawesome/free-solid-svg-icons": "6.2.0",
|
"@fortawesome/free-solid-svg-icons": "6.2.0",
|
||||||
"@fortawesome/vue-fontawesome": "3.0.1",
|
"@fortawesome/vue-fontawesome": "3.0.1",
|
||||||
"@github/hotkey": "2.0.1",
|
"@github/hotkey": "2.0.1",
|
||||||
|
"@infectoone/vue-ganttastic": "2.1.2",
|
||||||
"@kyvg/vue3-notification": "2.4.1",
|
"@kyvg/vue3-notification": "2.4.1",
|
||||||
"@sentry/tracing": "7.17.0",
|
"@sentry/tracing": "7.17.0",
|
||||||
"@sentry/vue": "7.17.0",
|
"@sentry/vue": "7.17.0",
|
||||||
@ -37,8 +38,10 @@
|
|||||||
"camel-case": "4.1.2",
|
"camel-case": "4.1.2",
|
||||||
"codemirror": "5.65.9",
|
"codemirror": "5.65.9",
|
||||||
"date-fns": "2.29.3",
|
"date-fns": "2.29.3",
|
||||||
|
"dayjs": "1.11.6",
|
||||||
"dompurify": "2.4.0",
|
"dompurify": "2.4.0",
|
||||||
"easymde": "2.18.0",
|
"easymde": "2.18.0",
|
||||||
|
"fast-deep-equal": "3.1.3",
|
||||||
"flatpickr": "4.6.13",
|
"flatpickr": "4.6.13",
|
||||||
"flexsearch": "0.7.21",
|
"flexsearch": "0.7.21",
|
||||||
"floating-vue": "2.0.0-beta.20",
|
"floating-vue": "2.0.0-beta.20",
|
||||||
@ -55,7 +58,6 @@
|
|||||||
"ufo": "0.8.6",
|
"ufo": "0.8.6",
|
||||||
"vue": "3.2.41",
|
"vue": "3.2.41",
|
||||||
"vue-advanced-cropper": "2.8.6",
|
"vue-advanced-cropper": "2.8.6",
|
||||||
"vue-drag-resize": "2.0.3",
|
|
||||||
"vue-flatpickr-component": "10.0.0",
|
"vue-flatpickr-component": "10.0.0",
|
||||||
"vue-i18n": "9.2.2",
|
"vue-i18n": "9.2.2",
|
||||||
"vue-router": "4.1.6",
|
"vue-router": "4.1.6",
|
||||||
@ -89,7 +91,7 @@
|
|||||||
"eslint": "8.26.0",
|
"eslint": "8.26.0",
|
||||||
"eslint-plugin-vue": "9.6.0",
|
"eslint-plugin-vue": "9.6.0",
|
||||||
"express": "4.18.2",
|
"express": "4.18.2",
|
||||||
"happy-dom": "7.6.0",
|
"happy-dom": "7.6.6",
|
||||||
"netlify-cli": "12.0.11",
|
"netlify-cli": "12.0.11",
|
||||||
"postcss": "8.4.18",
|
"postcss": "8.4.18",
|
||||||
"postcss-preset-env": "7.8.2",
|
"postcss-preset-env": "7.8.2",
|
||||||
|
52
pnpm-lock.yaml
generated
52
pnpm-lock.yaml
generated
@ -10,6 +10,7 @@ specifiers:
|
|||||||
'@fortawesome/free-solid-svg-icons': 6.2.0
|
'@fortawesome/free-solid-svg-icons': 6.2.0
|
||||||
'@fortawesome/vue-fontawesome': 3.0.1
|
'@fortawesome/vue-fontawesome': 3.0.1
|
||||||
'@github/hotkey': 2.0.1
|
'@github/hotkey': 2.0.1
|
||||||
|
'@infectoone/vue-ganttastic': 2.1.2
|
||||||
'@kyvg/vue3-notification': 2.4.1
|
'@kyvg/vue3-notification': 2.4.1
|
||||||
'@rushstack/eslint-patch': 1.2.0
|
'@rushstack/eslint-patch': 1.2.0
|
||||||
'@sentry/tracing': 7.17.0
|
'@sentry/tracing': 7.17.0
|
||||||
@ -42,16 +43,18 @@ specifiers:
|
|||||||
codemirror: 5.65.9
|
codemirror: 5.65.9
|
||||||
cypress: 10.11.0
|
cypress: 10.11.0
|
||||||
date-fns: 2.29.3
|
date-fns: 2.29.3
|
||||||
|
dayjs: 1.11.6
|
||||||
dompurify: 2.4.0
|
dompurify: 2.4.0
|
||||||
easymde: 2.18.0
|
easymde: 2.18.0
|
||||||
esbuild: 0.15.12
|
esbuild: 0.15.12
|
||||||
eslint: 8.26.0
|
eslint: 8.26.0
|
||||||
eslint-plugin-vue: 9.6.0
|
eslint-plugin-vue: 9.6.0
|
||||||
express: 4.18.2
|
express: 4.18.2
|
||||||
|
fast-deep-equal: 3.1.3
|
||||||
flatpickr: 4.6.13
|
flatpickr: 4.6.13
|
||||||
flexsearch: 0.7.21
|
flexsearch: 0.7.21
|
||||||
floating-vue: 2.0.0-beta.20
|
floating-vue: 2.0.0-beta.20
|
||||||
happy-dom: 7.6.0
|
happy-dom: 7.6.6
|
||||||
highlight.js: 11.6.0
|
highlight.js: 11.6.0
|
||||||
is-touch-device: 1.0.1
|
is-touch-device: 1.0.1
|
||||||
lodash.clonedeep: 4.5.0
|
lodash.clonedeep: 4.5.0
|
||||||
@ -76,7 +79,6 @@ specifiers:
|
|||||||
vitest: 0.24.3
|
vitest: 0.24.3
|
||||||
vue: 3.2.41
|
vue: 3.2.41
|
||||||
vue-advanced-cropper: 2.8.6
|
vue-advanced-cropper: 2.8.6
|
||||||
vue-drag-resize: 2.0.3
|
|
||||||
vue-flatpickr-component: 10.0.0
|
vue-flatpickr-component: 10.0.0
|
||||||
vue-i18n: 9.2.2
|
vue-i18n: 9.2.2
|
||||||
vue-router: 4.1.6
|
vue-router: 4.1.6
|
||||||
@ -92,6 +94,7 @@ dependencies:
|
|||||||
'@fortawesome/free-solid-svg-icons': 6.2.0
|
'@fortawesome/free-solid-svg-icons': 6.2.0
|
||||||
'@fortawesome/vue-fontawesome': 3.0.1_lteq7vqmz6gtgcgatkvrcm56su
|
'@fortawesome/vue-fontawesome': 3.0.1_lteq7vqmz6gtgcgatkvrcm56su
|
||||||
'@github/hotkey': 2.0.1
|
'@github/hotkey': 2.0.1
|
||||||
|
'@infectoone/vue-ganttastic': 2.1.2_dayjs@1.11.6+vue@3.2.41
|
||||||
'@kyvg/vue3-notification': 2.4.1_vue@3.2.41
|
'@kyvg/vue3-notification': 2.4.1_vue@3.2.41
|
||||||
'@sentry/tracing': 7.17.0
|
'@sentry/tracing': 7.17.0
|
||||||
'@sentry/vue': 7.17.0_vue@3.2.41
|
'@sentry/vue': 7.17.0_vue@3.2.41
|
||||||
@ -106,8 +109,10 @@ dependencies:
|
|||||||
camel-case: 4.1.2
|
camel-case: 4.1.2
|
||||||
codemirror: 5.65.9
|
codemirror: 5.65.9
|
||||||
date-fns: 2.29.3
|
date-fns: 2.29.3
|
||||||
|
dayjs: 1.11.6
|
||||||
dompurify: 2.4.0
|
dompurify: 2.4.0
|
||||||
easymde: 2.18.0
|
easymde: 2.18.0
|
||||||
|
fast-deep-equal: 3.1.3
|
||||||
flatpickr: 4.6.13
|
flatpickr: 4.6.13
|
||||||
flexsearch: 0.7.21
|
flexsearch: 0.7.21
|
||||||
floating-vue: 2.0.0-beta.20_vue@3.2.41
|
floating-vue: 2.0.0-beta.20_vue@3.2.41
|
||||||
@ -124,7 +129,6 @@ dependencies:
|
|||||||
ufo: 0.8.6
|
ufo: 0.8.6
|
||||||
vue: 3.2.41
|
vue: 3.2.41
|
||||||
vue-advanced-cropper: 2.8.6_vue@3.2.41
|
vue-advanced-cropper: 2.8.6_vue@3.2.41
|
||||||
vue-drag-resize: 2.0.3
|
|
||||||
vue-flatpickr-component: 10.0.0_vue@3.2.41
|
vue-flatpickr-component: 10.0.0_vue@3.2.41
|
||||||
vue-i18n: 9.2.2_vue@3.2.41
|
vue-i18n: 9.2.2_vue@3.2.41
|
||||||
vue-router: 4.1.6_vue@3.2.41
|
vue-router: 4.1.6_vue@3.2.41
|
||||||
@ -158,7 +162,7 @@ devDependencies:
|
|||||||
eslint: 8.26.0
|
eslint: 8.26.0
|
||||||
eslint-plugin-vue: 9.6.0_eslint@8.26.0
|
eslint-plugin-vue: 9.6.0_eslint@8.26.0
|
||||||
express: 4.18.2
|
express: 4.18.2
|
||||||
happy-dom: 7.6.0
|
happy-dom: 7.6.6
|
||||||
netlify-cli: 12.0.11_@types+node@18.11.7
|
netlify-cli: 12.0.11_@types+node@18.11.7
|
||||||
postcss: 8.4.18
|
postcss: 8.4.18
|
||||||
postcss-preset-env: 7.8.2_postcss@8.4.18
|
postcss-preset-env: 7.8.2_postcss@8.4.18
|
||||||
@ -169,7 +173,7 @@ devDependencies:
|
|||||||
vite: 3.2.0_sass@1.55.0+terser@5.10.0
|
vite: 3.2.0_sass@1.55.0+terser@5.10.0
|
||||||
vite-plugin-pwa: 0.13.1_26qty5l7rsv3yrimlrr6zrzqbu
|
vite-plugin-pwa: 0.13.1_26qty5l7rsv3yrimlrr6zrzqbu
|
||||||
vite-svg-loader: 3.6.0
|
vite-svg-loader: 3.6.0
|
||||||
vitest: 0.24.3_gqoderdqe64ltl5b7jo6jid56m
|
vitest: 0.24.3_zk3z3yjjczezb4ds7r7zfrc4ay
|
||||||
vue-tsc: 1.0.9_typescript@4.8.4
|
vue-tsc: 1.0.9_typescript@4.8.4
|
||||||
wait-on: 6.0.1
|
wait-on: 6.0.1
|
||||||
workbox-cli: 6.5.4_acorn@8.8.0
|
workbox-cli: 6.5.4_acorn@8.8.0
|
||||||
@ -1745,6 +1749,16 @@ packages:
|
|||||||
resolution: {integrity: sha512-tWZNBIS1CoekcwlMuyG2mr0a1Wo5lb5lEHwwWvZo+5GLgr3e9LLDTtmgtCWEwBpXMkxn9D+2W9j2FY6eZQq0tA==}
|
resolution: {integrity: sha512-tWZNBIS1CoekcwlMuyG2mr0a1Wo5lb5lEHwwWvZo+5GLgr3e9LLDTtmgtCWEwBpXMkxn9D+2W9j2FY6eZQq0tA==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/@infectoone/vue-ganttastic/2.1.2_dayjs@1.11.6+vue@3.2.41:
|
||||||
|
resolution: {integrity: sha512-xYjhA1bUUSVNjbFmM5eeN0mdKdDg7LWMO9RSh+9fFqfnqu24VwazTumHBYQZFGvGfdJW0DE7ZN3tbLhL9vkYlA==}
|
||||||
|
peerDependencies:
|
||||||
|
dayjs: ^1.11.5
|
||||||
|
vue: ^3.2.40
|
||||||
|
dependencies:
|
||||||
|
dayjs: 1.11.6
|
||||||
|
vue: 3.2.41
|
||||||
|
dev: false
|
||||||
|
|
||||||
/@intlify/core-base/9.2.2:
|
/@intlify/core-base/9.2.2:
|
||||||
resolution: {integrity: sha512-JjUpQtNfn+joMbrXvpR4hTF8iJQ2sEFzzK3KIESOx+f+uwIjgw20igOyaIdhfsVVBCds8ZM64MoeNSx+PHQMkA==}
|
resolution: {integrity: sha512-JjUpQtNfn+joMbrXvpR4hTF8iJQ2sEFzzK3KIESOx+f+uwIjgw20igOyaIdhfsVVBCds8ZM64MoeNSx+PHQMkA==}
|
||||||
engines: {node: '>= 14'}
|
engines: {node: '>= 14'}
|
||||||
@ -4023,7 +4037,7 @@ packages:
|
|||||||
postcss: ^8.1.0
|
postcss: ^8.1.0
|
||||||
dependencies:
|
dependencies:
|
||||||
browserslist: 4.21.4
|
browserslist: 4.21.4
|
||||||
caniuse-lite: 1.0.30001423
|
caniuse-lite: 1.0.30001426
|
||||||
fraction.js: 4.2.0
|
fraction.js: 4.2.0
|
||||||
normalize-range: 0.1.2
|
normalize-range: 0.1.2
|
||||||
picocolors: 1.0.0
|
picocolors: 1.0.0
|
||||||
@ -4312,7 +4326,7 @@ packages:
|
|||||||
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
dependencies:
|
dependencies:
|
||||||
caniuse-lite: 1.0.30001423
|
caniuse-lite: 1.0.30001426
|
||||||
electron-to-chromium: 1.4.256
|
electron-to-chromium: 1.4.256
|
||||||
node-releases: 2.0.6
|
node-releases: 2.0.6
|
||||||
update-browserslist-db: 1.0.9_browserslist@4.21.4
|
update-browserslist-db: 1.0.9_browserslist@4.21.4
|
||||||
@ -4517,6 +4531,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-09iwWGOlifvE1XuHokFMP7eR38a0JnajoyL3/i87c8ZjRWRrdKo1fqjNfugfBD0UDBIOz0U+jtNhJ0EPm1VleQ==}
|
resolution: {integrity: sha512-09iwWGOlifvE1XuHokFMP7eR38a0JnajoyL3/i87c8ZjRWRrdKo1fqjNfugfBD0UDBIOz0U+jtNhJ0EPm1VleQ==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/caniuse-lite/1.0.30001426:
|
||||||
|
resolution: {integrity: sha512-n7cosrHLl8AWt0wwZw/PJZgUg3lV0gk9LMI7ikGJwhyhgsd2Nb65vKvmSexCqq/J7rbH3mFG6yZZiPR5dLPW5A==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
/caseless/0.12.0:
|
/caseless/0.12.0:
|
||||||
resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==}
|
resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==}
|
||||||
dev: true
|
dev: true
|
||||||
@ -5230,7 +5248,7 @@ packages:
|
|||||||
cli-table3: 0.6.1
|
cli-table3: 0.6.1
|
||||||
commander: 5.1.0
|
commander: 5.1.0
|
||||||
common-tags: 1.8.2
|
common-tags: 1.8.2
|
||||||
dayjs: 1.10.7
|
dayjs: 1.11.6
|
||||||
debug: 4.3.4_supports-color@8.1.1
|
debug: 4.3.4_supports-color@8.1.1
|
||||||
enquirer: 2.3.6
|
enquirer: 2.3.6
|
||||||
eventemitter2: 6.4.7
|
eventemitter2: 6.4.7
|
||||||
@ -5286,9 +5304,8 @@ packages:
|
|||||||
time-zone: 1.0.0
|
time-zone: 1.0.0
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/dayjs/1.10.7:
|
/dayjs/1.11.6:
|
||||||
resolution: {integrity: sha512-P6twpd70BcPK34K26uJ1KT3wlhpuOAPoMwJzpsIWUxHZ7wpmbdZL/hQqBDfz7hGurYSa5PhzdhDHtt319hL3ig==}
|
resolution: {integrity: sha512-zZbY5giJAinCG+7AGaw0wIhNZ6J8AhWuSXKvuc1KAyMiRsvGQWqh4L+MomvhdAYjN+lqvVCMq1I41e3YHvXkyQ==}
|
||||||
dev: true
|
|
||||||
|
|
||||||
/de-indent/1.0.2:
|
/de-indent/1.0.2:
|
||||||
resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==}
|
resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==}
|
||||||
@ -6629,7 +6646,6 @@ packages:
|
|||||||
|
|
||||||
/fast-deep-equal/3.1.3:
|
/fast-deep-equal/3.1.3:
|
||||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||||
dev: true
|
|
||||||
|
|
||||||
/fast-diff/1.2.0:
|
/fast-diff/1.2.0:
|
||||||
resolution: {integrity: sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==}
|
resolution: {integrity: sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==}
|
||||||
@ -7440,8 +7456,8 @@ packages:
|
|||||||
engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0}
|
engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/happy-dom/7.6.0:
|
/happy-dom/7.6.6:
|
||||||
resolution: {integrity: sha512-QnNsiblZdyVDzW5ts6E7ub79JnabqHJeJgt+1WGNq9fSYqS/r/RzzTVXCZSDl6EVkipdwI48B4bgXAnMZPecIw==}
|
resolution: {integrity: sha512-28NxRiHXjzhr+BGciLNUoQW4OaBnQPRT/LPYLufh0Fj3Iwh1j9qJaozjBm/Uqdj5Ps4cukevQ7ERieA6Ddwf1g==}
|
||||||
dependencies:
|
dependencies:
|
||||||
css.escape: 1.5.1
|
css.escape: 1.5.1
|
||||||
he: 1.2.0
|
he: 1.2.0
|
||||||
@ -12922,7 +12938,7 @@ packages:
|
|||||||
fsevents: 2.3.2
|
fsevents: 2.3.2
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/vitest/0.24.3_gqoderdqe64ltl5b7jo6jid56m:
|
/vitest/0.24.3_zk3z3yjjczezb4ds7r7zfrc4ay:
|
||||||
resolution: {integrity: sha512-aM0auuPPgMSstWvr851hB74g/LKaKBzSxcG3da7ejfZbx08Y21JpZmbmDYrMTCGhVZKqTGwzcnLMwyfz2WzkhQ==}
|
resolution: {integrity: sha512-aM0auuPPgMSstWvr851hB74g/LKaKBzSxcG3da7ejfZbx08Y21JpZmbmDYrMTCGhVZKqTGwzcnLMwyfz2WzkhQ==}
|
||||||
engines: {node: '>=v14.16.0'}
|
engines: {node: '>=v14.16.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@ -12949,7 +12965,7 @@ packages:
|
|||||||
'@types/node': 18.11.7
|
'@types/node': 18.11.7
|
||||||
chai: 4.3.6
|
chai: 4.3.6
|
||||||
debug: 4.3.4
|
debug: 4.3.4
|
||||||
happy-dom: 7.6.0
|
happy-dom: 7.6.6
|
||||||
local-pkg: 0.4.2
|
local-pkg: 0.4.2
|
||||||
strip-literal: 0.4.2
|
strip-literal: 0.4.2
|
||||||
tinybench: 2.3.0
|
tinybench: 2.3.0
|
||||||
@ -12992,10 +13008,6 @@ packages:
|
|||||||
vue: 3.2.41
|
vue: 3.2.41
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/vue-drag-resize/2.0.3:
|
|
||||||
resolution: {integrity: sha512-5q03tZ/LyvQsg1iHRcqs+wI2OKNbNIWl9+7V8rVL6MxJhZLCIYSSgbAUaDE38LhD6dFd5aJhdgNmES61AxjXuw==}
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/vue-eslint-parser/9.0.3_eslint@8.26.0:
|
/vue-eslint-parser/9.0.3_eslint@8.26.0:
|
||||||
resolution: {integrity: sha512-yL+ZDb+9T0ELG4VIFo/2anAOz8SvBdlqEnQnvJ3M7Scq56DvtjY0VY88bByRZB0D4J0u8olBcfrXTVONXsh4og==}
|
resolution: {integrity: sha512-yL+ZDb+9T0ELG4VIFo/2anAOz8SvBdlqEnQnvJ3M7Scq56DvtjY0VY88bByRZB0D4J0u8olBcfrXTVONXsh4og==}
|
||||||
engines: {node: ^14.17.0 || >=16.0.0}
|
engines: {node: ^14.17.0 || >=16.0.0}
|
||||||
|
@ -1,12 +1,3 @@
|
|||||||
import { defineAsyncComponent } from 'vue'
|
import {createAsyncComponent} from '@/helpers/createAsyncComponent'
|
||||||
import ErrorComponent from '@/components/misc/error.vue'
|
|
||||||
import LoadingComponent from '@/components/misc/loading.vue'
|
|
||||||
|
|
||||||
const Editor = () => import('@/components/input/editor.vue')
|
export default createAsyncComponent(() => import('@/components/input/editor.vue'))
|
||||||
|
|
||||||
export default defineAsyncComponent({
|
|
||||||
loader: Editor,
|
|
||||||
loadingComponent: LoadingComponent,
|
|
||||||
errorComponent: ErrorComponent,
|
|
||||||
timeout: 60000,
|
|
||||||
})
|
|
@ -7,6 +7,12 @@
|
|||||||
</message>
|
</message>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
export default {
|
||||||
|
inheritAttrs: false,
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import Message from '@/components/misc/message.vue'
|
import Message from '@/components/misc/message.vue'
|
||||||
import ButtonLink from '@/components/misc/ButtonLink.vue'
|
import ButtonLink from '@/components/misc/ButtonLink.vue'
|
||||||
|
236
src/components/misc/flatpickr/Flatpickr.vue
Normal file
236
src/components/misc/flatpickr/Flatpickr.vue
Normal file
@ -0,0 +1,236 @@
|
|||||||
|
<template>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
data-input
|
||||||
|
:disabled="disabled"
|
||||||
|
v-bind="attrs"
|
||||||
|
ref="root"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import flatpickr from 'flatpickr'
|
||||||
|
import 'flatpickr/dist/flatpickr.css'
|
||||||
|
|
||||||
|
// FIXME: Not sure how to alias these correctly
|
||||||
|
// import Options = Flatpickr.Options doesn't work
|
||||||
|
type Hook = flatpickr.Options.Hook
|
||||||
|
type HookKey = flatpickr.Options.HookKey
|
||||||
|
type Options = flatpickr.Options.Options
|
||||||
|
type DateOption = flatpickr.Options.DateOption
|
||||||
|
|
||||||
|
function camelToKebab(string: string) {
|
||||||
|
return string.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function arrayify<T = unknown>(obj: T) {
|
||||||
|
return obj instanceof Array
|
||||||
|
? obj
|
||||||
|
: [obj]
|
||||||
|
}
|
||||||
|
|
||||||
|
function nullify<T = unknown>(value: T) {
|
||||||
|
return (value && (value as unknown[]).length)
|
||||||
|
? value
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Events to emit, copied from flatpickr source
|
||||||
|
const includedEvents = [
|
||||||
|
'onChange',
|
||||||
|
'onClose',
|
||||||
|
'onDestroy',
|
||||||
|
'onMonthChange',
|
||||||
|
'onOpen',
|
||||||
|
'onYearChange',
|
||||||
|
] as HookKey[]
|
||||||
|
|
||||||
|
// Let's not emit these events by default
|
||||||
|
const excludedEvents = [
|
||||||
|
'onValueUpdate',
|
||||||
|
'onDayCreate',
|
||||||
|
'onParseConfig',
|
||||||
|
'onReady',
|
||||||
|
'onPreCalendarPosition',
|
||||||
|
'onKeyDown',
|
||||||
|
] as HookKey[]
|
||||||
|
|
||||||
|
// Keep a copy of all events for later use
|
||||||
|
const allEvents = includedEvents.concat(excludedEvents)
|
||||||
|
|
||||||
|
export default {inheritAttrs: false}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {computed, onBeforeUnmount, onMounted, ref, toRefs, useAttrs, watch, watchEffect, type PropType} from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: [String, Number, Date, Array] as PropType<DateOption | DateOption[]>,
|
||||||
|
default: null,
|
||||||
|
required: true,
|
||||||
|
// validator(value) {
|
||||||
|
// return (
|
||||||
|
// value === null ||
|
||||||
|
// value instanceof Date ||
|
||||||
|
// typeof value === 'string' ||
|
||||||
|
// value instanceof String ||
|
||||||
|
// value instanceof Array ||
|
||||||
|
// typeof value === 'number'
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
},
|
||||||
|
// https://flatpickr.js.org/options/
|
||||||
|
config: {
|
||||||
|
type: Object as PropType<Options>,
|
||||||
|
default: () => ({
|
||||||
|
defaultDate: null,
|
||||||
|
wrap: false,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
events: {
|
||||||
|
type: Array as PropType<HookKey[]>,
|
||||||
|
default: () => includedEvents,
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits([
|
||||||
|
'blur',
|
||||||
|
'update:modelValue',
|
||||||
|
...allEvents.map(camelToKebab),
|
||||||
|
])
|
||||||
|
|
||||||
|
const {modelValue, config, disabled} = toRefs(props)
|
||||||
|
|
||||||
|
// bind listener like onBlur
|
||||||
|
const attrs = useAttrs()
|
||||||
|
|
||||||
|
const root = ref<HTMLInputElement | null>(null)
|
||||||
|
const fp = ref<flatpickr.Instance | null>(null)
|
||||||
|
const safeConfig = ref<Options>({ ...props.config })
|
||||||
|
|
||||||
|
function prepareConfig() {
|
||||||
|
// Don't mutate original object on parent component
|
||||||
|
const newConfig: Options = { ...props.config }
|
||||||
|
|
||||||
|
props.events.forEach((hook) => {
|
||||||
|
// Respect global callbacks registered via setDefault() method
|
||||||
|
const globalCallbacks = flatpickr.defaultConfig[hook] || []
|
||||||
|
|
||||||
|
// Inject our own method along with user callback
|
||||||
|
const localCallback: Hook = (...args) => emit(camelToKebab(hook), ...args)
|
||||||
|
|
||||||
|
// Overwrite with merged array
|
||||||
|
newConfig[hook] = arrayify(newConfig[hook] || []).concat(
|
||||||
|
globalCallbacks,
|
||||||
|
localCallback,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Watch for value changed by date-picker itself and notify parent component
|
||||||
|
const onChange: Hook = (dates) => emit('update:modelValue', dates)
|
||||||
|
newConfig['onChange'] = arrayify(newConfig['onChange'] || []).concat(onChange)
|
||||||
|
|
||||||
|
// Flatpickr does not emit input event in some cases
|
||||||
|
// const onClose: Hook = (_selectedDates, dateStr) => emit('update:modelValue', dateStr)
|
||||||
|
// newConfig['onClose'] = arrayify(newConfig['onClose'] || []).concat(onClose)
|
||||||
|
|
||||||
|
// Set initial date without emitting any event
|
||||||
|
newConfig.defaultDate = props.modelValue || newConfig.defaultDate
|
||||||
|
|
||||||
|
safeConfig.value = newConfig
|
||||||
|
|
||||||
|
return safeConfig.value
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (
|
||||||
|
fp.value || // Return early if flatpickr is already loaded
|
||||||
|
!root.value // our input needs to be mounted
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
prepareConfig()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the HTML node where flatpickr to be attached
|
||||||
|
* Bind on parent element if wrap is true
|
||||||
|
*/
|
||||||
|
const element = props.config.wrap
|
||||||
|
? root.value.parentNode
|
||||||
|
: root.value
|
||||||
|
|
||||||
|
// Init flatpickr
|
||||||
|
fp.value = flatpickr(element, safeConfig.value)
|
||||||
|
})
|
||||||
|
onBeforeUnmount(() => fp.value?.destroy())
|
||||||
|
|
||||||
|
watch(config, () => {
|
||||||
|
if (!fp.value) return
|
||||||
|
// Workaround: Don't pass hooks to configs again otherwise
|
||||||
|
// previously registered hooks will stop working
|
||||||
|
// Notice: we are looping through all events
|
||||||
|
// This also means that new callbacks can not be passed once component has been initialized
|
||||||
|
allEvents.forEach((hook) => {
|
||||||
|
delete safeConfig.value?.[hook]
|
||||||
|
})
|
||||||
|
fp.value.set(safeConfig.value)
|
||||||
|
|
||||||
|
// Passing these properties in `set()` method will cause flatpickr to trigger some callbacks
|
||||||
|
const configCallbacks = ['locale', 'showMonths'] as (keyof Options)[]
|
||||||
|
|
||||||
|
// Workaround: Allow to change locale dynamically
|
||||||
|
configCallbacks.forEach(name => {
|
||||||
|
if (typeof safeConfig.value?.[name] !== 'undefined' && fp.value) {
|
||||||
|
fp.value.set(name, safeConfig.value[name])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, {deep:true})
|
||||||
|
|
||||||
|
const fpInput = computed(() => {
|
||||||
|
if (!fp.value) return
|
||||||
|
return fp.value.altInput || fp.value.input
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* init blur event
|
||||||
|
* (is required by many validation libraries)
|
||||||
|
*/
|
||||||
|
function onBlur(event: Event) {
|
||||||
|
emit('blur', nullify((event.target as HTMLInputElement).value))
|
||||||
|
}
|
||||||
|
|
||||||
|
watchEffect(() => fpInput.value?.addEventListener('blur', onBlur))
|
||||||
|
onBeforeUnmount(() => fpInput.value?.removeEventListener('blur', onBlur))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Watch for the disabled property and sets the value to the real input.
|
||||||
|
*/
|
||||||
|
watchEffect(() => {
|
||||||
|
if (disabled.value) {
|
||||||
|
fpInput.value?.setAttribute('disabled', '')
|
||||||
|
} else {
|
||||||
|
fpInput.value?.removeAttribute('disabled')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Watch for changes from parent component and update DOM
|
||||||
|
*/
|
||||||
|
watch(
|
||||||
|
modelValue,
|
||||||
|
newValue => {
|
||||||
|
// Prevent updates if v-model value is same as input's current value
|
||||||
|
if (!root.value || newValue === nullify(root.value.value)) return
|
||||||
|
// Make sure we have a flatpickr instance and
|
||||||
|
// notify flatpickr instance that there is a change in value
|
||||||
|
fp.value?.setDate(newValue, true)
|
||||||
|
},
|
||||||
|
{deep: true},
|
||||||
|
)
|
||||||
|
</script>
|
@ -2,6 +2,12 @@
|
|||||||
<div class="loader-container is-loading"></div>
|
<div class="loader-container is-loading"></div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
export default {
|
||||||
|
inheritAttrs: false,
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.loader-container {
|
.loader-container {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
255
src/components/tasks/GanttChart.vue
Normal file
255
src/components/tasks/GanttChart.vue
Normal file
@ -0,0 +1,255 @@
|
|||||||
|
<template>
|
||||||
|
<Loading
|
||||||
|
v-if="props.isLoading && tasks.size || dayjsLanguageLoading"
|
||||||
|
class="gantt-container"
|
||||||
|
/>
|
||||||
|
<div class="gantt-container" v-else>
|
||||||
|
<GGanttChart
|
||||||
|
:date-format="DAYJS_ISO_DATE_FORMAT"
|
||||||
|
:chart-start="isoToKebabDate(filters.dateFrom)"
|
||||||
|
:chart-end="isoToKebabDate(filters.dateTo)"
|
||||||
|
precision="day"
|
||||||
|
bar-start="startDate"
|
||||||
|
bar-end="endDate"
|
||||||
|
:grid="true"
|
||||||
|
@dragend-bar="updateGanttTask"
|
||||||
|
@dblclick-bar="openTask"
|
||||||
|
:width="ganttChartWidth + 'px'"
|
||||||
|
>
|
||||||
|
<template #timeunit="{label, value}">
|
||||||
|
<div
|
||||||
|
class="timeunit-wrapper"
|
||||||
|
:class="{'today': dayIsToday(label)}"
|
||||||
|
>
|
||||||
|
<span>{{ value }}</span>
|
||||||
|
<span class="weekday">
|
||||||
|
{{ weekdayFromTimeLabel(label) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<GGanttRow
|
||||||
|
v-for="(bar, k) in ganttBars"
|
||||||
|
:key="k"
|
||||||
|
label=""
|
||||||
|
:bars="bar"
|
||||||
|
/>
|
||||||
|
</GGanttChart>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {computed, ref, watch, toRefs} from 'vue'
|
||||||
|
import {useRouter} from 'vue-router'
|
||||||
|
import {format, parse} from 'date-fns'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import isToday from 'dayjs/plugin/isToday'
|
||||||
|
|
||||||
|
import {getHexColor} from '@/models/task'
|
||||||
|
|
||||||
|
import {colorIsDark} from '@/helpers/color/colorIsDark'
|
||||||
|
import {isoToKebabDate} from '@/helpers/time/isoToKebabDate'
|
||||||
|
import {parseKebabDate} from '@/helpers/time/parseKebabDate'
|
||||||
|
|
||||||
|
import type {ITask, ITaskPartialWithId} from '@/modelTypes/ITask'
|
||||||
|
import type {DateISO} from '@/types/DateISO'
|
||||||
|
import type {GanttFilters} from '@/views/list/helpers/useGanttFilters'
|
||||||
|
|
||||||
|
import {
|
||||||
|
extendDayjs,
|
||||||
|
GGanttChart,
|
||||||
|
GGanttRow,
|
||||||
|
type GanttBarObject,
|
||||||
|
} from '@infectoone/vue-ganttastic'
|
||||||
|
|
||||||
|
import Loading from '@/components/misc/loading.vue'
|
||||||
|
import {MILLISECONDS_A_DAY} from '@/constants/date'
|
||||||
|
|
||||||
|
export interface GanttChartProps {
|
||||||
|
isLoading: boolean,
|
||||||
|
filters: GanttFilters,
|
||||||
|
tasks: Map<ITask['id'], ITask>,
|
||||||
|
defaultTaskStartDate: DateISO
|
||||||
|
defaultTaskEndDate: DateISO
|
||||||
|
}
|
||||||
|
|
||||||
|
const DAYJS_ISO_DATE_FORMAT = 'YYYY-MM-DD'
|
||||||
|
|
||||||
|
const props = defineProps<GanttChartProps>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:task', task: ITaskPartialWithId): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const {tasks, filters} = toRefs(props)
|
||||||
|
|
||||||
|
// setup dayjs for vue-ganttastic
|
||||||
|
const dayjsLanguageLoading = ref(false)
|
||||||
|
// const dayjsLanguageLoading = useDayjsLanguageSync(dayjs)
|
||||||
|
dayjs.extend(isToday)
|
||||||
|
extendDayjs()
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const dateFromDate = computed(() => new Date(new Date(filters.value.dateFrom).setHours(0,0,0,0)))
|
||||||
|
const dateToDate = computed(() => new Date(new Date(filters.value.dateTo).setHours(23,59,0,0)))
|
||||||
|
|
||||||
|
const DAY_WIDTH_PIXELS = 30
|
||||||
|
const ganttChartWidth = computed(() => {
|
||||||
|
const dateDiff = Math.floor((dateToDate.value.valueOf() - dateFromDate.value.valueOf()) / MILLISECONDS_A_DAY)
|
||||||
|
|
||||||
|
return dateDiff * DAY_WIDTH_PIXELS
|
||||||
|
})
|
||||||
|
|
||||||
|
const ganttBars = ref<GanttBarObject[][]>([])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update ganttBars when tasks change
|
||||||
|
*/
|
||||||
|
watch(
|
||||||
|
tasks,
|
||||||
|
() => {
|
||||||
|
ganttBars.value = []
|
||||||
|
tasks.value.forEach(t => ganttBars.value.push(transformTaskToGanttBar(t)))
|
||||||
|
},
|
||||||
|
{deep: true, immediate: true},
|
||||||
|
)
|
||||||
|
|
||||||
|
function transformTaskToGanttBar(t: ITask) {
|
||||||
|
const black = 'var(--grey-800)'
|
||||||
|
return [{
|
||||||
|
startDate: isoToKebabDate(t.startDate ? t.startDate.toISOString() : props.defaultTaskStartDate),
|
||||||
|
endDate: isoToKebabDate(t.endDate ? t.endDate.toISOString() : props.defaultTaskEndDate),
|
||||||
|
ganttBarConfig: {
|
||||||
|
id: String(t.id),
|
||||||
|
label: t.title,
|
||||||
|
hasHandles: true,
|
||||||
|
style: {
|
||||||
|
color: t.startDate ? (colorIsDark(getHexColor(t.hexColor)) ? black : 'white') : black,
|
||||||
|
backgroundColor: t.startDate ? getHexColor(t.hexColor) : 'var(--grey-100)',
|
||||||
|
border: t.startDate ? '' : '2px dashed var(--grey-300)',
|
||||||
|
'text-decoration': t.done ? 'line-through' : null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as GanttBarObject]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateGanttTask(e: {
|
||||||
|
bar: GanttBarObject;
|
||||||
|
e: MouseEvent;
|
||||||
|
datetime?: string | undefined;
|
||||||
|
}) {
|
||||||
|
emit('update:task', {
|
||||||
|
id: Number(e.bar.ganttBarConfig.id),
|
||||||
|
startDate: new Date(parseKebabDate(e.bar.startDate).setHours(0,0,0,0)),
|
||||||
|
endDate: new Date(parseKebabDate(e.bar.endDate).setHours(23,59,0,0)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function openTask(e: {
|
||||||
|
bar: GanttBarObject;
|
||||||
|
e: MouseEvent;
|
||||||
|
datetime?: string | undefined;
|
||||||
|
}) {
|
||||||
|
router.push({
|
||||||
|
name: 'task.detail',
|
||||||
|
params: {id: e.bar.ganttBarConfig.id},
|
||||||
|
state: {backdropView: router.currentRoute.value.fullPath},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTimeLabel(label: string) {
|
||||||
|
return parse(label, 'dd.MMM', dateFromDate.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function weekdayFromTimeLabel(label: string): string {
|
||||||
|
const parsed = parseTimeLabel(label)
|
||||||
|
return format(parsed, 'E')
|
||||||
|
}
|
||||||
|
|
||||||
|
function dayIsToday(label: string): boolean {
|
||||||
|
const parsed = parseTimeLabel(label)
|
||||||
|
|
||||||
|
const today = new Date()
|
||||||
|
return parsed.getDate() === today.getDate() &&
|
||||||
|
parsed.getMonth() === today.getMonth() &&
|
||||||
|
parsed.getFullYear() === today.getFullYear()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.gantt-container {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
// Not scoped because we need to style the elements inside the gantt chart component
|
||||||
|
.g-gantt-chart {
|
||||||
|
width: max-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-gantt-row-label {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-upper-timeunit, .g-timeunit {
|
||||||
|
background: var(--white) !important;
|
||||||
|
font-family: $vikunja-font;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-upper-timeunit {
|
||||||
|
font-weight: bold;
|
||||||
|
border-right: 1px solid var(--grey-200);
|
||||||
|
padding: .5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-timeunit .timeunit-wrapper {
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
font-size: 1rem !important;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&.today {
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--white);
|
||||||
|
border-radius: 5px 5px 0 0;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekday {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-timeaxis {
|
||||||
|
height: auto !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-gantt-row > .g-gantt-row-bars-container {
|
||||||
|
border-bottom: none !important;
|
||||||
|
border-top: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-gantt-row:nth-child(odd) {
|
||||||
|
background: hsla(var(--grey-100-hsl), .5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-gantt-bar {
|
||||||
|
border-radius: $radius * 1.5;
|
||||||
|
overflow: visible;
|
||||||
|
font-size: .85rem;
|
||||||
|
|
||||||
|
&-handle-left,
|
||||||
|
&-handle-right {
|
||||||
|
width: 6px !important;
|
||||||
|
height: 75% !important;
|
||||||
|
opacity: .75 !important;
|
||||||
|
border-radius: $radius !important;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
78
src/components/tasks/TaskForm.vue
Normal file
78
src/components/tasks/TaskForm.vue
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
<template>
|
||||||
|
<form
|
||||||
|
@submit.prevent="createTask"
|
||||||
|
class="add-new-task"
|
||||||
|
>
|
||||||
|
<transition name="width">
|
||||||
|
<input
|
||||||
|
v-if="newTaskFieldActive"
|
||||||
|
v-model="newTaskTitle"
|
||||||
|
@blur="hideCreateNewTask"
|
||||||
|
@keyup.esc="newTaskFieldActive = false"
|
||||||
|
class="input"
|
||||||
|
ref="newTaskTitleField"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
</transition>
|
||||||
|
<x-button @click="showCreateTaskOrCreate" :shadow="false" icon="plus">
|
||||||
|
{{ $t('task.new') }}
|
||||||
|
</x-button>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {nextTick, ref} from 'vue'
|
||||||
|
import type {ITask} from '@/modelTypes/ITask'
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'create-task', title: string): Promise<ITask>
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const newTaskFieldActive = ref(false)
|
||||||
|
const newTaskTitleField = ref()
|
||||||
|
const newTaskTitle = ref('')
|
||||||
|
|
||||||
|
function showCreateTaskOrCreate() {
|
||||||
|
if (!newTaskFieldActive.value) {
|
||||||
|
// Timeout to not send the form if the field isn't even shown
|
||||||
|
setTimeout(() => {
|
||||||
|
newTaskFieldActive.value = true
|
||||||
|
nextTick(() => newTaskTitleField.value.focus())
|
||||||
|
}, 100)
|
||||||
|
} else {
|
||||||
|
createTask()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideCreateNewTask() {
|
||||||
|
if (newTaskTitle.value === '') {
|
||||||
|
nextTick(() => (newTaskFieldActive.value = false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTask() {
|
||||||
|
if (!newTaskFieldActive.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await emit('create-task', newTaskTitle.value)
|
||||||
|
newTaskTitle.value = ''
|
||||||
|
hideCreateNewTask()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.add-new-task {
|
||||||
|
padding: 1rem .7rem .4rem .7rem;
|
||||||
|
display: flex;
|
||||||
|
max-width: 450px;
|
||||||
|
|
||||||
|
.input {
|
||||||
|
margin-right: .7rem;
|
||||||
|
font-size: .8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
font-size: .68rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
@ -1,642 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="gantt-chart">
|
|
||||||
<div class="filter-container">
|
|
||||||
<div class="items">
|
|
||||||
<filter-popup
|
|
||||||
v-model="params"
|
|
||||||
@update:modelValue="loadTasks()"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="dates">
|
|
||||||
<template v-for="(y, yk) in days" :key="yk + 'year'">
|
|
||||||
<div class="months">
|
|
||||||
<div
|
|
||||||
:key="mk + 'month'"
|
|
||||||
class="month"
|
|
||||||
v-for="(m, mk) in days[yk]"
|
|
||||||
>
|
|
||||||
{{ formatMonthAndYear(yk, parseInt(mk) + 1) }}
|
|
||||||
<div class="days">
|
|
||||||
<div
|
|
||||||
:class="{ today: d.toDateString() === now.toDateString() }"
|
|
||||||
:key="dk + 'day'"
|
|
||||||
:style="{ width: dayWidth + 'px' }"
|
|
||||||
class="day"
|
|
||||||
v-for="(d, dk) in days[yk][mk]"
|
|
||||||
>
|
|
||||||
<span class="theday" v-if="dayWidth > 25">
|
|
||||||
{{ d.getDate() }}
|
|
||||||
</span>
|
|
||||||
<span class="weekday" v-if="dayWidth > 25">
|
|
||||||
{{
|
|
||||||
d.toLocaleString('en-us', {
|
|
||||||
weekday: 'short',
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
<div :style="{ width: fullWidth + 'px' }" class="tasks">
|
|
||||||
<div
|
|
||||||
v-for="(t, k) in theTasks"
|
|
||||||
:key="t ? t.id : 0"
|
|
||||||
:style="{
|
|
||||||
background:
|
|
||||||
'repeating-linear-gradient(90deg, #ededed, #ededed 1px, ' +
|
|
||||||
(k % 2 === 0
|
|
||||||
? '#fafafa 1px, #fafafa '
|
|
||||||
: '#fff 1px, #fff ') +
|
|
||||||
dayWidth +
|
|
||||||
'px)',
|
|
||||||
}"
|
|
||||||
class="row"
|
|
||||||
>
|
|
||||||
<VueDragResize
|
|
||||||
:class="{
|
|
||||||
done: t ? t.done : false,
|
|
||||||
'is-current-edit': taskToEdit !== null && taskToEdit.id === t.id,
|
|
||||||
'has-light-text': !colorIsDark(t.getHexColor()),
|
|
||||||
'has-dark-text': colorIsDark(t.getHexColor()),
|
|
||||||
}"
|
|
||||||
:gridX="dayWidth"
|
|
||||||
:h="31"
|
|
||||||
:isActive="canWrite"
|
|
||||||
:minw="dayWidth"
|
|
||||||
:parentLimitation="true"
|
|
||||||
:parentW="fullWidth"
|
|
||||||
:snapToGrid="true"
|
|
||||||
:sticks="['mr', 'ml']"
|
|
||||||
:style="{
|
|
||||||
'border-color': t.getHexColor(),
|
|
||||||
'background-color': t.getHexColor(),
|
|
||||||
}"
|
|
||||||
:w="t.durationDays * dayWidth"
|
|
||||||
:x="t.offsetDays * dayWidth - 6"
|
|
||||||
:y="0"
|
|
||||||
@dragstop="(e) => resizeTask(t, e)"
|
|
||||||
@resizestop="(e) => resizeTask(t, e)"
|
|
||||||
axis="x"
|
|
||||||
class="task"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
:class="{
|
|
||||||
'has-high-priority': t.priority >= priorities.HIGH,
|
|
||||||
'has-not-so-high-priority':
|
|
||||||
t.priority === priorities.HIGH,
|
|
||||||
'has-super-high-priority':
|
|
||||||
t.priority === priorities.DO_NOW,
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
{{ t.title }}
|
|
||||||
</span>
|
|
||||||
<priority-label :priority="t.priority" :done="t.done"/>
|
|
||||||
<!-- using the key here forces vue to use the updated version model and not the response returned by the api -->
|
|
||||||
<!-- FIXME: add label -->
|
|
||||||
<BaseButton @click="editTask(theTasks[k])" class="edit-toggle">
|
|
||||||
<icon icon="pen"/>
|
|
||||||
</BaseButton>
|
|
||||||
</VueDragResize>
|
|
||||||
</div>
|
|
||||||
<template v-if="showTaskswithoutDates">
|
|
||||||
<div
|
|
||||||
:key="t.id"
|
|
||||||
:style="{
|
|
||||||
background:
|
|
||||||
'repeating-linear-gradient(90deg, #ededed, #ededed 1px, ' +
|
|
||||||
(k % 2 === 0
|
|
||||||
? '#fafafa 1px, #fafafa '
|
|
||||||
: '#fff 1px, #fff ') +
|
|
||||||
dayWidth +
|
|
||||||
'px)',
|
|
||||||
}"
|
|
||||||
class="row"
|
|
||||||
v-for="(t, k) in tasksWithoutDates"
|
|
||||||
>
|
|
||||||
<VueDragResize
|
|
||||||
:gridX="dayWidth"
|
|
||||||
:h="31"
|
|
||||||
:isActive="canWrite"
|
|
||||||
:minw="dayWidth"
|
|
||||||
:parentLimitation="true"
|
|
||||||
:parentW="fullWidth"
|
|
||||||
:snapToGrid="true"
|
|
||||||
:sticks="['mr', 'ml']"
|
|
||||||
:x="dayOffsetUntilToday * dayWidth - 6"
|
|
||||||
:y="0"
|
|
||||||
@dragstop="(e) => resizeTask(t, e)"
|
|
||||||
@resizestop="(e) => resizeTask(t, e)"
|
|
||||||
axis="x"
|
|
||||||
class="task nodate"
|
|
||||||
v-tooltip="$t('list.gantt.noDates')"
|
|
||||||
>
|
|
||||||
<span>{{ t.title }}</span>
|
|
||||||
</VueDragResize>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
<form
|
|
||||||
@submit.prevent="addNewTask()"
|
|
||||||
class="add-new-task"
|
|
||||||
v-if="canWrite"
|
|
||||||
>
|
|
||||||
<transition name="width">
|
|
||||||
<input
|
|
||||||
@blur="hideCrateNewTask"
|
|
||||||
@keyup.esc="newTaskFieldActive = false"
|
|
||||||
class="input"
|
|
||||||
ref="newTaskTitleField"
|
|
||||||
type="text"
|
|
||||||
v-if="newTaskFieldActive"
|
|
||||||
v-model="newTaskTitle"
|
|
||||||
/>
|
|
||||||
</transition>
|
|
||||||
<x-button @click="showCreateNewTask" :shadow="false" icon="plus">
|
|
||||||
{{ $t('list.list.newTaskCta') }}
|
|
||||||
</x-button>
|
|
||||||
</form>
|
|
||||||
<transition name="fade">
|
|
||||||
<edit-task
|
|
||||||
v-if="isTaskEdit"
|
|
||||||
class="taskedit"
|
|
||||||
:title="$t('list.list.editTask')"
|
|
||||||
@close="() => {isTaskEdit = false;taskToEdit = null}"
|
|
||||||
:task="taskToEdit"
|
|
||||||
/>
|
|
||||||
</transition>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import {defineComponent} from 'vue'
|
|
||||||
import {mapState} from 'pinia'
|
|
||||||
|
|
||||||
import VueDragResize from 'vue-drag-resize'
|
|
||||||
import EditTask from './edit-task.vue'
|
|
||||||
|
|
||||||
import TaskService from '../../services/task'
|
|
||||||
import TaskModel from '../../models/task'
|
|
||||||
import {PRIORITIES as priorities} from '@/constants/priorities'
|
|
||||||
import PriorityLabel from './partials/priorityLabel.vue'
|
|
||||||
import TaskCollectionService from '../../services/taskCollection'
|
|
||||||
import {RIGHTS as Rights} from '@/constants/rights'
|
|
||||||
import FilterPopup from '@/components/list/partials/filter-popup.vue'
|
|
||||||
import BaseButton from '@/components/base/BaseButton.vue'
|
|
||||||
|
|
||||||
import {colorIsDark} from '@/helpers/color/colorIsDark'
|
|
||||||
import {formatDate} from '@/helpers/time/formatDate'
|
|
||||||
|
|
||||||
import {useBaseStore} from '@/stores/base'
|
|
||||||
|
|
||||||
export default defineComponent({
|
|
||||||
name: 'GanttChart',
|
|
||||||
components: {
|
|
||||||
BaseButton,
|
|
||||||
FilterPopup,
|
|
||||||
PriorityLabel,
|
|
||||||
EditTask,
|
|
||||||
VueDragResize,
|
|
||||||
},
|
|
||||||
props: {
|
|
||||||
listId: {
|
|
||||||
type: Number,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
showTaskswithoutDates: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
dateFrom: {
|
|
||||||
default: () => new Date(new Date().setDate(new Date().getDate() - 15)),
|
|
||||||
},
|
|
||||||
dateTo: {
|
|
||||||
default: () => new Date(new Date().setDate(new Date().getDate() + 30)),
|
|
||||||
},
|
|
||||||
// The width of a day in pixels, used to calculate all sorts of things.
|
|
||||||
dayWidth: {
|
|
||||||
type: Number,
|
|
||||||
default: 35,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
days: [],
|
|
||||||
startDate: null,
|
|
||||||
endDate: null,
|
|
||||||
theTasks: [], // Pretty much a copy of the prop, since we cant mutate the prop directly
|
|
||||||
tasksWithoutDates: [],
|
|
||||||
taskService: new TaskService(),
|
|
||||||
fullWidth: 0,
|
|
||||||
now: new Date(),
|
|
||||||
dayOffsetUntilToday: 0,
|
|
||||||
isTaskEdit: false,
|
|
||||||
taskToEdit: null,
|
|
||||||
newTaskTitle: '',
|
|
||||||
newTaskFieldActive: false,
|
|
||||||
priorities: priorities,
|
|
||||||
taskCollectionService: new TaskCollectionService(),
|
|
||||||
|
|
||||||
params: {
|
|
||||||
sort_by: ['done', 'id'],
|
|
||||||
order_by: ['asc', 'desc'],
|
|
||||||
filter_by: ['done'],
|
|
||||||
filter_value: ['false'],
|
|
||||||
filter_comparator: ['equals'],
|
|
||||||
filter_concat: 'and',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
dateFrom: 'buildTheGanttChart',
|
|
||||||
dateTo: 'buildTheGanttChart',
|
|
||||||
listId: 'parseTasks',
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
this.buildTheGanttChart()
|
|
||||||
},
|
|
||||||
computed: mapState(useBaseStore, {
|
|
||||||
canWrite: (state) => state.currentList.maxRight > Rights.READ,
|
|
||||||
}),
|
|
||||||
methods: {
|
|
||||||
colorIsDark,
|
|
||||||
buildTheGanttChart() {
|
|
||||||
this.setDates()
|
|
||||||
this.prepareGanttDays()
|
|
||||||
this.parseTasks()
|
|
||||||
},
|
|
||||||
setDates() {
|
|
||||||
this.startDate = new Date(this.dateFrom)
|
|
||||||
this.endDate = new Date(this.dateTo)
|
|
||||||
console.debug('setDates; start date: ', this.startDate, 'end date:', this.endDate, 'date from:', this.dateFrom, 'date to:', this.dateTo)
|
|
||||||
|
|
||||||
this.dayOffsetUntilToday = Math.floor((this.now - this.startDate) / 1000 / 60 / 60 / 24) + 1
|
|
||||||
},
|
|
||||||
prepareGanttDays() {
|
|
||||||
console.debug('prepareGanttDays; start date: ', this.startDate, 'end date:', this.endDate)
|
|
||||||
// Layout: years => [months => [days]]
|
|
||||||
const years = {}
|
|
||||||
for (
|
|
||||||
let d = this.startDate;
|
|
||||||
d <= this.endDate;
|
|
||||||
d.setDate(d.getDate() + 1)
|
|
||||||
) {
|
|
||||||
const date = new Date(d)
|
|
||||||
if (years[date.getFullYear() + ''] === undefined) {
|
|
||||||
years[date.getFullYear() + ''] = {}
|
|
||||||
}
|
|
||||||
if (years[date.getFullYear() + ''][date.getMonth() + ''] === undefined) {
|
|
||||||
years[date.getFullYear() + ''][date.getMonth() + ''] = []
|
|
||||||
}
|
|
||||||
years[date.getFullYear() + ''][date.getMonth() + ''].push(date)
|
|
||||||
this.fullWidth += this.dayWidth
|
|
||||||
}
|
|
||||||
console.debug('prepareGanttDays; years:', years)
|
|
||||||
this.days = years
|
|
||||||
},
|
|
||||||
|
|
||||||
parseTasks() {
|
|
||||||
this.setDates()
|
|
||||||
this.loadTasks()
|
|
||||||
},
|
|
||||||
|
|
||||||
async loadTasks() {
|
|
||||||
this.theTasks = []
|
|
||||||
this.tasksWithoutDates = []
|
|
||||||
|
|
||||||
const getAllTasks = async (page = 1) => {
|
|
||||||
const tasks = await this.taskCollectionService.getAll({listId: this.listId}, this.params, page)
|
|
||||||
if (page < this.taskCollectionService.totalPages) {
|
|
||||||
const nextTasks = await getAllTasks(page + 1)
|
|
||||||
return tasks.concat(nextTasks)
|
|
||||||
}
|
|
||||||
return tasks
|
|
||||||
}
|
|
||||||
|
|
||||||
const tasks = await getAllTasks()
|
|
||||||
this.theTasks = tasks
|
|
||||||
.filter((t) => {
|
|
||||||
if (t.startDate === null && !t.done) {
|
|
||||||
this.tasksWithoutDates.push(t)
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
t.startDate >= this.startDate &&
|
|
||||||
t.endDate <= this.endDate
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.map((t) => this.addGantAttributes(t))
|
|
||||||
.sort(function (a, b) {
|
|
||||||
if (a.startDate < b.startDate) return -1
|
|
||||||
if (a.startDate > b.startDate) return 1
|
|
||||||
return 0
|
|
||||||
})
|
|
||||||
},
|
|
||||||
addGantAttributes(t) {
|
|
||||||
if (typeof t.durationDays !== 'undefined' && typeof t.offsetDays !== 'undefined') {
|
|
||||||
return t
|
|
||||||
}
|
|
||||||
|
|
||||||
t.endDate === null ? this.endDate : t.endDate
|
|
||||||
t.durationDays = Math.floor((t.endDate - t.startDate) / 1000 / 60 / 60 / 24)
|
|
||||||
t.offsetDays = Math.floor((t.startDate - this.startDate) / 1000 / 60 / 60 / 24)
|
|
||||||
return t
|
|
||||||
},
|
|
||||||
async resizeTask(taskDragged, newRect) {
|
|
||||||
if (this.isTaskEdit) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let newTask = {...taskDragged}
|
|
||||||
|
|
||||||
const didntHaveDates = newTask.startDate === null ? true : false
|
|
||||||
|
|
||||||
const startDate = new Date(this.startDate)
|
|
||||||
startDate.setDate(
|
|
||||||
startDate.getDate() + newRect.left / this.dayWidth,
|
|
||||||
)
|
|
||||||
startDate.setUTCHours(0)
|
|
||||||
startDate.setUTCMinutes(0)
|
|
||||||
startDate.setUTCSeconds(0)
|
|
||||||
startDate.setUTCMilliseconds(0)
|
|
||||||
newTask.startDate = startDate
|
|
||||||
const endDate = new Date(startDate)
|
|
||||||
endDate.setDate(
|
|
||||||
startDate.getDate() + newRect.width / this.dayWidth,
|
|
||||||
)
|
|
||||||
newTask.startDate = startDate
|
|
||||||
newTask.endDate = endDate
|
|
||||||
|
|
||||||
// We take the task from the overall tasks array because the one in it has bad data after it was updated once.
|
|
||||||
// FIXME: This is a workaround. We should use a better mechanism to get the task or, even better,
|
|
||||||
// prevent it from containing outdated Data in the first place.
|
|
||||||
for (const tt in this.theTasks) {
|
|
||||||
if (this.theTasks[tt].id === newTask.id) {
|
|
||||||
newTask = this.theTasks[tt]
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const ganttData = {
|
|
||||||
endDate: newTask.endDate,
|
|
||||||
durationDays: newTask.durationDays,
|
|
||||||
offsetDays: newTask.offsetDays,
|
|
||||||
}
|
|
||||||
|
|
||||||
const r = await this.taskService.update(newTask)
|
|
||||||
r.endDate = ganttData.endDate
|
|
||||||
r.durationDays = ganttData.durationDays
|
|
||||||
r.offsetDays = ganttData.offsetDays
|
|
||||||
|
|
||||||
// If the task didn't have dates before, we'll update the list
|
|
||||||
if (didntHaveDates) {
|
|
||||||
for (const t in this.tasksWithoutDates) {
|
|
||||||
if (this.tasksWithoutDates[t].id === r.id) {
|
|
||||||
this.tasksWithoutDates.splice(t, 1)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.theTasks.push(this.addGantAttributes(r))
|
|
||||||
} else {
|
|
||||||
for (const tt in this.theTasks) {
|
|
||||||
if (this.theTasks[tt].id === r.id) {
|
|
||||||
this.theTasks[tt] = this.addGantAttributes(r)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
editTask(task) {
|
|
||||||
this.taskToEdit = task
|
|
||||||
this.isTaskEdit = true
|
|
||||||
},
|
|
||||||
showCreateNewTask() {
|
|
||||||
if (!this.newTaskFieldActive) {
|
|
||||||
// Timeout to not send the form if the field isn't even shown
|
|
||||||
setTimeout(() => {
|
|
||||||
this.newTaskFieldActive = true
|
|
||||||
this.$nextTick(() => this.$refs.newTaskTitleField.focus())
|
|
||||||
}, 100)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
hideCrateNewTask() {
|
|
||||||
if (this.newTaskTitle === '') {
|
|
||||||
this.$nextTick(() => (this.newTaskFieldActive = false))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async addNewTask() {
|
|
||||||
if (!this.newTaskFieldActive) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const task = new TaskModel({
|
|
||||||
title: this.newTaskTitle,
|
|
||||||
listId: this.listId,
|
|
||||||
})
|
|
||||||
const r = await this.taskService.create(task)
|
|
||||||
this.tasksWithoutDates.push(this.addGantAttributes(r))
|
|
||||||
this.newTaskTitle = ''
|
|
||||||
this.hideCrateNewTask()
|
|
||||||
},
|
|
||||||
formatMonthAndYear(year, month) {
|
|
||||||
month = month < 10 ? '0' + month : month
|
|
||||||
const date = new Date(`${year}-${month}-01`)
|
|
||||||
return formatDate(date, 'MMMM, yyyy')
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
$gantt-border: 1px solid var(--grey-200);
|
|
||||||
$gantt-vertical-border-color: var(--grey-100);
|
|
||||||
|
|
||||||
.gantt-chart {
|
|
||||||
overflow-x: auto;
|
|
||||||
border-top: 1px solid var(--grey-200);
|
|
||||||
|
|
||||||
.dates {
|
|
||||||
display: flex;
|
|
||||||
text-align: center;
|
|
||||||
|
|
||||||
.months {
|
|
||||||
display: flex;
|
|
||||||
|
|
||||||
.month {
|
|
||||||
padding: 0.5rem 0 0;
|
|
||||||
border-right: $gantt-border;
|
|
||||||
font-family: $vikunja-font;
|
|
||||||
font-weight: bold;
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
border-right: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.days {
|
|
||||||
display: flex;
|
|
||||||
|
|
||||||
.day {
|
|
||||||
padding: 0.5rem 0;
|
|
||||||
font-weight: normal;
|
|
||||||
|
|
||||||
&.today {
|
|
||||||
background: var(--primary);
|
|
||||||
color: var(--white);
|
|
||||||
border-radius: 5px 5px 0 0;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theday {
|
|
||||||
padding: 0 .5rem;
|
|
||||||
width: 100%;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.weekday {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.tasks {
|
|
||||||
max-width: unset !important;
|
|
||||||
border-top: $gantt-border;
|
|
||||||
|
|
||||||
.row {
|
|
||||||
height: 45px;
|
|
||||||
|
|
||||||
.task {
|
|
||||||
display: inline-block;
|
|
||||||
border: 2px solid var(--primary);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
margin: 0.5rem;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
cursor: grab;
|
|
||||||
position: relative;
|
|
||||||
height: 31px !important;
|
|
||||||
|
|
||||||
-webkit-touch-callout: none; // iOS Safari
|
|
||||||
user-select: none; // Non-prefixed version
|
|
||||||
|
|
||||||
&.is-current-edit {
|
|
||||||
border-color: var(--warning) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.has-light-text {
|
|
||||||
color: var(--grey-100);
|
|
||||||
|
|
||||||
&.done span:after {
|
|
||||||
border-top: 1px solid var(--grey-100);
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-toggle {
|
|
||||||
color: var(--grey-100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.has-dark-text {
|
|
||||||
color: var(--text);
|
|
||||||
|
|
||||||
&.done span:after {
|
|
||||||
border-top: 1px solid var(--dark);
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-toggle {
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.done span {
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
&::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
right: 0;
|
|
||||||
left: 0;
|
|
||||||
top: 57%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
span:not(.high-priority) {
|
|
||||||
max-width: calc(100% - 20px);
|
|
||||||
display: inline-block;
|
|
||||||
white-space: nowrap;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
&.has-high-priority {
|
|
||||||
max-width: calc(100% - 90px);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.has-not-so-high-priority {
|
|
||||||
max-width: calc(100% - 70px);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.has-super-high-priority {
|
|
||||||
max-width: calc(100% - 111px);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.icon {
|
|
||||||
width: 10px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.high-priority {
|
|
||||||
margin: 0 0 0 .5rem;
|
|
||||||
vertical-align: bottom;
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-toggle {
|
|
||||||
float: right;
|
|
||||||
cursor: pointer;
|
|
||||||
margin-right: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.nodate {
|
|
||||||
border: 2px dashed var(--grey-300);
|
|
||||||
background: var(--grey-100);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
cursor: grabbing;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.taskedit {
|
|
||||||
position: fixed;
|
|
||||||
top: 10vh;
|
|
||||||
right: 10vw;
|
|
||||||
z-index: 5;
|
|
||||||
|
|
||||||
// FIXME: should be an option of the card, e.g. overflow
|
|
||||||
:deep(.card-content) {
|
|
||||||
max-height: 60vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-new-task {
|
|
||||||
padding: 1rem .7rem .4rem .7rem;
|
|
||||||
display: flex;
|
|
||||||
max-width: 450px;
|
|
||||||
|
|
||||||
.input {
|
|
||||||
margin-right: .7rem;
|
|
||||||
font-size: .8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button {
|
|
||||||
font-size: .68rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
@ -3,6 +3,9 @@ import {useRouter} from 'vue-router'
|
|||||||
import {useEventListener} from '@vueuse/core'
|
import {useEventListener} from '@vueuse/core'
|
||||||
|
|
||||||
import {useAuthStore} from '@/stores/auth'
|
import {useAuthStore} from '@/stores/auth'
|
||||||
|
import {MILLISECONDS_A_HOUR, SECONDS_A_HOUR} from '@/constants/date'
|
||||||
|
|
||||||
|
const SECONDS_TOKEN_VALID = 60 * SECONDS_A_HOUR
|
||||||
|
|
||||||
export function useRenewTokenOnFocus() {
|
export function useRenewTokenOnFocus() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@ -21,7 +24,7 @@ export function useRenewTokenOnFocus() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const expiresIn = (userInfo.value !== null ? userInfo.value.exp : 0) - +new Date() / 1000
|
const expiresIn = (userInfo.value !== null ? userInfo.value.exp : 0) - new Date().valueOf() / MILLISECONDS_A_HOUR
|
||||||
|
|
||||||
// If the token expiry is negative, it is already expired and we have no choice but to redirect
|
// If the token expiry is negative, it is already expired and we have no choice but to redirect
|
||||||
// the user to the login page
|
// the user to the login page
|
||||||
@ -32,7 +35,7 @@ export function useRenewTokenOnFocus() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if the token is valid for less than 60 hours and renew if thats the case
|
// Check if the token is valid for less than 60 hours and renew if thats the case
|
||||||
if (expiresIn < 60 * 3600) {
|
if (expiresIn < SECONDS_TOKEN_VALID) {
|
||||||
authStore.renewToken()
|
authStore.renewToken()
|
||||||
console.debug('renewed token')
|
console.debug('renewed token')
|
||||||
}
|
}
|
||||||
|
55
src/composables/useRouteFilters.ts
Normal file
55
src/composables/useRouteFilters.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import {computed, ref, watch, type Ref} from 'vue'
|
||||||
|
import {useRouter, type RouteLocationNormalized, type RouteLocationRaw} from 'vue-router'
|
||||||
|
import cloneDeep from 'lodash.clonedeep'
|
||||||
|
import equal from 'fast-deep-equal/es6'
|
||||||
|
|
||||||
|
export type Filters = Record<string, any>
|
||||||
|
|
||||||
|
export function useRouteFilters<CurrentFilters extends Filters>(
|
||||||
|
route: Ref<RouteLocationNormalized>,
|
||||||
|
getDefaultFilters: (route: RouteLocationNormalized) => CurrentFilters,
|
||||||
|
routeToFilters: (route: RouteLocationNormalized) => CurrentFilters,
|
||||||
|
filtersToRoute: (filters: CurrentFilters) => RouteLocationRaw,
|
||||||
|
) {
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const filters = ref<CurrentFilters>(routeToFilters(route.value))
|
||||||
|
|
||||||
|
const routeFromFiltersFullPath = computed(() => router.resolve(filtersToRoute(filters.value)).fullPath)
|
||||||
|
|
||||||
|
watch(() => cloneDeep(route.value), (route, oldRoute) => {
|
||||||
|
if (
|
||||||
|
route.name !== oldRoute.name ||
|
||||||
|
routeFromFiltersFullPath.value === route.fullPath
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
filters.value = routeToFilters(route)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
filters,
|
||||||
|
async () => {
|
||||||
|
if (routeFromFiltersFullPath.value !== route.value.fullPath) {
|
||||||
|
await router.push(routeFromFiltersFullPath.value)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// only apply new route after all filters have changed in component cycle
|
||||||
|
{flush: 'post'},
|
||||||
|
)
|
||||||
|
|
||||||
|
const hasDefaultFilters = computed(() => {
|
||||||
|
return equal(filters.value, getDefaultFilters(route.value))
|
||||||
|
})
|
||||||
|
|
||||||
|
function setDefaultFilters() {
|
||||||
|
filters.value = getDefaultFilters(route.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
filters,
|
||||||
|
hasDefaultFilters,
|
||||||
|
setDefaultFilters,
|
||||||
|
}
|
||||||
|
}
|
14
src/constants/date.ts
Normal file
14
src/constants/date.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
export const DATEFNS_DATE_FORMAT_KEBAB = 'yyyy-LL-dd'
|
||||||
|
|
||||||
|
export const SECONDS_A_MINUTE = 60
|
||||||
|
export const SECONDS_A_HOUR = SECONDS_A_MINUTE * 60
|
||||||
|
export const SECONDS_A_DAY = SECONDS_A_HOUR * 24
|
||||||
|
export const SECONDS_A_WEEK = SECONDS_A_DAY * 7
|
||||||
|
export const SECONDS_A_MONTH = SECONDS_A_DAY * 30
|
||||||
|
export const SECONDS_A_YEAR = SECONDS_A_DAY * 365
|
||||||
|
|
||||||
|
export const MILLISECONDS_A_SECOND = 1000
|
||||||
|
export const MILLISECONDS_A_MINUTE = SECONDS_A_MINUTE * MILLISECONDS_A_SECOND
|
||||||
|
export const MILLISECONDS_A_HOUR = SECONDS_A_HOUR * MILLISECONDS_A_SECOND
|
||||||
|
export const MILLISECONDS_A_DAY = SECONDS_A_DAY * MILLISECONDS_A_SECOND
|
||||||
|
export const MILLISECONDS_A_WEEK = SECONDS_A_WEEK * MILLISECONDS_A_SECOND
|
@ -4,7 +4,7 @@
|
|||||||
* @param color
|
* @param color
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
export function colorFromHex(color) {
|
export function colorFromHex(color: string) {
|
||||||
if (color.substring(0, 1) === '#') {
|
if (color.substring(0, 1) === '#') {
|
||||||
color = color.substring(1, 7)
|
color = color.substring(1, 7)
|
||||||
}
|
}
|
||||||
|
21
src/helpers/createAsyncComponent.ts
Normal file
21
src/helpers/createAsyncComponent.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { defineAsyncComponent, type AsyncComponentLoader, type AsyncComponentOptions, type Component, type ComponentPublicInstance } from 'vue'
|
||||||
|
|
||||||
|
import ErrorComponent from '@/components/misc/error.vue'
|
||||||
|
import LoadingComponent from '@/components/misc/loading.vue'
|
||||||
|
|
||||||
|
const DEFAULT_TIMEOUT = 60000
|
||||||
|
|
||||||
|
export function createAsyncComponent<T extends Component = {
|
||||||
|
new (): ComponentPublicInstance;
|
||||||
|
}>(source: AsyncComponentLoader<T> | AsyncComponentOptions<T>): T {
|
||||||
|
if (typeof source === 'function') {
|
||||||
|
source = { loader: source }
|
||||||
|
}
|
||||||
|
|
||||||
|
return defineAsyncComponent({
|
||||||
|
...source,
|
||||||
|
loadingComponent: LoadingComponent,
|
||||||
|
errorComponent: ErrorComponent,
|
||||||
|
timeout: DEFAULT_TIMEOUT,
|
||||||
|
})
|
||||||
|
}
|
@ -1,5 +1,7 @@
|
|||||||
import {createDateFromString} from '@/helpers/time/createDateFromString'
|
import {createDateFromString} from '@/helpers/time/createDateFromString'
|
||||||
import {format, formatDistanceToNow, formatISO as formatISOfns} from 'date-fns'
|
import {format, formatDistanceToNow, formatISO as formatISOfns} from 'date-fns'
|
||||||
|
|
||||||
|
// FIXME: support all locales and load dynamically
|
||||||
import {enGB, de, fr, ru} from 'date-fns/locale'
|
import {enGB, de, fr, ru} from 'date-fns/locale'
|
||||||
|
|
||||||
import {i18n} from '@/i18n'
|
import {i18n} from '@/i18n'
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import {MILLISECONDS_A_WEEK} from '@/constants/date'
|
||||||
|
|
||||||
export function getNextWeekDate(): Date {
|
export function getNextWeekDate(): Date {
|
||||||
return new Date((new Date()).getTime() + 7 * 24 * 60 * 60 * 1000)
|
return new Date((new Date()).getTime() + MILLISECONDS_A_WEEK)
|
||||||
}
|
}
|
||||||
|
8
src/helpers/time/isoToKebabDate.ts
Normal file
8
src/helpers/time/isoToKebabDate.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import {format} from 'date-fns'
|
||||||
|
import {DATEFNS_DATE_FORMAT_KEBAB} from '@/constants/date'
|
||||||
|
import type {DateISO} from '@/types/DateISO'
|
||||||
|
import type {DateKebab} from '@/types/DateKebab'
|
||||||
|
|
||||||
|
export function isoToKebabDate(isoDate: DateISO) {
|
||||||
|
return format(new Date(isoDate), DATEFNS_DATE_FORMAT_KEBAB) as DateKebab
|
||||||
|
}
|
5
src/helpers/time/parseBooleanProp.ts
Normal file
5
src/helpers/time/parseBooleanProp.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export function parseBooleanProp(booleanProp: string | undefined) {
|
||||||
|
return (booleanProp === 'false' || booleanProp === '0')
|
||||||
|
? false
|
||||||
|
: Boolean(booleanProp)
|
||||||
|
}
|
@ -349,9 +349,7 @@ const getMonthFromText = (text: string, date: Date) => {
|
|||||||
const getDateFromInterval = (interval: number): Date => {
|
const getDateFromInterval = (interval: number): Date => {
|
||||||
const newDate = new Date()
|
const newDate = new Date()
|
||||||
newDate.setDate(newDate.getDate() + interval)
|
newDate.setDate(newDate.getDate() + interval)
|
||||||
newDate.setHours(calculateNearestHours(newDate))
|
newDate.setHours(calculateNearestHours(newDate), 0, 0)
|
||||||
newDate.setMinutes(0)
|
|
||||||
newDate.setSeconds(0)
|
|
||||||
|
|
||||||
return newDate
|
return newDate
|
||||||
}
|
}
|
||||||
|
30
src/helpers/time/parseDateProp.ts
Normal file
30
src/helpers/time/parseDateProp.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import type {DateISO} from '@/types/DateISO'
|
||||||
|
import type {DateKebab} from '@/types/DateKebab'
|
||||||
|
|
||||||
|
export function parseDateProp(kebabDate: DateKebab | undefined): string | undefined {
|
||||||
|
try {
|
||||||
|
|
||||||
|
if (!kebabDate) {
|
||||||
|
throw new Error('No value')
|
||||||
|
}
|
||||||
|
const dateValues = kebabDate.split('-')
|
||||||
|
const [, monthString, dateString] = dateValues
|
||||||
|
const [year, month, date] = dateValues.map(val => Number(val))
|
||||||
|
const dateValuesAreValid = (
|
||||||
|
!Number.isNaN(year) &&
|
||||||
|
monthString.length >= 1 && monthString.length <= 2 &&
|
||||||
|
!Number.isNaN(month) &&
|
||||||
|
month >= 1 && month <= 12 &&
|
||||||
|
dateString.length >= 1 && dateString.length <= 31 &&
|
||||||
|
!Number.isNaN(date) &&
|
||||||
|
date >= 1 && date <= 31
|
||||||
|
)
|
||||||
|
if (!dateValuesAreValid) {
|
||||||
|
throw new Error('Invalid date values')
|
||||||
|
}
|
||||||
|
return new Date(year, month, date).toISOString() as DateISO
|
||||||
|
} catch(e) {
|
||||||
|
// ignore nonsense route queries
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
7
src/helpers/time/parseKebabDate.ts
Normal file
7
src/helpers/time/parseKebabDate.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import {parse} from 'date-fns'
|
||||||
|
import {DATEFNS_DATE_FORMAT_KEBAB} from '@/constants/date'
|
||||||
|
import type {DateKebab} from '@/types/DateKebab'
|
||||||
|
|
||||||
|
export function parseKebabDate(date: DateKebab): Date {
|
||||||
|
return parse(date, DATEFNS_DATE_FORMAT_KEBAB, new Date())
|
||||||
|
}
|
@ -1,19 +1,8 @@
|
|||||||
import {createI18n} from 'vue-i18n'
|
import {createI18n} from 'vue-i18n'
|
||||||
import langEN from './lang/en.json'
|
import langEN from './lang/en.json'
|
||||||
|
|
||||||
export const i18n = createI18n({
|
export const SUPPORTED_LOCALES = {
|
||||||
locale: 'en', // set locale
|
'en': 'English',
|
||||||
fallbackLocale: 'en',
|
|
||||||
legacy: true,
|
|
||||||
globalInjection: true,
|
|
||||||
allowComposition: true,
|
|
||||||
messages: {
|
|
||||||
en: langEN,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
export const availableLanguages = {
|
|
||||||
en: 'English',
|
|
||||||
'de-DE': 'Deutsch',
|
'de-DE': 'Deutsch',
|
||||||
'de-swiss': 'Schwizertütsch',
|
'de-swiss': 'Schwizertütsch',
|
||||||
'ru-RU': 'Русский',
|
'ru-RU': 'Русский',
|
||||||
@ -24,62 +13,72 @@ export const availableLanguages = {
|
|||||||
'pl-PL': 'Polski',
|
'pl-PL': 'Polski',
|
||||||
'nl-NL': 'Nederlands',
|
'nl-NL': 'Nederlands',
|
||||||
'pt-PT': 'Português',
|
'pt-PT': 'Português',
|
||||||
}
|
'zh-CN': 'Chinese',
|
||||||
|
} as Record<string, string>
|
||||||
|
|
||||||
const loadedLanguages = ['en'] // our default language that is preloaded
|
export type SupportedLocale = keyof typeof SUPPORTED_LOCALES
|
||||||
|
|
||||||
const setI18nLanguage = (lang: string) => {
|
export const DEFAULT_LANGUAGE: SupportedLocale= 'en'
|
||||||
|
|
||||||
|
export type ISOLanguage = string
|
||||||
|
|
||||||
|
export const i18n = createI18n({
|
||||||
|
locale: DEFAULT_LANGUAGE, // set locale
|
||||||
|
fallbackLocale: DEFAULT_LANGUAGE,
|
||||||
|
legacy: true,
|
||||||
|
globalInjection: true,
|
||||||
|
allowComposition: true,
|
||||||
|
inheritLocale: true,
|
||||||
|
messages: {
|
||||||
|
en: langEN,
|
||||||
|
} as Record<SupportedLocale, any>,
|
||||||
|
})
|
||||||
|
|
||||||
|
function setI18nLanguage(lang: SupportedLocale): SupportedLocale {
|
||||||
i18n.global.locale = lang
|
i18n.global.locale = lang
|
||||||
document.documentElement.lang = lang
|
document.documentElement.lang = lang
|
||||||
return lang
|
return lang
|
||||||
}
|
}
|
||||||
|
|
||||||
export const loadLanguageAsync = lang => {
|
export async function loadLanguageAsync(lang: SupportedLocale) {
|
||||||
if (!lang) {
|
if (!lang) {
|
||||||
|
throw new Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
// do not change language to the current one
|
||||||
|
if (i18n.global.locale === lang) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
|
||||||
// If the same language
|
|
||||||
i18n.global.locale === lang ||
|
|
||||||
// If the language was already loaded
|
|
||||||
loadedLanguages.includes(lang)
|
|
||||||
) {
|
|
||||||
return setI18nLanguage(lang)
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the language hasn't been loaded yet
|
// If the language hasn't been loaded yet
|
||||||
return import(`./lang/${lang}.json`).then(
|
if (!i18n.global.availableLocales.includes(lang)) {
|
||||||
messages => {
|
const messages = await import(`./lang/${lang}.json`)
|
||||||
i18n.global.setLocaleMessage(lang, messages.default)
|
i18n.global.setLocaleMessage(lang, messages.default)
|
||||||
loadedLanguages.push(lang)
|
|
||||||
return setI18nLanguage(lang)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getCurrentLanguage = () => {
|
return setI18nLanguage(lang)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCurrentLanguage(): SupportedLocale {
|
||||||
const savedLanguage = localStorage.getItem('language')
|
const savedLanguage = localStorage.getItem('language')
|
||||||
if (savedLanguage !== null) {
|
if (savedLanguage !== null) {
|
||||||
return savedLanguage
|
return savedLanguage
|
||||||
}
|
}
|
||||||
|
|
||||||
const browserLanguage = navigator.language || navigator.userLanguage
|
const browserLanguage = navigator.language
|
||||||
|
|
||||||
for (const k in availableLanguages) {
|
const language: SupportedLocale | undefined = Object.keys(SUPPORTED_LOCALES).find(langKey => {
|
||||||
if (browserLanguage[k] === browserLanguage || k.startsWith(browserLanguage + '-')) {
|
return langKey === browserLanguage || langKey.startsWith(browserLanguage + '-')
|
||||||
return k
|
})
|
||||||
}
|
|
||||||
|
return language || DEFAULT_LANGUAGE
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'en'
|
export function saveLanguage(lang: SupportedLocale) {
|
||||||
}
|
|
||||||
|
|
||||||
export const saveLanguage = (lang: string) => {
|
|
||||||
localStorage.setItem('language', lang)
|
localStorage.setItem('language', lang)
|
||||||
setLanguage()
|
setLanguage()
|
||||||
}
|
}
|
||||||
|
|
||||||
export const setLanguage = () => {
|
export function setLanguage() {
|
||||||
loadLanguageAsync(getCurrentLanguage())
|
return loadLanguageAsync(getCurrentLanguage())
|
||||||
}
|
}
|
@ -286,8 +286,8 @@
|
|||||||
"default": "Default",
|
"default": "Default",
|
||||||
"month": "Month",
|
"month": "Month",
|
||||||
"day": "Day",
|
"day": "Day",
|
||||||
"from": "From",
|
"hour": "Hour",
|
||||||
"to": "To",
|
"range": "Date Range",
|
||||||
"noDates": "This task has no dates set."
|
"noDates": "This task has no dates set."
|
||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
|
60
src/i18n/useDayjsLanguageSync.ts
Normal file
60
src/i18n/useDayjsLanguageSync.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import {computed, ref, watch} from 'vue'
|
||||||
|
import type dayjs from 'dayjs'
|
||||||
|
import type ILocale from 'dayjs/locale/*'
|
||||||
|
|
||||||
|
import {i18n, type SupportedLocale, type ISOLanguage} from '@/i18n'
|
||||||
|
|
||||||
|
export const DAYJS_LOCALE_MAPPING = {
|
||||||
|
'de-de': 'de',
|
||||||
|
'de-swiss': 'de-at',
|
||||||
|
'ru-ru': 'ru',
|
||||||
|
'fr-fr': 'fr',
|
||||||
|
'vi-vn': 'vi',
|
||||||
|
'it-it': 'it',
|
||||||
|
'cs-cz': 'cs',
|
||||||
|
'pl-pl': 'pl',
|
||||||
|
'nl-nl': 'nl',
|
||||||
|
'pt-pt': 'pt',
|
||||||
|
'zh-cn': 'zh-cn',
|
||||||
|
} as Record<SupportedLocale, ISOLanguage>
|
||||||
|
|
||||||
|
export const DAYJS_LANGUAGE_IMPORTS = {
|
||||||
|
'de-de': () => import('dayjs/locale/de'),
|
||||||
|
'de-swiss': () => import('dayjs/locale/de-at'),
|
||||||
|
'ru-ru': () => import('dayjs/locale/ru'),
|
||||||
|
'fr-fr': () => import('dayjs/locale/fr'),
|
||||||
|
'vi-vn': () => import('dayjs/locale/vi'),
|
||||||
|
'it-it': () => import('dayjs/locale/it'),
|
||||||
|
'cs-cz': () => import('dayjs/locale/cs'),
|
||||||
|
'pl-pl': () => import('dayjs/locale/pl'),
|
||||||
|
'nl-nl': () => import('dayjs/locale/nl'),
|
||||||
|
'pt-pt': () => import('dayjs/locale/pt'),
|
||||||
|
'zh-cn': () => import('dayjs/locale/zh-cn'),
|
||||||
|
} as Record<SupportedLocale, () => Promise<ILocale>>
|
||||||
|
|
||||||
|
export function useDayjsLanguageSync(dayjsGlobal: typeof dayjs) {
|
||||||
|
|
||||||
|
const dayjsLanguageLoaded = ref(false)
|
||||||
|
watch(
|
||||||
|
() => i18n.global.locale,
|
||||||
|
async (currentLanguage: string) => {
|
||||||
|
if (!dayjsGlobal) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const dayjsLanguageCode = DAYJS_LOCALE_MAPPING[currentLanguage.toLowerCase()] || currentLanguage.toLowerCase()
|
||||||
|
dayjsLanguageLoaded.value = dayjsGlobal.locale() === dayjsLanguageCode
|
||||||
|
if (dayjsLanguageLoaded.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await DAYJS_LANGUAGE_IMPORTS[currentLanguage.toLowerCase()]()
|
||||||
|
dayjsGlobal.locale(dayjsLanguageCode)
|
||||||
|
dayjsLanguageLoaded.value = true
|
||||||
|
},
|
||||||
|
{immediate: true},
|
||||||
|
)
|
||||||
|
|
||||||
|
// we export the loading state since that's easier to work with
|
||||||
|
const isLoading = computed(() => !dayjsLanguageLoaded.value)
|
||||||
|
|
||||||
|
return isLoading
|
||||||
|
}
|
@ -1,6 +1,28 @@
|
|||||||
|
import {SECONDS_A_HOUR} from '@/constants/date'
|
||||||
import { REPEAT_TYPES, type IRepeatAfter } from '@/types/IRepeatAfter'
|
import { REPEAT_TYPES, type IRepeatAfter } from '@/types/IRepeatAfter'
|
||||||
import { nativeEnum, number, object, preprocess } from 'zod'
|
import { nativeEnum, number, object, preprocess } from 'zod'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses `repeatAfterSeconds` into a usable js object.
|
||||||
|
*/
|
||||||
|
export function parseRepeatAfter(repeatAfterSeconds: number): IRepeatAfter {
|
||||||
|
let repeatAfter: IRepeatAfter = {type: 'hours', amount: repeatAfterSeconds / SECONDS_A_HOUR}
|
||||||
|
|
||||||
|
// if its dividable by 24, its something with days, otherwise hours
|
||||||
|
if (repeatAfterSeconds % SECONDS_A_DAY === 0) {
|
||||||
|
if (repeatAfterSeconds % SECONDS_A_WEEK === 0) {
|
||||||
|
repeatAfter = {type: 'weeks', amount: repeatAfterSeconds / SECONDS_A_WEEK}
|
||||||
|
} else if (repeatAfterSeconds % SECONDS_A_MONTH === 0) {
|
||||||
|
repeatAfter = {type:'months', amount: repeatAfterSeconds / SECONDS_A_MONTH}
|
||||||
|
} else if (repeatAfterSeconds % SECONDS_A_YEAR === 0) {
|
||||||
|
repeatAfter = {type: 'years', amount: repeatAfterSeconds / SECONDS_A_YEAR}
|
||||||
|
} else {
|
||||||
|
repeatAfter = {type: 'days', amount: repeatAfterSeconds / SECONDS_A_DAY}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return repeatAfter
|
||||||
|
}
|
||||||
|
|
||||||
export const RepeatsSchema = preprocess(
|
export const RepeatsSchema = preprocess(
|
||||||
(repeats: unknown) => {
|
(repeats: unknown) => {
|
||||||
// Parses the "repeat after x seconds" from the task into a usable js object inside the task.
|
// Parses the "repeat after x seconds" from the task into a usable js object inside the task.
|
||||||
@ -9,32 +31,7 @@ export const RepeatsSchema = preprocess(
|
|||||||
return repeats
|
return repeats
|
||||||
}
|
}
|
||||||
|
|
||||||
const repeatAfterHours = (repeats / 60) / 60
|
return parseRepeatAfter(repeats)
|
||||||
|
|
||||||
const repeatAfter : IRepeatAfter = {
|
|
||||||
type: 'hours',
|
|
||||||
amount: repeatAfterHours,
|
|
||||||
}
|
|
||||||
|
|
||||||
// if its dividable by 24, its something with days, otherwise hours
|
|
||||||
if (repeatAfterHours % 24 === 0) {
|
|
||||||
const repeatAfterDays = repeatAfterHours / 24
|
|
||||||
if (repeatAfterDays % 7 === 0) {
|
|
||||||
repeatAfter.type = 'weeks'
|
|
||||||
repeatAfter.amount = repeatAfterDays / 7
|
|
||||||
} else if (repeatAfterDays % 30 === 0) {
|
|
||||||
repeatAfter.type = 'months'
|
|
||||||
repeatAfter.amount = repeatAfterDays / 30
|
|
||||||
} else if (repeatAfterDays % 365 === 0) {
|
|
||||||
repeatAfter.type = 'years'
|
|
||||||
repeatAfter.amount = repeatAfterDays / 365
|
|
||||||
} else {
|
|
||||||
repeatAfter.type = 'days'
|
|
||||||
repeatAfter.amount = repeatAfterDays
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return repeatAfter
|
|
||||||
},
|
},
|
||||||
object({
|
object({
|
||||||
type: nativeEnum(REPEAT_TYPES),
|
type: nativeEnum(REPEAT_TYPES),
|
||||||
|
@ -12,6 +12,8 @@ import type {IRelationKind} from '@/types/IRelationKind'
|
|||||||
import type {IRepeatAfter} from '@/types/IRepeatAfter'
|
import type {IRepeatAfter} from '@/types/IRepeatAfter'
|
||||||
import type {IRepeatMode} from '@/types/IRepeatMode'
|
import type {IRepeatMode} from '@/types/IRepeatMode'
|
||||||
|
|
||||||
|
import type {PartialWithId} from '@/types/PartialWithId'
|
||||||
|
|
||||||
export interface ITask extends IAbstract {
|
export interface ITask extends IAbstract {
|
||||||
id: number
|
id: number
|
||||||
title: string
|
title: string
|
||||||
@ -50,3 +52,5 @@ export interface ITask extends IAbstract {
|
|||||||
listId: IList['id'] // Meta, only used when creating a new task
|
listId: IList['id'] // Meta, only used when creating a new task
|
||||||
bucketId: IBucket['id']
|
bucketId: IBucket['id']
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ITaskPartialWithId = PartialWithId<ITask>
|
@ -1,5 +1,5 @@
|
|||||||
|
|
||||||
import {PRIORITIES, type Priority} from '@/constants/priorities'
|
import {PRIORITIES, type Priority} from '@/constants/priorities'
|
||||||
|
import {SECONDS_A_DAY, SECONDS_A_HOUR, SECONDS_A_MONTH, SECONDS_A_WEEK, SECONDS_A_YEAR} from '@/constants/date'
|
||||||
|
|
||||||
import type {ITask} from '@/modelTypes/ITask'
|
import type {ITask} from '@/modelTypes/ITask'
|
||||||
import type {ILabel} from '@/modelTypes/ILabel'
|
import type {ILabel} from '@/modelTypes/ILabel'
|
||||||
@ -10,10 +10,10 @@ import type {ISubscription} from '@/modelTypes/ISubscription'
|
|||||||
import type {IBucket} from '@/modelTypes/IBucket'
|
import type {IBucket} from '@/modelTypes/IBucket'
|
||||||
|
|
||||||
import type {IRepeatAfter} from '@/types/IRepeatAfter'
|
import type {IRepeatAfter} from '@/types/IRepeatAfter'
|
||||||
|
import type {IRelationKind} from '@/types/IRelationKind'
|
||||||
import {TASK_REPEAT_MODES, type IRepeatMode} from '@/types/IRepeatMode'
|
import {TASK_REPEAT_MODES, type IRepeatMode} from '@/types/IRepeatMode'
|
||||||
|
|
||||||
import {parseDateOrNull} from '@/helpers/parseDateOrNull'
|
import {parseDateOrNull} from '@/helpers/parseDateOrNull'
|
||||||
import type { IRelationKind } from '@/types/IRelationKind'
|
|
||||||
|
|
||||||
import AbstractModel from './abstractModel'
|
import AbstractModel from './abstractModel'
|
||||||
import LabelModel from './label'
|
import LabelModel from './label'
|
||||||
@ -36,6 +36,27 @@ export function getHexColor(hexColor: string): string {
|
|||||||
return hexColor
|
return hexColor
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses `repeatAfterSeconds` into a usable js object.
|
||||||
|
*/
|
||||||
|
export function parseRepeatAfter(repeatAfterSeconds: number): IRepeatAfter {
|
||||||
|
let repeatAfter: IRepeatAfter = {type: 'hours', amount: repeatAfterSeconds / SECONDS_A_HOUR}
|
||||||
|
|
||||||
|
// if its dividable by 24, its something with days, otherwise hours
|
||||||
|
if (repeatAfterSeconds % SECONDS_A_DAY === 0) {
|
||||||
|
if (repeatAfterSeconds % SECONDS_A_WEEK === 0) {
|
||||||
|
repeatAfter = {type: 'weeks', amount: repeatAfterSeconds / SECONDS_A_WEEK}
|
||||||
|
} else if (repeatAfterSeconds % SECONDS_A_MONTH === 0) {
|
||||||
|
repeatAfter = {type:'months', amount: repeatAfterSeconds / SECONDS_A_MONTH}
|
||||||
|
} else if (repeatAfterSeconds % SECONDS_A_YEAR === 0) {
|
||||||
|
repeatAfter = {type: 'years', amount: repeatAfterSeconds / SECONDS_A_YEAR}
|
||||||
|
} else {
|
||||||
|
repeatAfter = {type: 'days', amount: repeatAfterSeconds / SECONDS_A_DAY}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return repeatAfter
|
||||||
|
}
|
||||||
|
|
||||||
export default class TaskModel extends AbstractModel<ITask> implements ITask {
|
export default class TaskModel extends AbstractModel<ITask> implements ITask {
|
||||||
id = 0
|
id = 0
|
||||||
title = ''
|
title = ''
|
||||||
@ -95,7 +116,7 @@ export default class TaskModel extends AbstractModel<ITask> implements ITask {
|
|||||||
this.endDate = parseDateOrNull(this.endDate)
|
this.endDate = parseDateOrNull(this.endDate)
|
||||||
|
|
||||||
// Parse the repeat after into something usable
|
// Parse the repeat after into something usable
|
||||||
this.parseRepeatAfter()
|
this.repeatAfter = parseRepeatAfter(this.repeatAfter as number)
|
||||||
|
|
||||||
this.reminderDates = this.reminderDates.map(d => new Date(d))
|
this.reminderDates = this.reminderDates.map(d => new Date(d))
|
||||||
|
|
||||||
@ -151,33 +172,6 @@ export default class TaskModel extends AbstractModel<ITask> implements ITask {
|
|||||||
// Helper functions
|
// Helper functions
|
||||||
///////////////
|
///////////////
|
||||||
|
|
||||||
/**
|
|
||||||
* Parses the "repeat after x seconds" from the task into a usable js object inside the task.
|
|
||||||
* This function should only be called from the constructor.
|
|
||||||
*/
|
|
||||||
parseRepeatAfter() {
|
|
||||||
const repeatAfterHours = (this.repeatAfter as number / 60) / 60
|
|
||||||
this.repeatAfter = {type: 'hours', amount: repeatAfterHours}
|
|
||||||
|
|
||||||
// if its dividable by 24, its something with days, otherwise hours
|
|
||||||
if (repeatAfterHours % 24 === 0) {
|
|
||||||
const repeatAfterDays = repeatAfterHours / 24
|
|
||||||
if (repeatAfterDays % 7 === 0) {
|
|
||||||
this.repeatAfter.type = 'weeks'
|
|
||||||
this.repeatAfter.amount = repeatAfterDays / 7
|
|
||||||
} else if (repeatAfterDays % 30 === 0) {
|
|
||||||
this.repeatAfter.type = 'months'
|
|
||||||
this.repeatAfter.amount = repeatAfterDays / 30
|
|
||||||
} else if (repeatAfterDays % 365 === 0) {
|
|
||||||
this.repeatAfter.type = 'years'
|
|
||||||
this.repeatAfter.amount = repeatAfterDays / 365
|
|
||||||
} else {
|
|
||||||
this.repeatAfter.type = 'days'
|
|
||||||
this.repeatAfter.amount = repeatAfterDays
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async cancelScheduledNotifications() {
|
async cancelScheduledNotifications() {
|
||||||
if (!SUPPORTS_TRIGGERED_NOTIFICATION) {
|
if (!SUPPORTS_TRIGGERED_NOTIFICATION) {
|
||||||
return
|
return
|
||||||
|
@ -1,9 +1,10 @@
|
|||||||
import {beforeEach, afterEach, describe, it, expect, vi} from 'vitest'
|
import {beforeEach, afterEach, describe, it, expect, vi} from 'vitest'
|
||||||
|
|
||||||
import {parseTaskText, PrefixMode} from './parseTaskText'
|
import {parseTaskText, PrefixMode} from './parseTaskText'
|
||||||
import {getDateFromText, getDateFromTextIn, parseDate} from '../helpers/time/parseDate'
|
import {getDateFromText, parseDate} from '../helpers/time/parseDate'
|
||||||
import {calculateDayInterval} from '../helpers/time/calculateDayInterval'
|
import {calculateDayInterval} from '../helpers/time/calculateDayInterval'
|
||||||
import {PRIORITIES} from '@/constants/priorities'
|
import {PRIORITIES} from '@/constants/priorities'
|
||||||
|
import { MILLISECONDS_A_DAY } from '@/constants/date'
|
||||||
|
|
||||||
describe('Parse Task Text', () => {
|
describe('Parse Task Text', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@ -296,7 +297,7 @@ describe('Parse Task Text', () => {
|
|||||||
expect(result.date.getMonth()).toBe(expectedDate.getMonth())
|
expect(result.date.getMonth()).toBe(expectedDate.getMonth())
|
||||||
})
|
})
|
||||||
it('should recognize dates of the month in the future', () => {
|
it('should recognize dates of the month in the future', () => {
|
||||||
const nextDay = new Date(+new Date() + 60 * 60 * 24 * 1000)
|
const nextDay = new Date(+new Date() + MILLISECONDS_A_DAY)
|
||||||
const result = parseTaskText(`Lorem Ipsum ${nextDay.getDate()}nd`)
|
const result = parseTaskText(`Lorem Ipsum ${nextDay.getDate()}nd`)
|
||||||
|
|
||||||
expect(result.text).toBe('Lorem Ipsum')
|
expect(result.text).toBe('Lorem Ipsum')
|
||||||
|
@ -35,7 +35,7 @@ import MigrationComponent from '../views/migrator/Migrate.vue'
|
|||||||
import MigrateServiceComponent from '../views/migrator/MigrateService.vue'
|
import MigrateServiceComponent from '../views/migrator/MigrateService.vue'
|
||||||
// List Views
|
// List Views
|
||||||
import ListList from '../views/list/ListList.vue'
|
import ListList from '../views/list/ListList.vue'
|
||||||
import ListGantt from '../views/list/ListGantt.vue'
|
const ListGantt = () => import('../views/list/ListGantt.vue')
|
||||||
import ListTable from '../views/list/ListTable.vue'
|
import ListTable from '../views/list/ListTable.vue'
|
||||||
import ListKanban from '../views/list/ListKanban.vue'
|
import ListKanban from '../views/list/ListKanban.vue'
|
||||||
const ListInfo = () => import('../views/list/ListInfo.vue')
|
const ListInfo = () => import('../views/list/ListInfo.vue')
|
||||||
@ -379,7 +379,8 @@ const router = createRouter({
|
|||||||
name: 'list.gantt',
|
name: 'list.gantt',
|
||||||
component: ListGantt,
|
component: ListGantt,
|
||||||
beforeEnter: (to) => saveListView(to.params.listId, to.name),
|
beforeEnter: (to) => saveListView(to.params.listId, to.name),
|
||||||
props: route => ({ listId: Number(route.params.listId as string) }),
|
// FIXME: test if `useRoute` would be the same. If it would use it instead.
|
||||||
|
props: route => ({route}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/lists/:listId/table',
|
path: '/lists/:listId/table',
|
||||||
|
@ -2,7 +2,8 @@ import {AuthenticatedHTTPFactory} from '@/http-common'
|
|||||||
import type {Method} from 'axios'
|
import type {Method} from 'axios'
|
||||||
|
|
||||||
import {objectToSnakeCase} from '@/helpers/case'
|
import {objectToSnakeCase} from '@/helpers/case'
|
||||||
import AbstractModel, { type IAbstract } from '@/models/abstractModel'
|
import AbstractModel from '@/models/abstractModel'
|
||||||
|
import type {IAbstract} from '@/modelTypes/IAbstract'
|
||||||
import type {Right} from '@/constants/rights'
|
import type {Right} from '@/constants/rights'
|
||||||
import type {IFile} from '@/modelTypes/IFile'
|
import type {IFile} from '@/modelTypes/IFile'
|
||||||
|
|
||||||
|
@ -6,6 +6,7 @@ import LabelService from './label'
|
|||||||
|
|
||||||
import {formatISO} from 'date-fns'
|
import {formatISO} from 'date-fns'
|
||||||
import {colorFromHex} from '@/helpers/color/colorFromHex'
|
import {colorFromHex} from '@/helpers/color/colorFromHex'
|
||||||
|
import {SECONDS_A_DAY, SECONDS_A_HOUR, SECONDS_A_WEEK, SECONDS_A_MONTH, SECONDS_A_YEAR} from '@/constants/date'
|
||||||
|
|
||||||
const parseDate = date => {
|
const parseDate = date => {
|
||||||
if (date) {
|
if (date) {
|
||||||
@ -73,19 +74,19 @@ export default class TaskService extends AbstractService<ITask> {
|
|||||||
if (model.repeatAfter !== null && (model.repeatAfter.amount !== null || model.repeatAfter.amount !== 0)) {
|
if (model.repeatAfter !== null && (model.repeatAfter.amount !== null || model.repeatAfter.amount !== 0)) {
|
||||||
switch (model.repeatAfter.type) {
|
switch (model.repeatAfter.type) {
|
||||||
case 'hours':
|
case 'hours':
|
||||||
repeatAfterSeconds = model.repeatAfter.amount * 60 * 60
|
repeatAfterSeconds = model.repeatAfter.amount * SECONDS_A_HOUR
|
||||||
break
|
break
|
||||||
case 'days':
|
case 'days':
|
||||||
repeatAfterSeconds = model.repeatAfter.amount * 60 * 60 * 24
|
repeatAfterSeconds = model.repeatAfter.amount * SECONDS_A_DAY
|
||||||
break
|
break
|
||||||
case 'weeks':
|
case 'weeks':
|
||||||
repeatAfterSeconds = model.repeatAfter.amount * 60 * 60 * 24 * 7
|
repeatAfterSeconds = model.repeatAfter.amount * SECONDS_A_WEEK
|
||||||
break
|
break
|
||||||
case 'months':
|
case 'months':
|
||||||
repeatAfterSeconds = model.repeatAfter.amount * 60 * 60 * 24 * 30
|
repeatAfterSeconds = model.repeatAfter.amount * SECONDS_A_MONTH
|
||||||
break
|
break
|
||||||
case 'years':
|
case 'years':
|
||||||
repeatAfterSeconds = model.repeatAfter.amount * 60 * 60 * 24 * 365
|
repeatAfterSeconds = model.repeatAfter.amount * SECONDS_A_YEAR
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,22 @@
|
|||||||
import AbstractService from './abstractService'
|
|
||||||
import TaskModel from '../models/task'
|
|
||||||
import {formatISO} from 'date-fns'
|
import {formatISO} from 'date-fns'
|
||||||
|
|
||||||
export default class TaskCollectionService extends AbstractService {
|
import AbstractService from '@/services/abstractService'
|
||||||
|
import TaskModel from '@/models/task'
|
||||||
|
|
||||||
|
import type {ITask} from '@/modelTypes/ITask'
|
||||||
|
|
||||||
|
// FIXME: unite with other filter params types
|
||||||
|
export interface GetAllTasksParams {
|
||||||
|
sort_by: ('start_date' | 'done' | 'id')[],
|
||||||
|
order_by: ('asc' | 'asc' | 'desc')[],
|
||||||
|
filter_by: 'start_date'[],
|
||||||
|
filter_comparator: ('greater_equals' | 'less_equals')[],
|
||||||
|
filter_value: [string, string] // [dateFrom, dateTo],
|
||||||
|
filter_concat: 'and',
|
||||||
|
filter_include_nulls: boolean,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class TaskCollectionService extends AbstractService<ITask> {
|
||||||
constructor() {
|
constructor() {
|
||||||
super({
|
super({
|
||||||
getAll: '/lists/{listId}/tasks',
|
getAll: '/lists/{listId}/tasks',
|
||||||
|
@ -14,6 +14,7 @@ import type {IUserSettings} from '@/modelTypes/IUserSettings'
|
|||||||
import router from '@/router'
|
import router from '@/router'
|
||||||
import {useConfigStore} from '@/stores/config'
|
import {useConfigStore} from '@/stores/config'
|
||||||
import UserSettingsModel from '@/models/userSettings'
|
import UserSettingsModel from '@/models/userSettings'
|
||||||
|
import {MILLISECONDS_A_SECOND} from '@/constants/date'
|
||||||
|
|
||||||
export interface AuthState {
|
export interface AuthState {
|
||||||
authenticated: boolean,
|
authenticated: boolean,
|
||||||
@ -133,8 +134,10 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Registers a new user and logs them in.
|
/**
|
||||||
// Not sure if this is the right place to put the logic in, maybe a seperate js component would be better suited.
|
* Registers a new user and logs them in.
|
||||||
|
* Not sure if this is the right place to put the logic in, maybe a seperate js component would be better suited.
|
||||||
|
*/
|
||||||
async register(credentials) {
|
async register(credentials) {
|
||||||
const HTTP = HTTPFactory()
|
const HTTP = HTTPFactory()
|
||||||
this.setIsLoading(true)
|
this.setIsLoading(true)
|
||||||
@ -184,14 +187,17 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
// Populates user information from jwt token saved in local storage in store
|
/**
|
||||||
|
* Populates user information from jwt token saved in local storage in store
|
||||||
|
*/
|
||||||
async checkAuth() {
|
async checkAuth() {
|
||||||
|
const now = new Date()
|
||||||
|
const inOneMinute = new Date(new Date().setMinutes(now.getMinutes() + 1))
|
||||||
// This function can be called from multiple places at the same time and shortly after one another.
|
// This function can be called from multiple places at the same time and shortly after one another.
|
||||||
// To prevent hitting the api too frequently or race conditions, we check at most once per minute.
|
// To prevent hitting the api too frequently or race conditions, we check at most once per minute.
|
||||||
if (
|
if (
|
||||||
this.lastUserInfoRefresh !== null &&
|
this.lastUserInfoRefresh !== null &&
|
||||||
this.lastUserInfoRefresh > (new Date()).setMinutes((new Date()).getMinutes() + 1)
|
this.lastUserInfoRefresh > inOneMinute
|
||||||
) {
|
) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -204,7 +210,7 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
.replace('-', '+')
|
.replace('-', '+')
|
||||||
.replace('_', '/')
|
.replace('_', '/')
|
||||||
const info = new UserModel(JSON.parse(atob(base64)))
|
const info = new UserModel(JSON.parse(atob(base64)))
|
||||||
const ts = Math.round((new Date()).getTime() / 1000)
|
const ts = Math.round((new Date()).getTime() / MILLISECONDS_A_SECOND)
|
||||||
authenticated = info.exp >= ts
|
authenticated = info.exp >= ts
|
||||||
this.setUser(info)
|
this.setUser(info)
|
||||||
|
|
||||||
@ -282,9 +288,8 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Try to verify the email
|
* Try to verify the email
|
||||||
* @returns {Promise<boolean>} if the email was successfully confirmed
|
|
||||||
*/
|
*/
|
||||||
async verifyEmail() {
|
async verifyEmail(): Promise<boolean> {
|
||||||
const emailVerifyToken = localStorage.getItem('emailConfirmToken')
|
const emailVerifyToken = localStorage.getItem('emailConfirmToken')
|
||||||
if (emailVerifyToken) {
|
if (emailVerifyToken) {
|
||||||
const stopLoading = setModuleLoading(this)
|
const stopLoading = setModuleLoading(this)
|
||||||
@ -325,7 +330,9 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Renews the api token and saves it to local storage
|
/**
|
||||||
|
* Renews the api token and saves it to local storage
|
||||||
|
*/
|
||||||
renewToken() {
|
renewToken() {
|
||||||
// FIXME: Timeout to avoid race conditions when authenticated as a user (=auth token in localStorage) and as a
|
// FIXME: Timeout to avoid race conditions when authenticated as a user (=auth token in localStorage) and as a
|
||||||
// link share in another tab. Without the timeout both the token renew and link share auth are executed at
|
// link share in another tab. Without the timeout both the token renew and link share auth are executed at
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
// FIXME: should be a component <FilterContainer>
|
// FIXME: should be a component <FilterContainer>
|
||||||
// used in
|
// used in
|
||||||
// - gantt-component.vue
|
|
||||||
// - Kanban.vue
|
// - Kanban.vue
|
||||||
// - List.vue
|
// - List.vue
|
||||||
// - Table.vue
|
// - Table.vue
|
||||||
|
@ -46,7 +46,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// FIXME: is only used where <edit-task> is used aswell:
|
// FIXME: is only used where <edit-task> is used aswell:
|
||||||
// - gantt-component.vue
|
|
||||||
// - List.vue
|
// - List.vue
|
||||||
// -> Move the <card> wrapper including this class definition inside <edit-task>
|
// -> Move the <card> wrapper including this class definition inside <edit-task>
|
||||||
.is-max-width-desktop .tasks .task {
|
.is-max-width-desktop .tasks .task {
|
||||||
|
7
src/types/DateISO.ts
Normal file
7
src/types/DateISO.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* Returns a date as a string value in ISO format.
|
||||||
|
* same format as `new Date().toISOString()`
|
||||||
|
*/
|
||||||
|
export type DateISO<T extends string = string> = T
|
||||||
|
|
||||||
|
new Date().toISOString()
|
4
src/types/DateKebab.ts
Normal file
4
src/types/DateKebab.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
/**
|
||||||
|
* Date in Format 2022-12-10
|
||||||
|
*/
|
||||||
|
export type DateKebab = `${string}-${string}-${string}`
|
1
src/types/PartialWithId.ts
Normal file
1
src/types/PartialWithId.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export type PartialWithId<T extends { id: unknown }> = Pick<T, 'id'> & Omit<Partial<T>, 'id'>
|
@ -1,47 +1,30 @@
|
|||||||
<template>
|
<template>
|
||||||
<ListWrapper class="list-gantt" :list-id="props.listId" viewName="gantt">
|
<ListWrapper class="list-gantt" :list-id="filters.listId" viewName="gantt">
|
||||||
<template #header>
|
<template #header>
|
||||||
<card class="gantt-options">
|
<card>
|
||||||
<fancycheckbox class="is-block" v-model="showTaskswithoutDates">
|
<div class="gantt-options">
|
||||||
|
<div class="field">
|
||||||
|
<label class="label" for="range">{{ $t('list.gantt.range') }}</label>
|
||||||
|
<div class="control">
|
||||||
|
<Foo
|
||||||
|
ref="flatPickerEl"
|
||||||
|
:config="flatPickerConfig"
|
||||||
|
class="input"
|
||||||
|
id="range"
|
||||||
|
:placeholder="$t('list.gantt.range')"
|
||||||
|
v-model="flatPickerDateRange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field" v-if="!hasDefaultFilters">
|
||||||
|
<label class="label" for="range">Reset</label>
|
||||||
|
<div class="control">
|
||||||
|
<x-button @click="setDefaultFilters">Reset</x-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<fancycheckbox class="is-block" v-model="filters.showTasksWithoutDates">
|
||||||
{{ $t('list.gantt.showTasksWithoutDates') }}
|
{{ $t('list.gantt.showTasksWithoutDates') }}
|
||||||
</fancycheckbox>
|
</fancycheckbox>
|
||||||
<div class="range-picker">
|
|
||||||
<div class="field">
|
|
||||||
<label class="label" for="dayWidth">{{ $t('list.gantt.size') }}</label>
|
|
||||||
<div class="control">
|
|
||||||
<div class="select">
|
|
||||||
<select id="dayWidth" v-model.number="dayWidth">
|
|
||||||
<option value="35">{{ $t('list.gantt.default') }}</option>
|
|
||||||
<option value="10">{{ $t('list.gantt.month') }}</option>
|
|
||||||
<option value="80">{{ $t('list.gantt.day') }}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label class="label" for="fromDate">{{ $t('list.gantt.from') }}</label>
|
|
||||||
<div class="control">
|
|
||||||
<flat-pickr
|
|
||||||
:config="flatPickerConfig"
|
|
||||||
class="input"
|
|
||||||
id="fromDate"
|
|
||||||
:placeholder="$t('list.gantt.from')"
|
|
||||||
v-model="dateFrom"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label class="label" for="toDate">{{ $t('list.gantt.to') }}</label>
|
|
||||||
<div class="control">
|
|
||||||
<flat-pickr
|
|
||||||
:config="flatPickerConfig"
|
|
||||||
class="input"
|
|
||||||
id="toDate"
|
|
||||||
:placeholder="$t('list.gantt.to')"
|
|
||||||
v-model="dateTo"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</card>
|
</card>
|
||||||
</template>
|
</template>
|
||||||
@ -49,15 +32,15 @@
|
|||||||
<template #default>
|
<template #default>
|
||||||
<div class="gantt-chart-container">
|
<div class="gantt-chart-container">
|
||||||
<card :padding="false" class="has-overflow">
|
<card :padding="false" class="has-overflow">
|
||||||
|
|
||||||
<gantt-chart
|
<gantt-chart
|
||||||
:date-from="dateFrom"
|
:filters="filters"
|
||||||
:date-to="dateTo"
|
:tasks="tasks"
|
||||||
:day-width="dayWidth"
|
:isLoading="isLoading"
|
||||||
:list-id="props.listId"
|
:default-task-start-date="defaultTaskStartDate"
|
||||||
:show-taskswithout-dates="showTaskswithoutDates"
|
:default-task-end-date="defaultTaskEndDate"
|
||||||
|
@update:task="updateTask"
|
||||||
/>
|
/>
|
||||||
|
<TaskForm v-if="canWrite" @create-task="addGanttTask" />
|
||||||
</card>
|
</card>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -65,46 +48,92 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {ref, computed} from 'vue'
|
import {computed, ref, toRefs} from 'vue'
|
||||||
import flatPickr from 'vue-flatpickr-component'
|
import type Flatpickr from 'flatpickr'
|
||||||
import {useI18n} from 'vue-i18n'
|
import {useI18n} from 'vue-i18n'
|
||||||
|
import type {RouteLocationNormalized} from 'vue-router'
|
||||||
|
|
||||||
|
import {useBaseStore} from '@/stores/base'
|
||||||
import {useAuthStore} from '@/stores/auth'
|
import {useAuthStore} from '@/stores/auth'
|
||||||
|
|
||||||
|
import Foo from '@/components/misc/flatpickr/Flatpickr.vue'
|
||||||
import ListWrapper from './ListWrapper.vue'
|
import ListWrapper from './ListWrapper.vue'
|
||||||
import GanttChart from '@/components/tasks/gantt-component.vue'
|
|
||||||
import Fancycheckbox from '@/components/input/fancycheckbox.vue'
|
import Fancycheckbox from '@/components/input/fancycheckbox.vue'
|
||||||
|
import TaskForm from '@/components/tasks/TaskForm.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
import {createAsyncComponent} from '@/helpers/createAsyncComponent'
|
||||||
listId: {
|
import {useGanttFilters} from './helpers/useGanttFilters'
|
||||||
type: Number,
|
import {RIGHTS} from '@/constants/rights'
|
||||||
required: true,
|
|
||||||
|
import type {DateISO} from '@/types/DateISO'
|
||||||
|
import type {ITask} from '@/modelTypes/ITask'
|
||||||
|
|
||||||
|
type Options = Flatpickr.Options.Options
|
||||||
|
|
||||||
|
const GanttChart = createAsyncComponent(() => import('@/components/tasks/GanttChart.vue'))
|
||||||
|
|
||||||
|
const props = defineProps<{route: RouteLocationNormalized}>()
|
||||||
|
|
||||||
|
const baseStore = useBaseStore()
|
||||||
|
const canWrite = computed(() => baseStore.currentList.maxRight > RIGHTS.READ)
|
||||||
|
|
||||||
|
const {route} = toRefs(props)
|
||||||
|
const {
|
||||||
|
filters,
|
||||||
|
hasDefaultFilters,
|
||||||
|
setDefaultFilters,
|
||||||
|
tasks,
|
||||||
|
isLoading,
|
||||||
|
addTask,
|
||||||
|
updateTask,
|
||||||
|
} = useGanttFilters(route)
|
||||||
|
|
||||||
|
const today = new Date(new Date().setHours(0,0,0,0))
|
||||||
|
const defaultTaskStartDate: DateISO = new Date(today).toISOString()
|
||||||
|
const defaultTaskEndDate: DateISO = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 7, 23,59,0,0).toISOString()
|
||||||
|
|
||||||
|
async function addGanttTask(title: ITask['title']) {
|
||||||
|
return await addTask({
|
||||||
|
title,
|
||||||
|
listId: filters.value.listId,
|
||||||
|
startDate: defaultTaskStartDate,
|
||||||
|
endDate: defaultTaskEndDate,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const flatPickerEl = ref<typeof Foo | null>(null)
|
||||||
|
const flatPickerDateRange = computed<Date[]>({
|
||||||
|
get: () => ([
|
||||||
|
new Date(filters.value.dateFrom),
|
||||||
|
new Date(filters.value.dateTo),
|
||||||
|
]),
|
||||||
|
set(newVal) {
|
||||||
|
const [dateFrom, dateTo] = newVal.map((date) => date?.toISOString())
|
||||||
|
|
||||||
|
// only set after whole range has been selected
|
||||||
|
if (!dateTo) return
|
||||||
|
|
||||||
|
Object.assign(filters.value, {dateFrom, dateTo})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const DEFAULT_DAY_COUNT = 35
|
const initialDateRange = [filters.value.dateFrom, filters.value.dateTo]
|
||||||
|
|
||||||
const showTaskswithoutDates = ref(false)
|
|
||||||
const dayWidth = ref(DEFAULT_DAY_COUNT)
|
|
||||||
|
|
||||||
const now = ref(new Date())
|
|
||||||
const dateFrom = ref(new Date((new Date()).setDate(now.value.getDate() - 15)))
|
|
||||||
const dateTo = ref(new Date((new Date()).setDate(now.value.getDate() + 30)))
|
|
||||||
|
|
||||||
const {t} = useI18n({useScope: 'global'})
|
const {t} = useI18n({useScope: 'global'})
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const flatPickerConfig = computed(() => ({
|
const flatPickerConfig = computed<Options>(() => ({
|
||||||
altFormat: t('date.altFormatShort'),
|
altFormat: t('date.altFormatShort'),
|
||||||
altInput: true,
|
altInput: true,
|
||||||
dateFormat: 'Y-m-d',
|
defaultDate: initialDateRange,
|
||||||
enableTime: false,
|
enableTime: false,
|
||||||
|
mode: 'range',
|
||||||
locale: {
|
locale: {
|
||||||
firstDayOfWeek: authStore.settings.weekStart,
|
firstDayOfWeek: authStore.settings.weekStart,
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss" scoped>
|
||||||
.gantt-chart-container {
|
.gantt-chart-container {
|
||||||
padding-bottom: 1rem;
|
padding-bottom: 1rem;
|
||||||
}
|
}
|
||||||
@ -118,15 +147,15 @@ const flatPickerConfig = computed(() => ({
|
|||||||
@media screen and (max-width: $tablet) {
|
@media screen and (max-width: $tablet) {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.range-picker {
|
:global(.link-share-view:not(.has-background)) .gantt-options {
|
||||||
display: flex;
|
border: none;
|
||||||
margin-bottom: 1rem;
|
box-shadow: none;
|
||||||
width: 50%;
|
|
||||||
|
|
||||||
@media screen and (max-width: $tablet) {
|
.card-content {
|
||||||
flex-direction: column;
|
padding: .5rem;
|
||||||
width: 100%;
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.field {
|
.field {
|
||||||
@ -148,32 +177,15 @@ const flatPickerConfig = computed(() => ({
|
|||||||
font-size: .8rem;
|
font-size: .8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.select, .select select {
|
.select,
|
||||||
|
.select select {
|
||||||
height: auto;
|
height: auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
font-size: .8rem;
|
font-size: .8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
font-size: .9rem;
|
font-size: .9rem;
|
||||||
padding-left: .4rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// vue-draggable overwrites
|
|
||||||
.vdr.active::before {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-share-view:not(.has-background) .card.gantt-options {
|
|
||||||
border: none;
|
|
||||||
box-shadow: none;
|
|
||||||
|
|
||||||
.card-content {
|
|
||||||
padding: .5rem;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
@ -61,7 +61,6 @@ import {useTitle} from '@/composables/useTitle'
|
|||||||
|
|
||||||
import {useBaseStore} from '@/stores/base'
|
import {useBaseStore} from '@/stores/base'
|
||||||
import {useListStore} from '@/stores/lists'
|
import {useListStore} from '@/stores/lists'
|
||||||
import {useKanbanStore} from '@/stores/kanban'
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
listId: {
|
listId: {
|
||||||
@ -77,7 +76,6 @@ const props = defineProps({
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
const baseStore = useBaseStore()
|
const baseStore = useBaseStore()
|
||||||
const kanbanStore = useKanbanStore()
|
|
||||||
const listStore = useListStore()
|
const listStore = useListStore()
|
||||||
const listService = ref(new ListService())
|
const listService = ref(new ListService())
|
||||||
const loadedListId = ref(0)
|
const loadedListId = ref(0)
|
||||||
@ -90,6 +88,7 @@ const currentList = computed(() => {
|
|||||||
maxRight: null,
|
maxRight: null,
|
||||||
} : baseStore.currentList
|
} : 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.
|
// 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 resulted in loading and setting the list multiple times, even when navigating away from it.
|
||||||
@ -98,28 +97,11 @@ const currentList = computed(() => {
|
|||||||
// of it, most likely due to the rights not being properly populated.
|
// of it, most likely due to the rights not being properly populated.
|
||||||
watch(
|
watch(
|
||||||
() => props.listId,
|
() => props.listId,
|
||||||
listId => loadList(listId),
|
// loadList
|
||||||
{immediate: true},
|
async (listIdToLoad: number) => {
|
||||||
)
|
|
||||||
|
|
||||||
useTitle(() => currentList.value.id ? getListTitle(currentList.value) : '')
|
|
||||||
|
|
||||||
async function loadList(listIdToLoad: number) {
|
|
||||||
const listData = {id: listIdToLoad}
|
const listData = {id: listIdToLoad}
|
||||||
saveListToHistory(listData)
|
saveListToHistory(listData)
|
||||||
|
|
||||||
// This invalidates the loaded list at the kanban board which lets it reload its content when
|
|
||||||
// switched to it. This ensures updates done to tasks in the gantt or list views are consistently
|
|
||||||
// shown in all views while preventing reloads when closing a task popup.
|
|
||||||
// We don't do this for the table view because that does not change tasks.
|
|
||||||
// FIXME: remove this
|
|
||||||
if (
|
|
||||||
props.viewName === 'list.list' ||
|
|
||||||
props.viewName === 'list.gantt'
|
|
||||||
) {
|
|
||||||
kanbanStore.setListId(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't load the list if we either already loaded it or aren't dealing with a list at all currently and
|
// 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.
|
// the currently loaded list has the right set.
|
||||||
if (
|
if (
|
||||||
@ -153,7 +135,9 @@ async function loadList(listIdToLoad: number) {
|
|||||||
} finally {
|
} finally {
|
||||||
loadedListId.value = props.listId
|
loadedListId.value = props.listId
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
{immediate: true},
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
127
src/views/list/helpers/useGanttFilters.ts
Normal file
127
src/views/list/helpers/useGanttFilters.ts
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
import type {Ref} from 'vue'
|
||||||
|
import type {RouteLocationNormalized, RouteLocationRaw} from 'vue-router'
|
||||||
|
|
||||||
|
import {isoToKebabDate} from '@/helpers/time/isoToKebabDate'
|
||||||
|
import {parseDateProp} from '@/helpers/time/parseDateProp'
|
||||||
|
import {parseBooleanProp} from '@/helpers/time/parseBooleanProp'
|
||||||
|
import {useRouteFilters} from '@/composables/useRouteFilters'
|
||||||
|
import {useGanttTaskList} from './useGanttTaskList'
|
||||||
|
|
||||||
|
import type {IList} from '@/modelTypes/IList'
|
||||||
|
import type {GetAllTasksParams} from '@/services/taskCollection'
|
||||||
|
|
||||||
|
import type {DateISO} from '@/types/DateISO'
|
||||||
|
import type {DateKebab} from '@/types/DateKebab'
|
||||||
|
|
||||||
|
// convenient internal filter object
|
||||||
|
export interface GanttFilters {
|
||||||
|
listId: IList['id']
|
||||||
|
dateFrom: DateISO
|
||||||
|
dateTo: DateISO
|
||||||
|
showTasksWithoutDates: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_SHOW_TASKS_WITHOUT_DATES = false
|
||||||
|
|
||||||
|
const DEFAULT_DATEFROM_DAY_OFFSET = -15
|
||||||
|
const DEFAULT_DATETO_DAY_OFFSET = +55
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
|
function getDefaultDateFrom() {
|
||||||
|
return new Date(now.getFullYear(), now.getMonth(), now.getDate() + DEFAULT_DATEFROM_DAY_OFFSET).toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDefaultDateTo() {
|
||||||
|
return new Date(now.getFullYear(), now.getMonth(), now.getDate() + DEFAULT_DATETO_DAY_OFFSET).toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
// FIXME: use zod for this
|
||||||
|
function ganttRouteToFilters(route: Partial<RouteLocationNormalized>): GanttFilters {
|
||||||
|
const ganttRoute = route
|
||||||
|
return {
|
||||||
|
listId: Number(ganttRoute.params?.listId),
|
||||||
|
dateFrom: parseDateProp(ganttRoute.query?.dateFrom as DateKebab) || getDefaultDateFrom(),
|
||||||
|
dateTo: parseDateProp(ganttRoute.query?.dateTo as DateKebab) || getDefaultDateTo(),
|
||||||
|
showTasksWithoutDates: parseBooleanProp(ganttRoute.query?.showTasksWithoutDates as string) || DEFAULT_SHOW_TASKS_WITHOUT_DATES,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ganttGetDefaultFilters(route: Partial<RouteLocationNormalized>): GanttFilters {
|
||||||
|
return ganttRouteToFilters({params: {listId: route.params?.listId as string}})
|
||||||
|
}
|
||||||
|
|
||||||
|
// FIXME: use zod for this
|
||||||
|
function ganttFiltersToRoute(filters: GanttFilters): RouteLocationRaw {
|
||||||
|
let query: Record<string, string> = {}
|
||||||
|
if (
|
||||||
|
filters.dateFrom !== getDefaultDateFrom() ||
|
||||||
|
filters.dateTo !== getDefaultDateTo()
|
||||||
|
) {
|
||||||
|
query = {
|
||||||
|
dateFrom: isoToKebabDate(filters.dateFrom),
|
||||||
|
dateTo: isoToKebabDate(filters.dateTo),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.showTasksWithoutDates) {
|
||||||
|
query.showTasksWithoutDates = String(filters.showTasksWithoutDates)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: 'list.gantt',
|
||||||
|
params: {listId: filters.listId},
|
||||||
|
query,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ganttFiltersToApiParams(filters: GanttFilters): GetAllTasksParams {
|
||||||
|
return {
|
||||||
|
sort_by: ['start_date', 'done', 'id'],
|
||||||
|
order_by: ['asc', 'asc', 'desc'],
|
||||||
|
filter_by: ['start_date', 'start_date'],
|
||||||
|
filter_comparator: ['greater_equals', 'less_equals'],
|
||||||
|
filter_value: [isoToKebabDate(filters.dateFrom), isoToKebabDate(filters.dateTo)],
|
||||||
|
filter_concat: 'and',
|
||||||
|
filter_include_nulls: filters.showTasksWithoutDates,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UseGanttFiltersReturn =
|
||||||
|
ReturnType<typeof useRouteFilters<GanttFilters>> &
|
||||||
|
ReturnType<typeof useGanttTaskList<GanttFilters>>
|
||||||
|
|
||||||
|
export function useGanttFilters(route: Ref<RouteLocationNormalized>): UseGanttFiltersReturn {
|
||||||
|
const {
|
||||||
|
filters,
|
||||||
|
hasDefaultFilters,
|
||||||
|
setDefaultFilters,
|
||||||
|
} = useRouteFilters<GanttFilters>(
|
||||||
|
route,
|
||||||
|
ganttRouteToFilters,
|
||||||
|
ganttGetDefaultFilters,
|
||||||
|
ganttFiltersToRoute,
|
||||||
|
)
|
||||||
|
|
||||||
|
const {
|
||||||
|
tasks,
|
||||||
|
loadTasks,
|
||||||
|
|
||||||
|
isLoading,
|
||||||
|
addTask,
|
||||||
|
updateTask,
|
||||||
|
} = useGanttTaskList<GanttFilters>(filters, ganttFiltersToApiParams)
|
||||||
|
|
||||||
|
return {
|
||||||
|
filters,
|
||||||
|
hasDefaultFilters,
|
||||||
|
setDefaultFilters,
|
||||||
|
|
||||||
|
tasks,
|
||||||
|
loadTasks,
|
||||||
|
|
||||||
|
isLoading,
|
||||||
|
addTask,
|
||||||
|
updateTask,
|
||||||
|
}
|
||||||
|
}
|
102
src/views/list/helpers/useGanttTaskList.ts
Normal file
102
src/views/list/helpers/useGanttTaskList.ts
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
import {computed, ref, shallowReactive, watch, type Ref} from 'vue'
|
||||||
|
import cloneDeep from 'lodash.clonedeep'
|
||||||
|
|
||||||
|
import type {Filters} from '@/composables/useRouteFilters'
|
||||||
|
import type {ITask, ITaskPartialWithId} from '@/modelTypes/ITask'
|
||||||
|
|
||||||
|
import TaskCollectionService, {type GetAllTasksParams} from '@/services/taskCollection'
|
||||||
|
import TaskService from '@/services/task'
|
||||||
|
|
||||||
|
import TaskModel from '@/models/task'
|
||||||
|
import {error, success} from '@/message'
|
||||||
|
|
||||||
|
// FIXME: unify with general `useTaskList`
|
||||||
|
export function useGanttTaskList<F extends Filters>(
|
||||||
|
filters: Ref<F>,
|
||||||
|
filterToApiParams: (filters: F) => GetAllTasksParams,
|
||||||
|
options: {
|
||||||
|
loadAll?: boolean,
|
||||||
|
} = {
|
||||||
|
loadAll: true,
|
||||||
|
}) {
|
||||||
|
const taskCollectionService = shallowReactive(new TaskCollectionService())
|
||||||
|
const taskService = shallowReactive(new TaskService())
|
||||||
|
|
||||||
|
const isLoading = computed(() => taskCollectionService.loading)
|
||||||
|
|
||||||
|
const tasks = ref<Map<ITask['id'], ITask>>(new Map())
|
||||||
|
|
||||||
|
async function fetchTasks(params: GetAllTasksParams, page = 1): Promise<ITask[]> {
|
||||||
|
const tasks = await taskCollectionService.getAll({listId: filters.value.listId}, params, page) as ITask[]
|
||||||
|
if (options.loadAll && page < taskCollectionService.totalPages) {
|
||||||
|
const nextTasks = await fetchTasks(params, page + 1)
|
||||||
|
return tasks.concat(nextTasks)
|
||||||
|
}
|
||||||
|
return tasks
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load and assign new tasks
|
||||||
|
* Normally there is no need to trigger this manually
|
||||||
|
*/
|
||||||
|
async function loadTasks() {
|
||||||
|
const params: GetAllTasksParams = filterToApiParams(filters.value)
|
||||||
|
|
||||||
|
const loadedTasks = await fetchTasks(params)
|
||||||
|
tasks.value = new Map()
|
||||||
|
loadedTasks.forEach(t => tasks.value.set(t.id, t))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load tasks when filters change
|
||||||
|
*/
|
||||||
|
watch(
|
||||||
|
filters,
|
||||||
|
() => loadTasks(),
|
||||||
|
{immediate: true, deep: true},
|
||||||
|
)
|
||||||
|
|
||||||
|
async function addTask(task: Partial<ITask>) {
|
||||||
|
const newTask = await taskService.create(new TaskModel({...task}))
|
||||||
|
tasks.value.set(newTask.id, newTask)
|
||||||
|
|
||||||
|
return newTask
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateTask(task: ITaskPartialWithId) {
|
||||||
|
const oldTask = cloneDeep(tasks.value.get(task.id))
|
||||||
|
|
||||||
|
if (!oldTask) return
|
||||||
|
|
||||||
|
// we extend the task with potentially missing info
|
||||||
|
const newTask: ITask = {
|
||||||
|
...oldTask,
|
||||||
|
...task,
|
||||||
|
}
|
||||||
|
|
||||||
|
// set in expectation that server update works
|
||||||
|
tasks.value.set(newTask.id, newTask)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updatedTask = await taskService.update(newTask)
|
||||||
|
// update the task with possible changes from server
|
||||||
|
tasks.value.set(updatedTask.id, updatedTask)
|
||||||
|
success('Saved')
|
||||||
|
} catch(e: any) {
|
||||||
|
error('Something went wrong saving the task')
|
||||||
|
// roll back changes
|
||||||
|
tasks.value.set(task.id, oldTask)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return {
|
||||||
|
tasks,
|
||||||
|
|
||||||
|
isLoading,
|
||||||
|
loadTasks,
|
||||||
|
|
||||||
|
addTask,
|
||||||
|
updateTask,
|
||||||
|
}
|
||||||
|
}
|
@ -158,7 +158,7 @@ import {PrefixMode} from '@/modules/parseTaskText'
|
|||||||
|
|
||||||
import ListSearch from '@/components/tasks/partials/listSearch.vue'
|
import ListSearch from '@/components/tasks/partials/listSearch.vue'
|
||||||
|
|
||||||
import {availableLanguages} from '@/i18n'
|
import {SUPPORTED_LOCALES} from '@/i18n'
|
||||||
import {playSoundWhenDoneKey, playPopSound} from '@/helpers/playPop'
|
import {playSoundWhenDoneKey, playPopSound} from '@/helpers/playPop'
|
||||||
import {getQuickAddMagicMode, setQuickAddMagicMode} from '@/helpers/quickAddMagicMode'
|
import {getQuickAddMagicMode, setQuickAddMagicMode} from '@/helpers/quickAddMagicMode'
|
||||||
import {createRandomID} from '@/helpers/randomId'
|
import {createRandomID} from '@/helpers/randomId'
|
||||||
@ -227,7 +227,7 @@ const authStore = useAuthStore()
|
|||||||
const settings = ref({...authStore.settings})
|
const settings = ref({...authStore.settings})
|
||||||
const id = ref(createRandomID())
|
const id = ref(createRandomID())
|
||||||
const availableLanguageOptions = ref(
|
const availableLanguageOptions = ref(
|
||||||
Object.entries(availableLanguages)
|
Object.entries(SUPPORTED_LOCALES)
|
||||||
.map(l => ({code: l[0], title: l[1]}))
|
.map(l => ({code: l[0], title: l[1]}))
|
||||||
.sort((a, b) => a.title.localeCompare(b.title)),
|
.sort((a, b) => a.title.localeCompare(b.title)),
|
||||||
)
|
)
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"extends": "@vue/tsconfig/tsconfig.web.json",
|
"extends": "@vue/tsconfig/tsconfig.web.json",
|
||||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
"include": ["env.d.ts", "src/**/*", "src/**/*.vue", "src/i18n/lang/*.json"],
|
||||||
"exclude": ["src/**/__tests__/*"],
|
"exclude": ["src/**/__tests__/*"],
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"composite": true,
|
"composite": true,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user