diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 7ac572699..e0d091949 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -5,7 +5,7 @@ module.exports = { 'root': true, 'env': { 'browser': true, - 'es2021': true, + 'es2022': true, 'node': true, 'vue/setup-compiler-macros': true, }, diff --git a/cypress/e2e/list/list-view-gantt.spec.ts b/cypress/e2e/list/list-view-gantt.spec.ts index e7d39b3e1..6763c359b 100644 --- a/cypress/e2e/list/list-view-gantt.spec.ts +++ b/cypress/e2e/list/list-view-gantt.spec.ts @@ -11,7 +11,7 @@ describe('List View Gantt', () => { const tasks = TaskFactory.create(1) cy.visit('/lists/1/gantt') - cy.get('.gantt-chart .tasks') + cy.get('.g-gantt-rows-container') .should('not.contain', tasks[0].title) }) @@ -25,7 +25,7 @@ describe('List View Gantt', () => { cy.visit('/lists/1/gantt') - cy.get('.gantt-chart .months') + cy.get('.g-timeunits-container') .should('contain', format(now, 'MMMM')) .should('contain', format(nextMonth, 'MMMM')) }) @@ -38,14 +38,13 @@ describe('List View Gantt', () => { }) cy.visit('/lists/1/gantt') - cy.get('.gantt-chart .tasks') + cy.get('.g-gantt-rows-container') .should('not.be.empty') - cy.get('.gantt-chart .tasks') .should('contain', tasks[0].title) }) it('Shows tasks with no dates after enabling them', () => { - TaskFactory.create(1, { + const tasks = TaskFactory.create(1, { start_date: null, end_date: null, }) @@ -55,13 +54,15 @@ describe('List View Gantt', () => { .contains('Show tasks which don\'t have dates set') .click() - cy.get('.gantt-chart .tasks') + cy.get('.g-gantt-rows-container') .should('not.be.empty') - cy.get('.gantt-chart .tasks .task.nodate') - .should('exist') + .should('contain', tasks[0].title) }) it('Drags a task around', () => { + cy.intercept('**/api/v1/tasks/*') + .as('taskUpdate') + const now = new Date() TaskFactory.create(1, { start_date: formatISO(now), @@ -69,10 +70,11 @@ describe('List View Gantt', () => { }) cy.visit('/lists/1/gantt') - cy.get('.gantt-chart .tasks .task') + cy.get('.g-gantt-rows-container .g-gantt-row .g-gantt-row-bars-container div .g-gantt-bar') .first() .trigger('mousedown', {which: 1}) .trigger('mousemove', {clientX: 500, clientY: 0}) .trigger('mouseup', {force: true}) + cy.wait('@taskUpdate') }) }) \ No newline at end of file diff --git a/package.json b/package.json index 4fe8c66c4..da9061a27 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@fortawesome/free-solid-svg-icons": "6.2.0", "@fortawesome/vue-fontawesome": "3.0.1", "@github/hotkey": "2.0.1", + "@infectoone/vue-ganttastic": "2.1.2", "@kyvg/vue3-notification": "2.4.1", "@sentry/tracing": "7.17.0", "@sentry/vue": "7.17.0", @@ -37,8 +38,10 @@ "camel-case": "4.1.2", "codemirror": "5.65.9", "date-fns": "2.29.3", + "dayjs": "1.11.6", "dompurify": "2.4.0", "easymde": "2.18.0", + "fast-deep-equal": "3.1.3", "flatpickr": "4.6.13", "flexsearch": "0.7.21", "floating-vue": "2.0.0-beta.20", @@ -55,7 +58,6 @@ "ufo": "0.8.6", "vue": "3.2.41", "vue-advanced-cropper": "2.8.6", - "vue-drag-resize": "2.0.3", "vue-flatpickr-component": "10.0.0", "vue-i18n": "9.2.2", "vue-router": "4.1.6", @@ -89,7 +91,7 @@ "eslint": "8.26.0", "eslint-plugin-vue": "9.6.0", "express": "4.18.2", - "happy-dom": "7.6.0", + "happy-dom": "7.6.6", "netlify-cli": "12.0.11", "postcss": "8.4.18", "postcss-preset-env": "7.8.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0b8668149..e54ff5b14 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,6 +10,7 @@ specifiers: '@fortawesome/free-solid-svg-icons': 6.2.0 '@fortawesome/vue-fontawesome': 3.0.1 '@github/hotkey': 2.0.1 + '@infectoone/vue-ganttastic': 2.1.2 '@kyvg/vue3-notification': 2.4.1 '@rushstack/eslint-patch': 1.2.0 '@sentry/tracing': 7.17.0 @@ -42,16 +43,18 @@ specifiers: codemirror: 5.65.9 cypress: 10.11.0 date-fns: 2.29.3 + dayjs: 1.11.6 dompurify: 2.4.0 easymde: 2.18.0 esbuild: 0.15.12 eslint: 8.26.0 eslint-plugin-vue: 9.6.0 express: 4.18.2 + fast-deep-equal: 3.1.3 flatpickr: 4.6.13 flexsearch: 0.7.21 floating-vue: 2.0.0-beta.20 - happy-dom: 7.6.0 + happy-dom: 7.6.6 highlight.js: 11.6.0 is-touch-device: 1.0.1 lodash.clonedeep: 4.5.0 @@ -76,7 +79,6 @@ specifiers: vitest: 0.24.3 vue: 3.2.41 vue-advanced-cropper: 2.8.6 - vue-drag-resize: 2.0.3 vue-flatpickr-component: 10.0.0 vue-i18n: 9.2.2 vue-router: 4.1.6 @@ -92,6 +94,7 @@ dependencies: '@fortawesome/free-solid-svg-icons': 6.2.0 '@fortawesome/vue-fontawesome': 3.0.1_lteq7vqmz6gtgcgatkvrcm56su '@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 '@sentry/tracing': 7.17.0 '@sentry/vue': 7.17.0_vue@3.2.41 @@ -106,8 +109,10 @@ dependencies: camel-case: 4.1.2 codemirror: 5.65.9 date-fns: 2.29.3 + dayjs: 1.11.6 dompurify: 2.4.0 easymde: 2.18.0 + fast-deep-equal: 3.1.3 flatpickr: 4.6.13 flexsearch: 0.7.21 floating-vue: 2.0.0-beta.20_vue@3.2.41 @@ -124,7 +129,6 @@ dependencies: ufo: 0.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-i18n: 9.2.2_vue@3.2.41 vue-router: 4.1.6_vue@3.2.41 @@ -158,7 +162,7 @@ devDependencies: eslint: 8.26.0 eslint-plugin-vue: 9.6.0_eslint@8.26.0 express: 4.18.2 - happy-dom: 7.6.0 + happy-dom: 7.6.6 netlify-cli: 12.0.11_@types+node@18.11.7 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-plugin-pwa: 0.13.1_26qty5l7rsv3yrimlrr6zrzqbu 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 wait-on: 6.0.1 workbox-cli: 6.5.4_acorn@8.8.0 @@ -1745,6 +1749,16 @@ packages: resolution: {integrity: sha512-tWZNBIS1CoekcwlMuyG2mr0a1Wo5lb5lEHwwWvZo+5GLgr3e9LLDTtmgtCWEwBpXMkxn9D+2W9j2FY6eZQq0tA==} 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: resolution: {integrity: sha512-JjUpQtNfn+joMbrXvpR4hTF8iJQ2sEFzzK3KIESOx+f+uwIjgw20igOyaIdhfsVVBCds8ZM64MoeNSx+PHQMkA==} engines: {node: '>= 14'} @@ -4023,7 +4037,7 @@ packages: postcss: ^8.1.0 dependencies: browserslist: 4.21.4 - caniuse-lite: 1.0.30001423 + caniuse-lite: 1.0.30001426 fraction.js: 4.2.0 normalize-range: 0.1.2 picocolors: 1.0.0 @@ -4312,7 +4326,7 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true dependencies: - caniuse-lite: 1.0.30001423 + caniuse-lite: 1.0.30001426 electron-to-chromium: 1.4.256 node-releases: 2.0.6 update-browserslist-db: 1.0.9_browserslist@4.21.4 @@ -4517,6 +4531,10 @@ packages: resolution: {integrity: sha512-09iwWGOlifvE1XuHokFMP7eR38a0JnajoyL3/i87c8ZjRWRrdKo1fqjNfugfBD0UDBIOz0U+jtNhJ0EPm1VleQ==} dev: true + /caniuse-lite/1.0.30001426: + resolution: {integrity: sha512-n7cosrHLl8AWt0wwZw/PJZgUg3lV0gk9LMI7ikGJwhyhgsd2Nb65vKvmSexCqq/J7rbH3mFG6yZZiPR5dLPW5A==} + dev: true + /caseless/0.12.0: resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==} dev: true @@ -5230,7 +5248,7 @@ packages: cli-table3: 0.6.1 commander: 5.1.0 common-tags: 1.8.2 - dayjs: 1.10.7 + dayjs: 1.11.6 debug: 4.3.4_supports-color@8.1.1 enquirer: 2.3.6 eventemitter2: 6.4.7 @@ -5286,9 +5304,8 @@ packages: time-zone: 1.0.0 dev: true - /dayjs/1.10.7: - resolution: {integrity: sha512-P6twpd70BcPK34K26uJ1KT3wlhpuOAPoMwJzpsIWUxHZ7wpmbdZL/hQqBDfz7hGurYSa5PhzdhDHtt319hL3ig==} - dev: true + /dayjs/1.11.6: + resolution: {integrity: sha512-zZbY5giJAinCG+7AGaw0wIhNZ6J8AhWuSXKvuc1KAyMiRsvGQWqh4L+MomvhdAYjN+lqvVCMq1I41e3YHvXkyQ==} /de-indent/1.0.2: resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==} @@ -6629,7 +6646,6 @@ packages: /fast-deep-equal/3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - dev: true /fast-diff/1.2.0: resolution: {integrity: sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==} @@ -7440,8 +7456,8 @@ packages: engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} dev: true - /happy-dom/7.6.0: - resolution: {integrity: sha512-QnNsiblZdyVDzW5ts6E7ub79JnabqHJeJgt+1WGNq9fSYqS/r/RzzTVXCZSDl6EVkipdwI48B4bgXAnMZPecIw==} + /happy-dom/7.6.6: + resolution: {integrity: sha512-28NxRiHXjzhr+BGciLNUoQW4OaBnQPRT/LPYLufh0Fj3Iwh1j9qJaozjBm/Uqdj5Ps4cukevQ7ERieA6Ddwf1g==} dependencies: css.escape: 1.5.1 he: 1.2.0 @@ -12922,7 +12938,7 @@ packages: fsevents: 2.3.2 dev: true - /vitest/0.24.3_gqoderdqe64ltl5b7jo6jid56m: + /vitest/0.24.3_zk3z3yjjczezb4ds7r7zfrc4ay: resolution: {integrity: sha512-aM0auuPPgMSstWvr851hB74g/LKaKBzSxcG3da7ejfZbx08Y21JpZmbmDYrMTCGhVZKqTGwzcnLMwyfz2WzkhQ==} engines: {node: '>=v14.16.0'} hasBin: true @@ -12949,7 +12965,7 @@ packages: '@types/node': 18.11.7 chai: 4.3.6 debug: 4.3.4 - happy-dom: 7.6.0 + happy-dom: 7.6.6 local-pkg: 0.4.2 strip-literal: 0.4.2 tinybench: 2.3.0 @@ -12992,10 +13008,6 @@ packages: vue: 3.2.41 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: resolution: {integrity: sha512-yL+ZDb+9T0ELG4VIFo/2anAOz8SvBdlqEnQnvJ3M7Scq56DvtjY0VY88bByRZB0D4J0u8olBcfrXTVONXsh4og==} engines: {node: ^14.17.0 || >=16.0.0} diff --git a/src/components/input/AsyncEditor.ts b/src/components/input/AsyncEditor.ts index 30278c292..f80015229 100644 --- a/src/components/input/AsyncEditor.ts +++ b/src/components/input/AsyncEditor.ts @@ -1,12 +1,3 @@ -import { defineAsyncComponent } from 'vue' -import ErrorComponent from '@/components/misc/error.vue' -import LoadingComponent from '@/components/misc/loading.vue' +import {createAsyncComponent} from '@/helpers/createAsyncComponent' -const Editor = () => import('@/components/input/editor.vue') - -export default defineAsyncComponent({ - loader: Editor, - loadingComponent: LoadingComponent, - errorComponent: ErrorComponent, - timeout: 60000, -}) +export default createAsyncComponent(() => import('@/components/input/editor.vue')) \ No newline at end of file diff --git a/src/components/misc/error.vue b/src/components/misc/error.vue index 04cd14483..7c797e264 100644 --- a/src/components/misc/error.vue +++ b/src/components/misc/error.vue @@ -7,6 +7,12 @@ + + + + diff --git a/src/components/misc/loading.vue b/src/components/misc/loading.vue index 6cc001a0c..9607ca3cf 100644 --- a/src/components/misc/loading.vue +++ b/src/components/misc/loading.vue @@ -2,6 +2,12 @@
+ + + + + \ No newline at end of file diff --git a/src/components/tasks/TaskForm.vue b/src/components/tasks/TaskForm.vue new file mode 100644 index 000000000..972fc1500 --- /dev/null +++ b/src/components/tasks/TaskForm.vue @@ -0,0 +1,78 @@ + + + + + diff --git a/src/components/tasks/gantt-component.vue b/src/components/tasks/gantt-component.vue deleted file mode 100644 index 596dae839..000000000 --- a/src/components/tasks/gantt-component.vue +++ /dev/null @@ -1,642 +0,0 @@ - - - - - \ No newline at end of file diff --git a/src/composables/taskList.ts b/src/composables/taskList.ts index e23f10087..85fdc23b7 100644 --- a/src/composables/taskList.ts +++ b/src/composables/taskList.ts @@ -2,7 +2,7 @@ import {ref, shallowReactive, watch, computed} from 'vue' import {useRoute} from 'vue-router' import TaskCollectionService from '@/services/taskCollection' -import type { ITask } from '@/modelTypes/ITask' +import type {ITask} from '@/modelTypes/ITask' // FIXME: merge with DEFAULT_PARAMS in filters.vue export const getDefaultParams = () => ({ diff --git a/src/composables/useRenewTokenOnFocus.ts b/src/composables/useRenewTokenOnFocus.ts index c5ed0fa65..21070f3af 100644 --- a/src/composables/useRenewTokenOnFocus.ts +++ b/src/composables/useRenewTokenOnFocus.ts @@ -3,6 +3,9 @@ import {useRouter} from 'vue-router' import {useEventListener} from '@vueuse/core' import {useAuthStore} from '@/stores/auth' +import {MILLISECONDS_A_HOUR, SECONDS_A_HOUR} from '@/constants/date' + +const SECONDS_TOKEN_VALID = 60 * SECONDS_A_HOUR export function useRenewTokenOnFocus() { const router = useRouter() @@ -21,7 +24,7 @@ export function useRenewTokenOnFocus() { return } - const expiresIn = (userInfo.value !== null ? userInfo.value.exp : 0) - +new Date() / 1000 + const expiresIn = (userInfo.value !== null ? userInfo.value.exp : 0) - new Date().valueOf() / MILLISECONDS_A_HOUR // If the token expiry is negative, it is already expired and we have no choice but to redirect // the user to the login page @@ -32,7 +35,7 @@ export function useRenewTokenOnFocus() { } // Check if the token is valid for less than 60 hours and renew if thats the case - if (expiresIn < 60 * 3600) { + if (expiresIn < SECONDS_TOKEN_VALID) { authStore.renewToken() console.debug('renewed token') } diff --git a/src/composables/useRouteFilters.ts b/src/composables/useRouteFilters.ts new file mode 100644 index 000000000..0959ab59b --- /dev/null +++ b/src/composables/useRouteFilters.ts @@ -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 + +export function useRouteFilters( + route: Ref, + getDefaultFilters: (route: RouteLocationNormalized) => CurrentFilters, + routeToFilters: (route: RouteLocationNormalized) => CurrentFilters, + filtersToRoute: (filters: CurrentFilters) => RouteLocationRaw, + ) { + const router = useRouter() + + const filters = ref(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, + } +} \ No newline at end of file diff --git a/src/constants/date.ts b/src/constants/date.ts new file mode 100644 index 000000000..835f2c4da --- /dev/null +++ b/src/constants/date.ts @@ -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 \ No newline at end of file diff --git a/src/helpers/color/colorFromHex.ts b/src/helpers/color/colorFromHex.ts index ac95be7e5..cc2fa0898 100644 --- a/src/helpers/color/colorFromHex.ts +++ b/src/helpers/color/colorFromHex.ts @@ -4,7 +4,7 @@ * @param color * @returns {string} */ -export function colorFromHex(color) { +export function colorFromHex(color: string) { if (color.substring(0, 1) === '#') { color = color.substring(1, 7) } diff --git a/src/helpers/createAsyncComponent.ts b/src/helpers/createAsyncComponent.ts new file mode 100644 index 000000000..233396dd4 --- /dev/null +++ b/src/helpers/createAsyncComponent.ts @@ -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(source: AsyncComponentLoader | AsyncComponentOptions): T { + if (typeof source === 'function') { + source = { loader: source } + } + + return defineAsyncComponent({ + ...source, + loadingComponent: LoadingComponent, + errorComponent: ErrorComponent, + timeout: DEFAULT_TIMEOUT, + }) +} diff --git a/src/helpers/time/formatDate.ts b/src/helpers/time/formatDate.ts index 2a202b9d2..5eb7ccc37 100644 --- a/src/helpers/time/formatDate.ts +++ b/src/helpers/time/formatDate.ts @@ -1,5 +1,7 @@ import {createDateFromString} from '@/helpers/time/createDateFromString' 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 {i18n} from '@/i18n' diff --git a/src/helpers/time/getNextWeekDate.ts b/src/helpers/time/getNextWeekDate.ts index d0bb303fb..bb7bcfe07 100644 --- a/src/helpers/time/getNextWeekDate.ts +++ b/src/helpers/time/getNextWeekDate.ts @@ -1,3 +1,5 @@ +import {MILLISECONDS_A_WEEK} from '@/constants/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) } diff --git a/src/helpers/time/isoToKebabDate.ts b/src/helpers/time/isoToKebabDate.ts new file mode 100644 index 000000000..b9ec26c52 --- /dev/null +++ b/src/helpers/time/isoToKebabDate.ts @@ -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 +} \ No newline at end of file diff --git a/src/helpers/time/parseBooleanProp.ts b/src/helpers/time/parseBooleanProp.ts new file mode 100644 index 000000000..b43ecac59 --- /dev/null +++ b/src/helpers/time/parseBooleanProp.ts @@ -0,0 +1,5 @@ +export function parseBooleanProp(booleanProp: string | undefined) { + return (booleanProp === 'false' || booleanProp === '0') + ? false + : Boolean(booleanProp) +} \ No newline at end of file diff --git a/src/helpers/time/parseDate.ts b/src/helpers/time/parseDate.ts index ffab0aa66..226cb8848 100644 --- a/src/helpers/time/parseDate.ts +++ b/src/helpers/time/parseDate.ts @@ -349,9 +349,7 @@ const getMonthFromText = (text: string, date: Date) => { const getDateFromInterval = (interval: number): Date => { const newDate = new Date() newDate.setDate(newDate.getDate() + interval) - newDate.setHours(calculateNearestHours(newDate)) - newDate.setMinutes(0) - newDate.setSeconds(0) + newDate.setHours(calculateNearestHours(newDate), 0, 0) return newDate } diff --git a/src/helpers/time/parseDateProp.ts b/src/helpers/time/parseDateProp.ts new file mode 100644 index 000000000..9e426df25 --- /dev/null +++ b/src/helpers/time/parseDateProp.ts @@ -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 + } +} \ No newline at end of file diff --git a/src/helpers/time/parseKebabDate.ts b/src/helpers/time/parseKebabDate.ts new file mode 100644 index 000000000..f1643aa48 --- /dev/null +++ b/src/helpers/time/parseKebabDate.ts @@ -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()) +} \ No newline at end of file diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 266fe99af..f44737786 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -1,19 +1,8 @@ import {createI18n} from 'vue-i18n' import langEN from './lang/en.json' -export const i18n = createI18n({ - locale: 'en', // set locale - fallbackLocale: 'en', - legacy: true, - globalInjection: true, - allowComposition: true, - messages: { - en: langEN, - }, -}) - -export const availableLanguages = { - en: 'English', +export const SUPPORTED_LOCALES = { + 'en': 'English', 'de-DE': 'Deutsch', 'de-swiss': 'Schwizertütsch', 'ru-RU': 'Русский', @@ -24,62 +13,72 @@ export const availableLanguages = { 'pl-PL': 'Polski', 'nl-NL': 'Nederlands', 'pt-PT': 'Português', -} + 'zh-CN': 'Chinese', +} as Record -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, +}) + +function setI18nLanguage(lang: SupportedLocale): SupportedLocale { i18n.global.locale = lang - document.documentElement.lang =lang + document.documentElement.lang = lang return lang } -export const loadLanguageAsync = lang => { +export async function loadLanguageAsync(lang: SupportedLocale) { if (!lang) { + throw new Error() + } + + // do not change language to the current one + if (i18n.global.locale === lang) { 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 (!i18n.global.availableLocales.includes(lang)) { + const messages = await import(`./lang/${lang}.json`) + i18n.global.setLocaleMessage(lang, messages.default) } - // If the language hasn't been loaded yet - return import(`./lang/${lang}.json`).then( - messages => { - i18n.global.setLocaleMessage(lang, messages.default) - loadedLanguages.push(lang) - return setI18nLanguage(lang) - }, - ) + return setI18nLanguage(lang) } -export const getCurrentLanguage = () => { +export function getCurrentLanguage(): SupportedLocale { const savedLanguage = localStorage.getItem('language') if (savedLanguage !== null) { return savedLanguage } - const browserLanguage = navigator.language || navigator.userLanguage + const browserLanguage = navigator.language - for (const k in availableLanguages) { - if (browserLanguage[k] === browserLanguage || k.startsWith(browserLanguage + '-')) { - return k - } - } + const language: SupportedLocale | undefined = Object.keys(SUPPORTED_LOCALES).find(langKey => { + return langKey === browserLanguage || langKey.startsWith(browserLanguage + '-') + }) - return 'en' + return language || DEFAULT_LANGUAGE } -export const saveLanguage = (lang: string) => { +export function saveLanguage(lang: SupportedLocale) { localStorage.setItem('language', lang) setLanguage() } -export const setLanguage = () => { - loadLanguageAsync(getCurrentLanguage()) -} +export function setLanguage() { + return loadLanguageAsync(getCurrentLanguage()) +} \ No newline at end of file diff --git a/src/i18n/lang/en.json b/src/i18n/lang/en.json index 109c422c9..012d48f74 100644 --- a/src/i18n/lang/en.json +++ b/src/i18n/lang/en.json @@ -286,8 +286,8 @@ "default": "Default", "month": "Month", "day": "Day", - "from": "From", - "to": "To", + "hour": "Hour", + "range": "Date Range", "noDates": "This task has no dates set." }, "table": { diff --git a/src/i18n/useDayjsLanguageSync.ts b/src/i18n/useDayjsLanguageSync.ts new file mode 100644 index 000000000..377737de7 --- /dev/null +++ b/src/i18n/useDayjsLanguageSync.ts @@ -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 + +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 Promise> + +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 +} \ No newline at end of file diff --git a/src/message/index.ts b/src/message/index.ts index c960bf84b..70902d50b 100644 --- a/src/message/index.ts +++ b/src/message/index.ts @@ -1,5 +1,5 @@ import {i18n} from '@/i18n' -import { notify } from '@kyvg/vue3-notification' +import {notify} from '@kyvg/vue3-notification' export const getErrorText = (r) => { diff --git a/src/modelSchema/common/repeats.ts b/src/modelSchema/common/repeats.ts index 175b0c727..da89e1f95 100644 --- a/src/modelSchema/common/repeats.ts +++ b/src/modelSchema/common/repeats.ts @@ -1,6 +1,28 @@ +import {SECONDS_A_HOUR} from '@/constants/date' import { REPEAT_TYPES, type IRepeatAfter } from '@/types/IRepeatAfter' 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( (repeats: unknown) => { // 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 } - const repeatAfterHours = (repeats / 60) / 60 - - 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 + return parseRepeatAfter(repeats) }, object({ type: nativeEnum(REPEAT_TYPES), diff --git a/src/modelTypes/ITask.ts b/src/modelTypes/ITask.ts index 4bfb23fa4..bcbcf3925 100644 --- a/src/modelTypes/ITask.ts +++ b/src/modelTypes/ITask.ts @@ -12,6 +12,8 @@ import type {IRelationKind} from '@/types/IRelationKind' import type {IRepeatAfter} from '@/types/IRepeatAfter' import type {IRepeatMode} from '@/types/IRepeatMode' +import type {PartialWithId} from '@/types/PartialWithId' + export interface ITask extends IAbstract { id: number title: string @@ -49,4 +51,6 @@ export interface ITask extends IAbstract { listId: IList['id'] // Meta, only used when creating a new task bucketId: IBucket['id'] -} \ No newline at end of file +} + +export type ITaskPartialWithId = PartialWithId \ No newline at end of file diff --git a/src/models/task.ts b/src/models/task.ts index 5f4e01d70..a133c5e65 100644 --- a/src/models/task.ts +++ b/src/models/task.ts @@ -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 {ILabel} from '@/modelTypes/ILabel' @@ -10,10 +10,10 @@ import type {ISubscription} from '@/modelTypes/ISubscription' import type {IBucket} from '@/modelTypes/IBucket' import type {IRepeatAfter} from '@/types/IRepeatAfter' +import type {IRelationKind} from '@/types/IRelationKind' import {TASK_REPEAT_MODES, type IRepeatMode} from '@/types/IRepeatMode' import {parseDateOrNull} from '@/helpers/parseDateOrNull' -import type { IRelationKind } from '@/types/IRelationKind' import AbstractModel from './abstractModel' import LabelModel from './label' @@ -36,6 +36,27 @@ export function getHexColor(hexColor: string): string { 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 implements ITask { id = 0 title = '' @@ -95,7 +116,7 @@ export default class TaskModel extends AbstractModel implements ITask { this.endDate = parseDateOrNull(this.endDate) // 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)) @@ -151,33 +172,6 @@ export default class TaskModel extends AbstractModel implements ITask { // 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() { if (!SUPPORTS_TRIGGERED_NOTIFICATION) { return diff --git a/src/modules/parseTaskText.test.ts b/src/modules/parseTaskText.test.ts index 83b291a70..728d1c8e3 100644 --- a/src/modules/parseTaskText.test.ts +++ b/src/modules/parseTaskText.test.ts @@ -1,9 +1,10 @@ import {beforeEach, afterEach, describe, it, expect, vi} from 'vitest' 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 {PRIORITIES} from '@/constants/priorities' +import { MILLISECONDS_A_DAY } from '@/constants/date' describe('Parse Task Text', () => { beforeEach(() => { @@ -296,7 +297,7 @@ describe('Parse Task Text', () => { expect(result.date.getMonth()).toBe(expectedDate.getMonth()) }) 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`) expect(result.text).toBe('Lorem Ipsum') diff --git a/src/router/index.ts b/src/router/index.ts index 06120f6f0..9a00995e9 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -35,7 +35,7 @@ import MigrationComponent from '../views/migrator/Migrate.vue' import MigrateServiceComponent from '../views/migrator/MigrateService.vue' // List Views 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 ListKanban from '../views/list/ListKanban.vue' const ListInfo = () => import('../views/list/ListInfo.vue') @@ -379,7 +379,8 @@ const router = createRouter({ name: 'list.gantt', component: ListGantt, 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', diff --git a/src/services/abstractService.ts b/src/services/abstractService.ts index b21f155ed..c056912ff 100644 --- a/src/services/abstractService.ts +++ b/src/services/abstractService.ts @@ -2,8 +2,9 @@ import {AuthenticatedHTTPFactory} from '@/http-common' import type {Method} from 'axios' import {objectToSnakeCase} from '@/helpers/case' -import AbstractModel, { type IAbstract } from '@/models/abstractModel' -import type { Right } from '@/constants/rights' +import AbstractModel from '@/models/abstractModel' +import type {IAbstract} from '@/modelTypes/IAbstract' +import type {Right} from '@/constants/rights' import type {IFile} from '@/modelTypes/IFile' interface Paths { diff --git a/src/services/task.ts b/src/services/task.ts index bb41b6853..0eee3227b 100644 --- a/src/services/task.ts +++ b/src/services/task.ts @@ -6,6 +6,7 @@ import LabelService from './label' import {formatISO} from 'date-fns' 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 => { if (date) { @@ -73,19 +74,19 @@ export default class TaskService extends AbstractService { if (model.repeatAfter !== null && (model.repeatAfter.amount !== null || model.repeatAfter.amount !== 0)) { switch (model.repeatAfter.type) { case 'hours': - repeatAfterSeconds = model.repeatAfter.amount * 60 * 60 + repeatAfterSeconds = model.repeatAfter.amount * SECONDS_A_HOUR break case 'days': - repeatAfterSeconds = model.repeatAfter.amount * 60 * 60 * 24 + repeatAfterSeconds = model.repeatAfter.amount * SECONDS_A_DAY break case 'weeks': - repeatAfterSeconds = model.repeatAfter.amount * 60 * 60 * 24 * 7 + repeatAfterSeconds = model.repeatAfter.amount * SECONDS_A_WEEK break case 'months': - repeatAfterSeconds = model.repeatAfter.amount * 60 * 60 * 24 * 30 + repeatAfterSeconds = model.repeatAfter.amount * SECONDS_A_MONTH break case 'years': - repeatAfterSeconds = model.repeatAfter.amount * 60 * 60 * 24 * 365 + repeatAfterSeconds = model.repeatAfter.amount * SECONDS_A_YEAR break } } diff --git a/src/services/taskCollection.ts b/src/services/taskCollection.ts index 51830862b..8533bd2a9 100644 --- a/src/services/taskCollection.ts +++ b/src/services/taskCollection.ts @@ -1,8 +1,22 @@ -import AbstractService from './abstractService' -import TaskModel from '../models/task' 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 { constructor() { super({ getAll: '/lists/{listId}/tasks', diff --git a/src/stores/auth.ts b/src/stores/auth.ts index 13d354a49..90cca6836 100644 --- a/src/stores/auth.ts +++ b/src/stores/auth.ts @@ -14,6 +14,7 @@ import type {IUserSettings} from '@/modelTypes/IUserSettings' import router from '@/router' import {useConfigStore} from '@/stores/config' import UserSettingsModel from '@/models/userSettings' +import {MILLISECONDS_A_SECOND} from '@/constants/date' export interface AuthState { 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) { const HTTP = HTTPFactory() this.setIsLoading(true) @@ -184,14 +187,17 @@ export const useAuthStore = defineStore('auth', { 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() { - + 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. // To prevent hitting the api too frequently or race conditions, we check at most once per minute. if ( this.lastUserInfoRefresh !== null && - this.lastUserInfoRefresh > (new Date()).setMinutes((new Date()).getMinutes() + 1) + this.lastUserInfoRefresh > inOneMinute ) { return } @@ -204,7 +210,7 @@ export const useAuthStore = defineStore('auth', { .replace('-', '+') .replace('_', '/') 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 this.setUser(info) @@ -282,9 +288,8 @@ export const useAuthStore = defineStore('auth', { /** * Try to verify the email - * @returns {Promise} if the email was successfully confirmed */ - async verifyEmail() { + async verifyEmail(): Promise { const emailVerifyToken = localStorage.getItem('emailConfirmToken') if (emailVerifyToken) { 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() { // 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 diff --git a/src/styles/components/list.scss b/src/styles/components/list.scss index ea340f3cd..a9bfda0c9 100644 --- a/src/styles/components/list.scss +++ b/src/styles/components/list.scss @@ -1,6 +1,5 @@ // FIXME: should be a component // used in -// - gantt-component.vue // - Kanban.vue // - List.vue // - Table.vue diff --git a/src/styles/components/tasks.scss b/src/styles/components/tasks.scss index 910f0440f..061728658 100644 --- a/src/styles/components/tasks.scss +++ b/src/styles/components/tasks.scss @@ -46,7 +46,6 @@ } // FIXME: is only used where is used aswell: -// - gantt-component.vue // - List.vue // -> Move the wrapper including this class definition inside .is-max-width-desktop .tasks .task { diff --git a/src/types/DateISO.ts b/src/types/DateISO.ts new file mode 100644 index 000000000..0db782318 --- /dev/null +++ b/src/types/DateISO.ts @@ -0,0 +1,7 @@ +/** + * Returns a date as a string value in ISO format. + * same format as `new Date().toISOString()` + */ +export type DateISO = T + +new Date().toISOString() \ No newline at end of file diff --git a/src/types/DateKebab.ts b/src/types/DateKebab.ts new file mode 100644 index 000000000..bdc1808df --- /dev/null +++ b/src/types/DateKebab.ts @@ -0,0 +1,4 @@ +/** +* Date in Format 2022-12-10 +*/ +export type DateKebab = `${string}-${string}-${string}` diff --git a/src/types/PartialWithId.ts b/src/types/PartialWithId.ts new file mode 100644 index 000000000..3d72b8f40 --- /dev/null +++ b/src/types/PartialWithId.ts @@ -0,0 +1 @@ +export type PartialWithId = Pick & Omit, 'id'> \ No newline at end of file diff --git a/src/views/list/ListGantt.vue b/src/views/list/ListGantt.vue index 12ab6e952..b032f74bb 100644 --- a/src/views/list/ListGantt.vue +++ b/src/views/list/ListGantt.vue @@ -1,47 +1,30 @@ - \ No newline at end of file diff --git a/src/views/list/ListWrapper.vue b/src/views/list/ListWrapper.vue index 274a7f664..b4306b8c6 100644 --- a/src/views/list/ListWrapper.vue +++ b/src/views/list/ListWrapper.vue @@ -61,7 +61,6 @@ import {useTitle} from '@/composables/useTitle' import {useBaseStore} from '@/stores/base' import {useListStore} from '@/stores/lists' -import {useKanbanStore} from '@/stores/kanban' const props = defineProps({ listId: { @@ -77,7 +76,6 @@ const props = defineProps({ const route = useRoute() const baseStore = useBaseStore() -const kanbanStore = useKanbanStore() const listStore = useListStore() const listService = ref(new ListService()) const loadedListId = ref(0) @@ -90,6 +88,7 @@ const currentList = computed(() => { maxRight: null, } : baseStore.currentList }) +useTitle(() => currentList.value.id ? getListTitle(currentList.value) : '') // watchEffect would be called every time the prop would get a value assigned, even if that value was the same as before. // This resulted in loading and setting the list multiple times, even when navigating away from it. @@ -98,62 +97,47 @@ const currentList = computed(() => { // of it, most likely due to the rights not being properly populated. watch( () => props.listId, - listId => loadList(listId), + // loadList + async (listIdToLoad: number) => { + const listData = {id: listIdToLoad} + saveListToHistory(listData) + + // Don't load the list if we either already loaded it or aren't dealing with a list at all currently and + // the currently loaded list has the right set. + if ( + ( + listIdToLoad === loadedListId.value || + typeof listIdToLoad === 'undefined' || + listIdToLoad === currentList.value.id + ) + && typeof currentList.value !== 'undefined' && currentList.value.maxRight !== null + ) { + loadedListId.value = props.listId + return + } + + console.debug(`Loading list, props.viewName = ${props.viewName}, $route.params =`, route.params, `, loadedListId = ${loadedListId.value}, currentList = `, currentList.value) + + // Set the current list to the one we're about to load so that the title is already shown at the top + loadedListId.value = 0 + const listFromStore = listStore.getListById(listData.id) + if (listFromStore !== null) { + baseStore.setBackground(null) + baseStore.setBlurHash(null) + baseStore.handleSetCurrentList({list: listFromStore}) + } + + // We create an extra list object instead of creating it in list.value because that would trigger a ui update which would result in bad ux. + const list = new ListModel(listData) + try { + const loadedList = await listService.value.get(list) + await baseStore.handleSetCurrentList({list: loadedList}) + } finally { + loadedListId.value = props.listId + } + }, {immediate: true}, ) - -useTitle(() => currentList.value.id ? getListTitle(currentList.value) : '') - -async function loadList(listIdToLoad: number) { - const listData = {id: listIdToLoad} - saveListToHistory(listData) - - // This invalidates the loaded list at the kanban board which lets it reload its content when - // switched to it. This ensures updates done to tasks in the gantt or list views are consistently - // shown in all views while preventing reloads when closing a task popup. - // We don't do this for the table view because that does not change tasks. - // FIXME: remove this - if ( - props.viewName === 'list.list' || - props.viewName === 'list.gantt' - ) { - kanbanStore.setListId(0) - } - - // Don't load the list if we either already loaded it or aren't dealing with a list at all currently and - // the currently loaded list has the right set. - if ( - ( - listIdToLoad === loadedListId.value || - typeof listIdToLoad === 'undefined' || - listIdToLoad === currentList.value.id - ) - && typeof currentList.value !== 'undefined' && currentList.value.maxRight !== null - ) { - loadedListId.value = props.listId - return - } - - console.debug(`Loading list, props.viewName = ${props.viewName}, $route.params =`, route.params, `, loadedListId = ${loadedListId.value}, currentList = `, currentList.value) - - // Set the current list to the one we're about to load so that the title is already shown at the top - loadedListId.value = 0 - const listFromStore = listStore.getListById(listData.id) - if (listFromStore !== null) { - baseStore.setBackground(null) - baseStore.setBlurHash(null) - baseStore.handleSetCurrentList({list: listFromStore}) - } - - // We create an extra list object instead of creating it in list.value because that would trigger a ui update which would result in bad ux. - const list = new ListModel(listData) - try { - const loadedList = await listService.value.get(list) - await baseStore.handleSetCurrentList({list: loadedList}) - } finally { - loadedListId.value = props.listId - } -}