feat: replace our home-grown gantt implementation with ganttastic #2180

Merged
konrad merged 78 commits from feature/ganttastic into main 2022-10-27 16:03:27 +00:00
48 changed files with 1394 additions and 989 deletions

View File

@ -5,7 +5,7 @@ module.exports = {
'root': true,
'env': {
'browser': true,
'es2021': true,
'es2022': true,
'node': true,
'vue/setup-compiler-macros': true,
},

View File

@ -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')
})
})

View File

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

View File

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

View File

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

View File

@ -7,6 +7,12 @@
</message>
</template>
<script lang="ts">
export default {
inheritAttrs: false,
}
</script>
<script lang="ts" setup>
import Message from '@/components/misc/message.vue'
import ButtonLink from '@/components/misc/ButtonLink.vue'

View File

@ -0,0 +1,236 @@
<template>

What are the flatpickr changes doing in this PR?

What are the flatpickr changes doing in this PR?
<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>

View File

@ -2,6 +2,12 @@
<div class="loader-container is-loading"></div>
</template>
<script lang="ts">
export default {
inheritAttrs: false,
}
</script>
<style scoped lang="scss">
.loader-container {
height: 100%;

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

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

View File

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

View File

@ -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 = () => ({

View File

@ -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')
}

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

View File

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

View File

@ -0,0 +1,21 @@
import { defineAsyncComponent, type AsyncComponentLoader, type AsyncComponentOptions, type Component, type ComponentPublicInstance } from 'vue'
konrad marked this conversation as resolved Outdated

Is this new helper relevant for the gantt chart?

Is this new helper relevant for the gantt chart?

I load the GanttChart async. This way the chart library can be loaded when necessary.

I load the GanttChart async. This way the chart library can be loaded when necessary.

Okay I think this could be improved/simplified further by just loading the whole gantt view async as that's the only place where the chart is loaded. But that's something I'd leave for another PR.

Okay I think this could be improved/simplified further by just loading the whole gantt view async as that's the only place where the chart is loaded. But that's something I'd leave for another PR.

I thought GanttChart makes more sense, since it's our custom wrapper where we also load some other stuff :) This way all additional load gets loaded async while the page load is really fast, since lightweight.

I thought GanttChart makes more sense, since it's our custom wrapper where we also load some other stuff :) This way all additional load gets loaded async while the page load is really fast, since lightweight.
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,
})
}

View File

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

View File

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

View 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
}

View File

@ -0,0 +1,5 @@
export function parseBooleanProp(booleanProp: string | undefined) {
return (booleanProp === 'false' || booleanProp === '0')
? false
: Boolean(booleanProp)
}

View File

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

View 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 {

Isn't this whole function locale-dependant?

Isn't this whole function locale-dependant?

No. What I called KebabDate and ISO dates are not locale dependant afaik.

After I created it I realized that KebabDate is actually a valid ISO date. I thought that it still makes sense to keep both types around since there are conversions in both directions.

No. What I called KebabDate and ISO dates are not locale dependant afaik. After I created it I realized that KebabDate is actually a valid ISO date. I thought that it still makes sense to keep both types around since there are conversions in both directions.
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
}
}

View 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())
}

View File

@ -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<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
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())
}

View File

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

View 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
}

View File

@ -1,5 +1,5 @@
import {i18n} from '@/i18n'
import { notify } from '@kyvg/vue3-notification'
import {notify} from '@kyvg/vue3-notification'
export const getErrorText = (r) => {

View File

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

View File

@ -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']
}
}
export type ITaskPartialWithId = PartialWithId<ITask>

View File

@ -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<ITask> implements ITask {
id = 0
title = ''
@ -95,7 +116,7 @@ export default class TaskModel extends AbstractModel<ITask> 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<ITask> 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

View File

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

View File

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

View File

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

View File

@ -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<ITask> {
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
}
}

View File

@ -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<ITask> {
constructor() {
super({
getAll: '/lists/{listId}/tasks',

View File

@ -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<boolean>} if the email was successfully confirmed
*/
async verifyEmail() {
async verifyEmail(): Promise<boolean> {
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

View File

@ -1,6 +1,5 @@
// FIXME: should be a component <FilterContainer>
// used in
// - gantt-component.vue
// - Kanban.vue
// - List.vue
// - Table.vue

View File

@ -46,7 +46,6 @@
}
// FIXME: is only used where <edit-task> is used aswell:
// - gantt-component.vue
// - List.vue
// -> Move the <card> wrapper including this class definition inside <edit-task>
.is-max-width-desktop .tasks .task {

7
src/types/DateISO.ts Normal file
View 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
View File

@ -0,0 +1,4 @@
/**
* Date in Format 2022-12-10
*/
export type DateKebab = `${string}-${string}-${string}`

View File

@ -0,0 +1 @@
export type PartialWithId<T extends { id: unknown }> = Pick<T, 'id'> & Omit<Partial<T>, 'id'>

View File

@ -1,47 +1,30 @@
<template>
<ListWrapper class="list-gantt" :list-id="props.listId" viewName="gantt">
<ListWrapper class="list-gantt" :list-id="filters.listId" viewName="gantt">
<template #header>
<card class="gantt-options">
<fancycheckbox class="is-block" v-model="showTaskswithoutDates">
{{ $t('list.gantt.showTasksWithoutDates') }}
</fancycheckbox>
<div class="range-picker">
<card>
<div class="gantt-options">
<div class="field">
<label class="label" for="dayWidth">{{ $t('list.gantt.size') }}</label>
<label class="label" for="range">{{ $t('list.gantt.range') }}</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
<Foo
ref="flatPickerEl"
:config="flatPickerConfig"
class="input"
id="fromDate"
:placeholder="$t('list.gantt.from')"
v-model="dateFrom"
id="range"
:placeholder="$t('list.gantt.range')"
v-model="flatPickerDateRange"
/>
</div>
</div>
<div class="field">
<label class="label" for="toDate">{{ $t('list.gantt.to') }}</label>
<div class="field" v-if="!hasDefaultFilters">
<label class="label" for="range">Reset</label>
<div class="control">
<flat-pickr
:config="flatPickerConfig"
class="input"
id="toDate"
:placeholder="$t('list.gantt.to')"
v-model="dateTo"
/>
<x-button @click="setDefaultFilters">Reset</x-button>
</div>
</div>
<fancycheckbox class="is-block" v-model="filters.showTasksWithoutDates">
{{ $t('list.gantt.showTasksWithoutDates') }}
</fancycheckbox>
</div>
</card>
</template>
@ -49,15 +32,15 @@
<template #default>
<div class="gantt-chart-container">
<card :padding="false" class="has-overflow">
<gantt-chart
:date-from="dateFrom"
:date-to="dateTo"
:day-width="dayWidth"
:list-id="props.listId"
:show-taskswithout-dates="showTaskswithoutDates"
:filters="filters"
:tasks="tasks"
:isLoading="isLoading"
:default-task-start-date="defaultTaskStartDate"
:default-task-end-date="defaultTaskEndDate"
@update:task="updateTask"
/>
<TaskForm v-if="canWrite" @create-task="addGanttTask" />
</card>
</div>
</template>
@ -65,46 +48,92 @@
</template>
<script setup lang="ts">
import {ref, computed} from 'vue'
import flatPickr from 'vue-flatpickr-component'
import {computed, ref, toRefs} from 'vue'
import type Flatpickr from 'flatpickr'
import {useI18n} from 'vue-i18n'
import type {RouteLocationNormalized} from 'vue-router'
import {useBaseStore} from '@/stores/base'
import {useAuthStore} from '@/stores/auth'
import Foo from '@/components/misc/flatpickr/Flatpickr.vue'
import ListWrapper from './ListWrapper.vue'
import GanttChart from '@/components/tasks/gantt-component.vue'
import Fancycheckbox from '@/components/input/fancycheckbox.vue'
import TaskForm from '@/components/tasks/TaskForm.vue'
const props = defineProps({
listId: {
type: Number,
required: true,
import {createAsyncComponent} from '@/helpers/createAsyncComponent'
import {useGanttFilters} from './helpers/useGanttFilters'
import {RIGHTS} from '@/constants/rights'
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)
konrad marked this conversation as resolved Outdated

Should this update?

Should this update?

No, doesn't even need to be ref.

No, doesn't even need to be ref.
const {route} = toRefs(props)
const {
filters,
hasDefaultFilters,

Can you explain in a different way?

Can you explain in a different way?

The problem is the gantt chart already updates when only one date (the start or end date) is selected. Ideally, they would only update the prop when both of these dates are available to avoid these partial updates.

The problem is the gantt chart already updates when only one date (the start or end date) is selected. Ideally, they would only update the prop when both of these dates are available to avoid these partial updates.

Maybe I'm still not getting this correctly, but can't we just update the value when both (start and end) are set?

Maybe I'm still not getting this correctly, but can't we just update the value when both (start and end) are set?

Currently the from and to dates get passed as individual props. That means if one changes, it changes directly in the chart.

I think the way to go here would be to pass a single object with both dates instead?

Currently the from and to dates get passed as individual props. That means if one changes, it changes directly in the chart. I think the way to go here would be to pass a single object with both dates instead?

That seems like the right approach

That seems like the right approach

Will check this out again. Shouldn't be too hard.

Will check this out again. Shouldn't be too hard.
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 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 initialDateRange = [filters.value.dateFrom, filters.value.dateTo]
const {t} = useI18n({useScope: 'global'})
const authStore = useAuthStore()
const flatPickerConfig = computed(() => ({
const flatPickerConfig = computed<Options>(() => ({
altFormat: t('date.altFormatShort'),
altInput: true,
dateFormat: 'Y-m-d',
defaultDate: initialDateRange,
enableTime: false,
mode: 'range',
locale: {
firstDayOfWeek: authStore.settings.weekStart,
},
}))
</script>
<style lang="scss">
<style lang="scss" scoped>
.gantt-chart-container {
padding-bottom: 1rem;
}
@ -118,57 +147,9 @@ const flatPickerConfig = computed(() => ({
@media screen and (max-width: $tablet) {
flex-direction: column;
}
.range-picker {
display: flex;
margin-bottom: 1rem;
width: 50%;
@media screen and (max-width: $tablet) {
flex-direction: column;
width: 100%;
}
.field {
margin-bottom: 0;
width: 33%;
&:not(:last-child) {
padding-right: .5rem;
}
@media screen and (max-width: $tablet) {
width: 100%;
max-width: 100%;
margin-top: .5rem;
padding-right: 0 !important;
}
&, .input {
font-size: .8rem;
}
.select, .select select {
height: auto;
width: 100%;
font-size: .8rem;
}
.label {
font-size: .9rem;
padding-left: .4rem;
}
}
}
}
// vue-draggable overwrites
.vdr.active::before {
display: none;
}
.link-share-view:not(.has-background) .card.gantt-options {
:global(.link-share-view:not(.has-background)) .gantt-options {
border: none;
box-shadow: none;
@ -176,4 +157,35 @@ const flatPickerConfig = computed(() => ({
padding: .5rem;
}
}
.field {
margin-bottom: 0;
width: 33%;
&:not(:last-child) {
padding-right: .5rem;
}
@media screen and (max-width: $tablet) {
width: 100%;
max-width: 100%;
margin-top: .5rem;
padding-right: 0 !important;
}
&, .input {
font-size: .8rem;
}
.select,
.select select {
height: auto;
width: 100%;
font-size: .8rem;
}
.label {
font-size: .9rem;
}
}
</style>

View File

@ -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
}
}
</script>
<style lang="scss" scoped>

View 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,
}
}

View 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,
}
}

View File

@ -158,7 +158,7 @@ import {PrefixMode} from '@/modules/parseTaskText'
import ListSearch from '@/components/tasks/partials/listSearch.vue'
import {availableLanguages} from '@/i18n'
import {SUPPORTED_LOCALES} from '@/i18n'
import {playSoundWhenDoneKey, playPopSound} from '@/helpers/playPop'
import {getQuickAddMagicMode, setQuickAddMagicMode} from '@/helpers/quickAddMagicMode'
import {createRandomID} from '@/helpers/randomId'
@ -227,7 +227,7 @@ const authStore = useAuthStore()
const settings = ref({...authStore.settings})
const id = ref(createRandomID())
const availableLanguageOptions = ref(
Object.entries(availableLanguages)
Object.entries(SUPPORTED_LOCALES)
.map(l => ({code: l[0], title: l[1]}))
.sort((a, b) => a.title.localeCompare(b.title)),
)

View File

@ -1,6 +1,6 @@
{
"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__/*"],
"compilerOptions": {
"composite": true,