feat: replace our home-grown gantt implementation with ganttastic #2180
@ -5,7 +5,7 @@ module.exports = {
|
||||
'root': true,
|
||||
'env': {
|
||||
'browser': true,
|
||||
'es2021': true,
|
||||
'es2022': true,
|
||||
'node': true,
|
||||
'vue/setup-compiler-macros': true,
|
||||
},
|
||||
|
@ -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')
|
||||
})
|
||||
})
|
@ -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",
|
||||
|
52
pnpm-lock.yaml
generated
52
pnpm-lock.yaml
generated
@ -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}
|
||||
|
@ -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'))
|
@ -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'
|
||||
|
236
src/components/misc/flatpickr/Flatpickr.vue
Normal file
236
src/components/misc/flatpickr/Flatpickr.vue
Normal file
@ -0,0 +1,236 @@
|
||||
<template>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
data-input
|
||||
:disabled="disabled"
|
||||
v-bind="attrs"
|
||||
ref="root"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import flatpickr from 'flatpickr'
|
||||
import 'flatpickr/dist/flatpickr.css'
|
||||
|
||||
// FIXME: Not sure how to alias these correctly
|
||||
// import Options = Flatpickr.Options doesn't work
|
||||
type Hook = flatpickr.Options.Hook
|
||||
type HookKey = flatpickr.Options.HookKey
|
||||
type Options = flatpickr.Options.Options
|
||||
type DateOption = flatpickr.Options.DateOption
|
||||
|
||||
function camelToKebab(string: string) {
|
||||
return string.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase()
|
||||
}
|
||||
|
||||
function arrayify<T = unknown>(obj: T) {
|
||||
return obj instanceof Array
|
||||
? obj
|
||||
: [obj]
|
||||
}
|
||||
|
||||
function nullify<T = unknown>(value: T) {
|
||||
return (value && (value as unknown[]).length)
|
||||
? value
|
||||
: null
|
||||
}
|
||||
|
||||
// Events to emit, copied from flatpickr source
|
||||
const includedEvents = [
|
||||
'onChange',
|
||||
'onClose',
|
||||
'onDestroy',
|
||||
'onMonthChange',
|
||||
'onOpen',
|
||||
'onYearChange',
|
||||
] as HookKey[]
|
||||
|
||||
// Let's not emit these events by default
|
||||
const excludedEvents = [
|
||||
'onValueUpdate',
|
||||
'onDayCreate',
|
||||
'onParseConfig',
|
||||
'onReady',
|
||||
'onPreCalendarPosition',
|
||||
'onKeyDown',
|
||||
] as HookKey[]
|
||||
|
||||
// Keep a copy of all events for later use
|
||||
const allEvents = includedEvents.concat(excludedEvents)
|
||||
|
||||
export default {inheritAttrs: false}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, onBeforeUnmount, onMounted, ref, toRefs, useAttrs, watch, watchEffect, type PropType} from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: [String, Number, Date, Array] as PropType<DateOption | DateOption[]>,
|
||||
default: null,
|
||||
required: true,
|
||||
// validator(value) {
|
||||
// return (
|
||||
// value === null ||
|
||||
// value instanceof Date ||
|
||||
// typeof value === 'string' ||
|
||||
// value instanceof String ||
|
||||
// value instanceof Array ||
|
||||
// typeof value === 'number'
|
||||
// );
|
||||
// }
|
||||
},
|
||||
// https://flatpickr.js.org/options/
|
||||
config: {
|
||||
type: Object as PropType<Options>,
|
||||
default: () => ({
|
||||
defaultDate: null,
|
||||
wrap: false,
|
||||
}),
|
||||
},
|
||||
events: {
|
||||
type: Array as PropType<HookKey[]>,
|
||||
default: () => includedEvents,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'blur',
|
||||
'update:modelValue',
|
||||
...allEvents.map(camelToKebab),
|
||||
])
|
||||
|
||||
const {modelValue, config, disabled} = toRefs(props)
|
||||
|
||||
// bind listener like onBlur
|
||||
const attrs = useAttrs()
|
||||
|
||||
const root = ref<HTMLInputElement | null>(null)
|
||||
const fp = ref<flatpickr.Instance | null>(null)
|
||||
const safeConfig = ref<Options>({ ...props.config })
|
||||
|
||||
function prepareConfig() {
|
||||
// Don't mutate original object on parent component
|
||||
const newConfig: Options = { ...props.config }
|
||||
|
||||
props.events.forEach((hook) => {
|
||||
// Respect global callbacks registered via setDefault() method
|
||||
const globalCallbacks = flatpickr.defaultConfig[hook] || []
|
||||
|
||||
// Inject our own method along with user callback
|
||||
const localCallback: Hook = (...args) => emit(camelToKebab(hook), ...args)
|
||||
|
||||
// Overwrite with merged array
|
||||
newConfig[hook] = arrayify(newConfig[hook] || []).concat(
|
||||
globalCallbacks,
|
||||
localCallback,
|
||||
)
|
||||
})
|
||||
|
||||
// Watch for value changed by date-picker itself and notify parent component
|
||||
const onChange: Hook = (dates) => emit('update:modelValue', dates)
|
||||
newConfig['onChange'] = arrayify(newConfig['onChange'] || []).concat(onChange)
|
||||
|
||||
// Flatpickr does not emit input event in some cases
|
||||
// const onClose: Hook = (_selectedDates, dateStr) => emit('update:modelValue', dateStr)
|
||||
// newConfig['onClose'] = arrayify(newConfig['onClose'] || []).concat(onClose)
|
||||
|
||||
// Set initial date without emitting any event
|
||||
newConfig.defaultDate = props.modelValue || newConfig.defaultDate
|
||||
|
||||
safeConfig.value = newConfig
|
||||
|
||||
return safeConfig.value
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (
|
||||
fp.value || // Return early if flatpickr is already loaded
|
||||
!root.value // our input needs to be mounted
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
prepareConfig()
|
||||
|
||||
/**
|
||||
* Get the HTML node where flatpickr to be attached
|
||||
* Bind on parent element if wrap is true
|
||||
*/
|
||||
const element = props.config.wrap
|
||||
? root.value.parentNode
|
||||
: root.value
|
||||
|
||||
// Init flatpickr
|
||||
fp.value = flatpickr(element, safeConfig.value)
|
||||
})
|
||||
onBeforeUnmount(() => fp.value?.destroy())
|
||||
|
||||
watch(config, () => {
|
||||
if (!fp.value) return
|
||||
// Workaround: Don't pass hooks to configs again otherwise
|
||||
// previously registered hooks will stop working
|
||||
// Notice: we are looping through all events
|
||||
// This also means that new callbacks can not be passed once component has been initialized
|
||||
allEvents.forEach((hook) => {
|
||||
delete safeConfig.value?.[hook]
|
||||
})
|
||||
fp.value.set(safeConfig.value)
|
||||
|
||||
// Passing these properties in `set()` method will cause flatpickr to trigger some callbacks
|
||||
const configCallbacks = ['locale', 'showMonths'] as (keyof Options)[]
|
||||
|
||||
// Workaround: Allow to change locale dynamically
|
||||
configCallbacks.forEach(name => {
|
||||
if (typeof safeConfig.value?.[name] !== 'undefined' && fp.value) {
|
||||
fp.value.set(name, safeConfig.value[name])
|
||||
}
|
||||
})
|
||||
}, {deep:true})
|
||||
|
||||
const fpInput = computed(() => {
|
||||
if (!fp.value) return
|
||||
return fp.value.altInput || fp.value.input
|
||||
})
|
||||
|
||||
/**
|
||||
* init blur event
|
||||
* (is required by many validation libraries)
|
||||
*/
|
||||
function onBlur(event: Event) {
|
||||
emit('blur', nullify((event.target as HTMLInputElement).value))
|
||||
}
|
||||
|
||||
watchEffect(() => fpInput.value?.addEventListener('blur', onBlur))
|
||||
onBeforeUnmount(() => fpInput.value?.removeEventListener('blur', onBlur))
|
||||
|
||||
/**
|
||||
* Watch for the disabled property and sets the value to the real input.
|
||||
*/
|
||||
watchEffect(() => {
|
||||
if (disabled.value) {
|
||||
fpInput.value?.setAttribute('disabled', '')
|
||||
} else {
|
||||
fpInput.value?.removeAttribute('disabled')
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Watch for changes from parent component and update DOM
|
||||
*/
|
||||
watch(
|
||||
modelValue,
|
||||
newValue => {
|
||||
// Prevent updates if v-model value is same as input's current value
|
||||
if (!root.value || newValue === nullify(root.value.value)) return
|
||||
// Make sure we have a flatpickr instance and
|
||||
// notify flatpickr instance that there is a change in value
|
||||
fp.value?.setDate(newValue, true)
|
||||
},
|
||||
{deep: true},
|
||||
)
|
||||
</script>
|
@ -2,6 +2,12 @@
|
||||
<div class="loader-container is-loading"></div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
inheritAttrs: false,
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.loader-container {
|
||||
height: 100%;
|
||||
|
255
src/components/tasks/GanttChart.vue
Normal file
255
src/components/tasks/GanttChart.vue
Normal file
@ -0,0 +1,255 @@
|
||||
<template>
|
||||
<Loading
|
||||
v-if="props.isLoading && tasks.size || dayjsLanguageLoading"
|
||||
class="gantt-container"
|
||||
/>
|
||||
<div class="gantt-container" v-else>
|
||||
<GGanttChart
|
||||
:date-format="DAYJS_ISO_DATE_FORMAT"
|
||||
:chart-start="isoToKebabDate(filters.dateFrom)"
|
||||
:chart-end="isoToKebabDate(filters.dateTo)"
|
||||
precision="day"
|
||||
bar-start="startDate"
|
||||
bar-end="endDate"
|
||||
:grid="true"
|
||||
@dragend-bar="updateGanttTask"
|
||||
@dblclick-bar="openTask"
|
||||
:width="ganttChartWidth + 'px'"
|
||||
>
|
||||
<template #timeunit="{label, value}">
|
||||
<div
|
||||
class="timeunit-wrapper"
|
||||
:class="{'today': dayIsToday(label)}"
|
||||
>
|
||||
<span>{{ value }}</span>
|
||||
<span class="weekday">
|
||||
{{ weekdayFromTimeLabel(label) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<GGanttRow
|
||||
v-for="(bar, k) in ganttBars"
|
||||
:key="k"
|
||||
label=""
|
||||
:bars="bar"
|
||||
/>
|
||||
</GGanttChart>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, ref, watch, toRefs} from 'vue'
|
||||
import {useRouter} from 'vue-router'
|
||||
import {format, parse} from 'date-fns'
|
||||
import dayjs from 'dayjs'
|
||||
import isToday from 'dayjs/plugin/isToday'
|
||||
|
||||
import {getHexColor} from '@/models/task'
|
||||
|
||||
import {colorIsDark} from '@/helpers/color/colorIsDark'
|
||||
import {isoToKebabDate} from '@/helpers/time/isoToKebabDate'
|
||||
import {parseKebabDate} from '@/helpers/time/parseKebabDate'
|
||||
|
||||
import type {ITask, ITaskPartialWithId} from '@/modelTypes/ITask'
|
||||
import type {DateISO} from '@/types/DateISO'
|
||||
import type {GanttFilters} from '@/views/list/helpers/useGanttFilters'
|
||||
|
||||
import {
|
||||
extendDayjs,
|
||||
GGanttChart,
|
||||
GGanttRow,
|
||||
type GanttBarObject,
|
||||
} from '@infectoone/vue-ganttastic'
|
||||
|
||||
import Loading from '@/components/misc/loading.vue'
|
||||
import {MILLISECONDS_A_DAY} from '@/constants/date'
|
||||
|
||||
export interface GanttChartProps {
|
||||
isLoading: boolean,
|
||||
filters: GanttFilters,
|
||||
tasks: Map<ITask['id'], ITask>,
|
||||
defaultTaskStartDate: DateISO
|
||||
defaultTaskEndDate: DateISO
|
||||
}
|
||||
|
||||
const DAYJS_ISO_DATE_FORMAT = 'YYYY-MM-DD'
|
||||
|
||||
const props = defineProps<GanttChartProps>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:task', task: ITaskPartialWithId): void
|
||||
}>()
|
||||
|
||||
const {tasks, filters} = toRefs(props)
|
||||
|
||||
// setup dayjs for vue-ganttastic
|
||||
const dayjsLanguageLoading = ref(false)
|
||||
// const dayjsLanguageLoading = useDayjsLanguageSync(dayjs)
|
||||
dayjs.extend(isToday)
|
||||
extendDayjs()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const dateFromDate = computed(() => new Date(new Date(filters.value.dateFrom).setHours(0,0,0,0)))
|
||||
const dateToDate = computed(() => new Date(new Date(filters.value.dateTo).setHours(23,59,0,0)))
|
||||
|
||||
const DAY_WIDTH_PIXELS = 30
|
||||
const ganttChartWidth = computed(() => {
|
||||
const dateDiff = Math.floor((dateToDate.value.valueOf() - dateFromDate.value.valueOf()) / MILLISECONDS_A_DAY)
|
||||
|
||||
return dateDiff * DAY_WIDTH_PIXELS
|
||||
})
|
||||
|
||||
const ganttBars = ref<GanttBarObject[][]>([])
|
||||
|
||||
/**
|
||||
* Update ganttBars when tasks change
|
||||
*/
|
||||
watch(
|
||||
tasks,
|
||||
() => {
|
||||
ganttBars.value = []
|
||||
tasks.value.forEach(t => ganttBars.value.push(transformTaskToGanttBar(t)))
|
||||
},
|
||||
{deep: true, immediate: true},
|
||||
)
|
||||
|
||||
function transformTaskToGanttBar(t: ITask) {
|
||||
const black = 'var(--grey-800)'
|
||||
return [{
|
||||
startDate: isoToKebabDate(t.startDate ? t.startDate.toISOString() : props.defaultTaskStartDate),
|
||||
endDate: isoToKebabDate(t.endDate ? t.endDate.toISOString() : props.defaultTaskEndDate),
|
||||
ganttBarConfig: {
|
||||
id: String(t.id),
|
||||
label: t.title,
|
||||
hasHandles: true,
|
||||
style: {
|
||||
color: t.startDate ? (colorIsDark(getHexColor(t.hexColor)) ? black : 'white') : black,
|
||||
backgroundColor: t.startDate ? getHexColor(t.hexColor) : 'var(--grey-100)',
|
||||
border: t.startDate ? '' : '2px dashed var(--grey-300)',
|
||||
'text-decoration': t.done ? 'line-through' : null,
|
||||
},
|
||||
},
|
||||
} as GanttBarObject]
|
||||
}
|
||||
|
||||
async function updateGanttTask(e: {
|
||||
bar: GanttBarObject;
|
||||
e: MouseEvent;
|
||||
datetime?: string | undefined;
|
||||
}) {
|
||||
emit('update:task', {
|
||||
id: Number(e.bar.ganttBarConfig.id),
|
||||
startDate: new Date(parseKebabDate(e.bar.startDate).setHours(0,0,0,0)),
|
||||
endDate: new Date(parseKebabDate(e.bar.endDate).setHours(23,59,0,0)),
|
||||
})
|
||||
}
|
||||
|
||||
function openTask(e: {
|
||||
bar: GanttBarObject;
|
||||
e: MouseEvent;
|
||||
datetime?: string | undefined;
|
||||
}) {
|
||||
router.push({
|
||||
name: 'task.detail',
|
||||
params: {id: e.bar.ganttBarConfig.id},
|
||||
state: {backdropView: router.currentRoute.value.fullPath},
|
||||
})
|
||||
}
|
||||
|
||||
function parseTimeLabel(label: string) {
|
||||
return parse(label, 'dd.MMM', dateFromDate.value)
|
||||
}
|
||||
|
||||
function weekdayFromTimeLabel(label: string): string {
|
||||
const parsed = parseTimeLabel(label)
|
||||
return format(parsed, 'E')
|
||||
}
|
||||
|
||||
function dayIsToday(label: string): boolean {
|
||||
const parsed = parseTimeLabel(label)
|
||||
|
||||
const today = new Date()
|
||||
return parsed.getDate() === today.getDate() &&
|
||||
parsed.getMonth() === today.getMonth() &&
|
||||
parsed.getFullYear() === today.getFullYear()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.gantt-container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
<style lang="scss">
|
||||
// Not scoped because we need to style the elements inside the gantt chart component
|
||||
.g-gantt-chart {
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.g-gantt-row-label {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.g-upper-timeunit, .g-timeunit {
|
||||
background: var(--white) !important;
|
||||
font-family: $vikunja-font;
|
||||
}
|
||||
|
||||
.g-upper-timeunit {
|
||||
font-weight: bold;
|
||||
border-right: 1px solid var(--grey-200);
|
||||
padding: .5rem 0;
|
||||
}
|
||||
|
||||
.g-timeunit .timeunit-wrapper {
|
||||
padding: 0.5rem 0;
|
||||
font-size: 1rem !important;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
|
||||
&.today {
|
||||
background: var(--primary);
|
||||
color: var(--white);
|
||||
border-radius: 5px 5px 0 0;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.weekday {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
.g-timeaxis {
|
||||
height: auto !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.g-gantt-row > .g-gantt-row-bars-container {
|
||||
border-bottom: none !important;
|
||||
border-top: none !important;
|
||||
}
|
||||
|
||||
.g-gantt-row:nth-child(odd) {
|
||||
background: hsla(var(--grey-100-hsl), .5);
|
||||
}
|
||||
|
||||
.g-gantt-bar {
|
||||
border-radius: $radius * 1.5;
|
||||
overflow: visible;
|
||||
font-size: .85rem;
|
||||
|
||||
&-handle-left,
|
||||
&-handle-right {
|
||||
width: 6px !important;
|
||||
height: 75% !important;
|
||||
opacity: .75 !important;
|
||||
border-radius: $radius !important;
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
</style>
|
78
src/components/tasks/TaskForm.vue
Normal file
78
src/components/tasks/TaskForm.vue
Normal file
@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<form
|
||||
@submit.prevent="createTask"
|
||||
class="add-new-task"
|
||||
>
|
||||
<transition name="width">
|
||||
<input
|
||||
v-if="newTaskFieldActive"
|
||||
v-model="newTaskTitle"
|
||||
@blur="hideCreateNewTask"
|
||||
@keyup.esc="newTaskFieldActive = false"
|
||||
class="input"
|
||||
ref="newTaskTitleField"
|
||||
type="text"
|
||||
/>
|
||||
</transition>
|
||||
<x-button @click="showCreateTaskOrCreate" :shadow="false" icon="plus">
|
||||
{{ $t('task.new') }}
|
||||
</x-button>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {nextTick, ref} from 'vue'
|
||||
import type {ITask} from '@/modelTypes/ITask'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'create-task', title: string): Promise<ITask>
|
||||
}>()
|
||||
|
||||
const newTaskFieldActive = ref(false)
|
||||
const newTaskTitleField = ref()
|
||||
const newTaskTitle = ref('')
|
||||
|
||||
function showCreateTaskOrCreate() {
|
||||
if (!newTaskFieldActive.value) {
|
||||
// Timeout to not send the form if the field isn't even shown
|
||||
setTimeout(() => {
|
||||
newTaskFieldActive.value = true
|
||||
nextTick(() => newTaskTitleField.value.focus())
|
||||
}, 100)
|
||||
} else {
|
||||
createTask()
|
||||
}
|
||||
}
|
||||
|
||||
function hideCreateNewTask() {
|
||||
if (newTaskTitle.value === '') {
|
||||
nextTick(() => (newTaskFieldActive.value = false))
|
||||
}
|
||||
}
|
||||
|
||||
async function createTask() {
|
||||
if (!newTaskFieldActive.value) {
|
||||
return
|
||||
}
|
||||
await emit('create-task', newTaskTitle.value)
|
||||
newTaskTitle.value = ''
|
||||
hideCreateNewTask()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.add-new-task {
|
||||
padding: 1rem .7rem .4rem .7rem;
|
||||
display: flex;
|
||||
max-width: 450px;
|
||||
|
||||
.input {
|
||||
margin-right: .7rem;
|
||||
font-size: .8rem;
|
||||
}
|
||||
|
||||
.button {
|
||||
font-size: .68rem;
|
||||
}
|
||||
}
|
||||
</style>
|
@ -1,642 +0,0 @@
|
||||
<template>
|
||||
<div class="gantt-chart">
|
||||
<div class="filter-container">
|
||||
<div class="items">
|
||||
<filter-popup
|
||||
v-model="params"
|
||||
@update:modelValue="loadTasks()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dates">
|
||||
<template v-for="(y, yk) in days" :key="yk + 'year'">
|
||||
<div class="months">
|
||||
<div
|
||||
:key="mk + 'month'"
|
||||
class="month"
|
||||
v-for="(m, mk) in days[yk]"
|
||||
>
|
||||
{{ formatMonthAndYear(yk, parseInt(mk) + 1) }}
|
||||
<div class="days">
|
||||
<div
|
||||
:class="{ today: d.toDateString() === now.toDateString() }"
|
||||
:key="dk + 'day'"
|
||||
:style="{ width: dayWidth + 'px' }"
|
||||
class="day"
|
||||
v-for="(d, dk) in days[yk][mk]"
|
||||
>
|
||||
<span class="theday" v-if="dayWidth > 25">
|
||||
{{ d.getDate() }}
|
||||
</span>
|
||||
<span class="weekday" v-if="dayWidth > 25">
|
||||
{{
|
||||
d.toLocaleString('en-us', {
|
||||
weekday: 'short',
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div :style="{ width: fullWidth + 'px' }" class="tasks">
|
||||
<div
|
||||
v-for="(t, k) in theTasks"
|
||||
:key="t ? t.id : 0"
|
||||
:style="{
|
||||
background:
|
||||
'repeating-linear-gradient(90deg, #ededed, #ededed 1px, ' +
|
||||
(k % 2 === 0
|
||||
? '#fafafa 1px, #fafafa '
|
||||
: '#fff 1px, #fff ') +
|
||||
dayWidth +
|
||||
'px)',
|
||||
}"
|
||||
class="row"
|
||||
>
|
||||
<VueDragResize
|
||||
:class="{
|
||||
done: t ? t.done : false,
|
||||
'is-current-edit': taskToEdit !== null && taskToEdit.id === t.id,
|
||||
'has-light-text': !colorIsDark(t.getHexColor()),
|
||||
'has-dark-text': colorIsDark(t.getHexColor()),
|
||||
}"
|
||||
:gridX="dayWidth"
|
||||
:h="31"
|
||||
:isActive="canWrite"
|
||||
:minw="dayWidth"
|
||||
:parentLimitation="true"
|
||||
:parentW="fullWidth"
|
||||
:snapToGrid="true"
|
||||
:sticks="['mr', 'ml']"
|
||||
:style="{
|
||||
'border-color': t.getHexColor(),
|
||||
'background-color': t.getHexColor(),
|
||||
}"
|
||||
:w="t.durationDays * dayWidth"
|
||||
:x="t.offsetDays * dayWidth - 6"
|
||||
:y="0"
|
||||
@dragstop="(e) => resizeTask(t, e)"
|
||||
@resizestop="(e) => resizeTask(t, e)"
|
||||
axis="x"
|
||||
class="task"
|
||||
>
|
||||
<span
|
||||
:class="{
|
||||
'has-high-priority': t.priority >= priorities.HIGH,
|
||||
'has-not-so-high-priority':
|
||||
t.priority === priorities.HIGH,
|
||||
'has-super-high-priority':
|
||||
t.priority === priorities.DO_NOW,
|
||||
}"
|
||||
>
|
||||
{{ t.title }}
|
||||
</span>
|
||||
<priority-label :priority="t.priority" :done="t.done"/>
|
||||
<!-- using the key here forces vue to use the updated version model and not the response returned by the api -->
|
||||
<!-- FIXME: add label -->
|
||||
<BaseButton @click="editTask(theTasks[k])" class="edit-toggle">
|
||||
<icon icon="pen"/>
|
||||
</BaseButton>
|
||||
</VueDragResize>
|
||||
</div>
|
||||
<template v-if="showTaskswithoutDates">
|
||||
<div
|
||||
:key="t.id"
|
||||
:style="{
|
||||
background:
|
||||
'repeating-linear-gradient(90deg, #ededed, #ededed 1px, ' +
|
||||
(k % 2 === 0
|
||||
? '#fafafa 1px, #fafafa '
|
||||
: '#fff 1px, #fff ') +
|
||||
dayWidth +
|
||||
'px)',
|
||||
}"
|
||||
class="row"
|
||||
v-for="(t, k) in tasksWithoutDates"
|
||||
>
|
||||
<VueDragResize
|
||||
:gridX="dayWidth"
|
||||
:h="31"
|
||||
:isActive="canWrite"
|
||||
:minw="dayWidth"
|
||||
:parentLimitation="true"
|
||||
:parentW="fullWidth"
|
||||
:snapToGrid="true"
|
||||
:sticks="['mr', 'ml']"
|
||||
:x="dayOffsetUntilToday * dayWidth - 6"
|
||||
:y="0"
|
||||
@dragstop="(e) => resizeTask(t, e)"
|
||||
@resizestop="(e) => resizeTask(t, e)"
|
||||
axis="x"
|
||||
class="task nodate"
|
||||
v-tooltip="$t('list.gantt.noDates')"
|
||||
>
|
||||
<span>{{ t.title }}</span>
|
||||
</VueDragResize>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<form
|
||||
@submit.prevent="addNewTask()"
|
||||
class="add-new-task"
|
||||
v-if="canWrite"
|
||||
>
|
||||
<transition name="width">
|
||||
<input
|
||||
@blur="hideCrateNewTask"
|
||||
@keyup.esc="newTaskFieldActive = false"
|
||||
class="input"
|
||||
ref="newTaskTitleField"
|
||||
type="text"
|
||||
v-if="newTaskFieldActive"
|
||||
v-model="newTaskTitle"
|
||||
/>
|
||||
</transition>
|
||||
<x-button @click="showCreateNewTask" :shadow="false" icon="plus">
|
||||
{{ $t('list.list.newTaskCta') }}
|
||||
</x-button>
|
||||
</form>
|
||||
<transition name="fade">
|
||||
<edit-task
|
||||
v-if="isTaskEdit"
|
||||
class="taskedit"
|
||||
:title="$t('list.list.editTask')"
|
||||
@close="() => {isTaskEdit = false;taskToEdit = null}"
|
||||
:task="taskToEdit"
|
||||
/>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {defineComponent} from 'vue'
|
||||
import {mapState} from 'pinia'
|
||||
|
||||
import VueDragResize from 'vue-drag-resize'
|
||||
import EditTask from './edit-task.vue'
|
||||
|
||||
import TaskService from '../../services/task'
|
||||
import TaskModel from '../../models/task'
|
||||
import {PRIORITIES as priorities} from '@/constants/priorities'
|
||||
import PriorityLabel from './partials/priorityLabel.vue'
|
||||
import TaskCollectionService from '../../services/taskCollection'
|
||||
import {RIGHTS as Rights} from '@/constants/rights'
|
||||
import FilterPopup from '@/components/list/partials/filter-popup.vue'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
|
||||
import {colorIsDark} from '@/helpers/color/colorIsDark'
|
||||
import {formatDate} from '@/helpers/time/formatDate'
|
||||
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'GanttChart',
|
||||
components: {
|
||||
BaseButton,
|
||||
FilterPopup,
|
||||
PriorityLabel,
|
||||
EditTask,
|
||||
VueDragResize,
|
||||
},
|
||||
props: {
|
||||
listId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
showTaskswithoutDates: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
dateFrom: {
|
||||
default: () => new Date(new Date().setDate(new Date().getDate() - 15)),
|
||||
},
|
||||
dateTo: {
|
||||
default: () => new Date(new Date().setDate(new Date().getDate() + 30)),
|
||||
},
|
||||
// The width of a day in pixels, used to calculate all sorts of things.
|
||||
dayWidth: {
|
||||
type: Number,
|
||||
default: 35,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
days: [],
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
theTasks: [], // Pretty much a copy of the prop, since we cant mutate the prop directly
|
||||
tasksWithoutDates: [],
|
||||
taskService: new TaskService(),
|
||||
fullWidth: 0,
|
||||
now: new Date(),
|
||||
dayOffsetUntilToday: 0,
|
||||
isTaskEdit: false,
|
||||
taskToEdit: null,
|
||||
newTaskTitle: '',
|
||||
newTaskFieldActive: false,
|
||||
priorities: priorities,
|
||||
taskCollectionService: new TaskCollectionService(),
|
||||
|
||||
params: {
|
||||
sort_by: ['done', 'id'],
|
||||
order_by: ['asc', 'desc'],
|
||||
filter_by: ['done'],
|
||||
filter_value: ['false'],
|
||||
filter_comparator: ['equals'],
|
||||
filter_concat: 'and',
|
||||
},
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
dateFrom: 'buildTheGanttChart',
|
||||
dateTo: 'buildTheGanttChart',
|
||||
listId: 'parseTasks',
|
||||
},
|
||||
mounted() {
|
||||
this.buildTheGanttChart()
|
||||
},
|
||||
computed: mapState(useBaseStore, {
|
||||
canWrite: (state) => state.currentList.maxRight > Rights.READ,
|
||||
}),
|
||||
methods: {
|
||||
colorIsDark,
|
||||
buildTheGanttChart() {
|
||||
this.setDates()
|
||||
this.prepareGanttDays()
|
||||
this.parseTasks()
|
||||
},
|
||||
setDates() {
|
||||
this.startDate = new Date(this.dateFrom)
|
||||
this.endDate = new Date(this.dateTo)
|
||||
console.debug('setDates; start date: ', this.startDate, 'end date:', this.endDate, 'date from:', this.dateFrom, 'date to:', this.dateTo)
|
||||
|
||||
this.dayOffsetUntilToday = Math.floor((this.now - this.startDate) / 1000 / 60 / 60 / 24) + 1
|
||||
},
|
||||
prepareGanttDays() {
|
||||
console.debug('prepareGanttDays; start date: ', this.startDate, 'end date:', this.endDate)
|
||||
// Layout: years => [months => [days]]
|
||||
const years = {}
|
||||
for (
|
||||
let d = this.startDate;
|
||||
d <= this.endDate;
|
||||
d.setDate(d.getDate() + 1)
|
||||
) {
|
||||
const date = new Date(d)
|
||||
if (years[date.getFullYear() + ''] === undefined) {
|
||||
years[date.getFullYear() + ''] = {}
|
||||
}
|
||||
if (years[date.getFullYear() + ''][date.getMonth() + ''] === undefined) {
|
||||
years[date.getFullYear() + ''][date.getMonth() + ''] = []
|
||||
}
|
||||
years[date.getFullYear() + ''][date.getMonth() + ''].push(date)
|
||||
this.fullWidth += this.dayWidth
|
||||
}
|
||||
console.debug('prepareGanttDays; years:', years)
|
||||
this.days = years
|
||||
},
|
||||
|
||||
parseTasks() {
|
||||
this.setDates()
|
||||
this.loadTasks()
|
||||
},
|
||||
|
||||
async loadTasks() {
|
||||
this.theTasks = []
|
||||
this.tasksWithoutDates = []
|
||||
|
||||
const getAllTasks = async (page = 1) => {
|
||||
const tasks = await this.taskCollectionService.getAll({listId: this.listId}, this.params, page)
|
||||
if (page < this.taskCollectionService.totalPages) {
|
||||
const nextTasks = await getAllTasks(page + 1)
|
||||
return tasks.concat(nextTasks)
|
||||
}
|
||||
return tasks
|
||||
}
|
||||
|
||||
const tasks = await getAllTasks()
|
||||
this.theTasks = tasks
|
||||
.filter((t) => {
|
||||
if (t.startDate === null && !t.done) {
|
||||
this.tasksWithoutDates.push(t)
|
||||
}
|
||||
return (
|
||||
t.startDate >= this.startDate &&
|
||||
t.endDate <= this.endDate
|
||||
)
|
||||
})
|
||||
.map((t) => this.addGantAttributes(t))
|
||||
.sort(function (a, b) {
|
||||
if (a.startDate < b.startDate) return -1
|
||||
if (a.startDate > b.startDate) return 1
|
||||
return 0
|
||||
})
|
||||
},
|
||||
addGantAttributes(t) {
|
||||
if (typeof t.durationDays !== 'undefined' && typeof t.offsetDays !== 'undefined') {
|
||||
return t
|
||||
}
|
||||
|
||||
t.endDate === null ? this.endDate : t.endDate
|
||||
t.durationDays = Math.floor((t.endDate - t.startDate) / 1000 / 60 / 60 / 24)
|
||||
t.offsetDays = Math.floor((t.startDate - this.startDate) / 1000 / 60 / 60 / 24)
|
||||
return t
|
||||
},
|
||||
async resizeTask(taskDragged, newRect) {
|
||||
if (this.isTaskEdit) {
|
||||
return
|
||||
}
|
||||
|
||||
let newTask = {...taskDragged}
|
||||
|
||||
const didntHaveDates = newTask.startDate === null ? true : false
|
||||
|
||||
const startDate = new Date(this.startDate)
|
||||
startDate.setDate(
|
||||
startDate.getDate() + newRect.left / this.dayWidth,
|
||||
)
|
||||
startDate.setUTCHours(0)
|
||||
startDate.setUTCMinutes(0)
|
||||
startDate.setUTCSeconds(0)
|
||||
startDate.setUTCMilliseconds(0)
|
||||
newTask.startDate = startDate
|
||||
const endDate = new Date(startDate)
|
||||
endDate.setDate(
|
||||
startDate.getDate() + newRect.width / this.dayWidth,
|
||||
)
|
||||
newTask.startDate = startDate
|
||||
newTask.endDate = endDate
|
||||
|
||||
// We take the task from the overall tasks array because the one in it has bad data after it was updated once.
|
||||
// FIXME: This is a workaround. We should use a better mechanism to get the task or, even better,
|
||||
// preven |
What are the flatpickr changes doing in this PR?