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
17 changed files with 659 additions and 158 deletions
Showing only changes of commit 9f146c8c7f - Show all commits

View File

@ -38,6 +38,7 @@
"camel-case": "4.1.2",
"codemirror": "5.65.9",
"date-fns": "2.29.3",
"dayjs": "^1.11.5",
"dompurify": "2.4.0",
"easymde": "2.18.0",
"flatpickr": "4.6.13",

View File

@ -42,6 +42,7 @@ specifiers:
codemirror: 5.65.9
cypress: 10.9.0
date-fns: 2.29.3
dayjs: ^1.11.5
dompurify: 2.4.0
easymde: 2.18.0
esbuild: 0.15.10
@ -80,7 +81,7 @@ specifiers:
vue-flatpickr-component: 9.0.6
vue-i18n: 9.2.2
vue-router: 4.1.5
vue-tsc: 0.40.13
vue-tsc: 1.0.2
wait-on: 6.0.1
workbox-cli: 6.5.4
workbox-precaching: 6.5.4
@ -107,6 +108,7 @@ dependencies:
camel-case: 4.1.2
codemirror: 5.65.9
date-fns: 2.29.3
dayjs: 1.11.5
dompurify: 2.4.0
easymde: 2.18.0
flatpickr: 4.6.13
@ -170,7 +172,7 @@ devDependencies:
vite-plugin-pwa: 0.13.1_bhe5iaipiq3lmbaxwdxgnnn2gq
vite-svg-loader: 3.6.0
vitest: 0.23.4_k3tqzgv3vrnpmbq3o6ks4hg4vi
vue-tsc: 0.40.13_typescript@4.8.4
vue-tsc: 1.0.2_typescript@4.8.4
wait-on: 6.0.1
workbox-cli: 6.5.4
@ -3385,42 +3387,44 @@ packages:
vue: 3.2.40
dev: true
/@volar/code-gen/0.40.13:
resolution: {integrity: sha512-4gShBWuMce868OVvgyA1cU5WxHbjfEme18Tw6uVMfweZCF5fB2KECG0iPrA9D54vHk3FeHarODNwgIaaFfUBlA==}
/@volar/language-core/1.0.2:
resolution: {integrity: sha512-kNzrnZYxNWcIgU8b1Yzm4pDb4fEM/LNicWLyVOxaNRvemU5Sh4ORDzKihI5AkzGGkvy0F/67xVe7/5/o9U1oPg==}
dependencies:
'@volar/source-map': 0.40.13
'@volar/source-map': 1.0.2
'@vue/reactivity': 3.2.40
muggle-string: 0.1.0
dev: true
/@volar/source-map/0.40.13:
resolution: {integrity: sha512-dbdkAB2Nxb0wLjAY5O64o3ywVWlAGONnBIoKAkXSf6qkGZM+nJxcizsoiI66K+RHQG0XqlyvjDizfnTxr+6PWg==}
/@volar/source-map/1.0.2:
resolution: {integrity: sha512-MizyyDw+Eg704bSVkS/8MeP7G4t5U4ZmaXdA2SW3cYAncQKn10P2r6eSyI/8vno+y8wgpM3k9aRfineXB1fMkQ==}
dependencies:
'@vue/reactivity': 3.2.38
muggle-string: 0.1.0
dev: true
/@volar/typescript-faster/0.40.13:
resolution: {integrity: sha512-uy+TlcFkKoNlKEnxA4x5acxdxLyVDIXGSc8cYDNXpPKjBKXrQaetzCzlO3kVBqu1VLMxKNGJMTKn35mo+ILQmw==}
/@volar/typescript/1.0.2:
resolution: {integrity: sha512-yQ6ze+gmf+jYTT/Y+IIH9cBE2YF3I92N6vGfdrVgwnDOj1jnulvJ5viwdTYcAIeEQRB0qtwYfRpSHOrGG2FL2w==}
dependencies:
semver: 7.3.8
'@volar/language-core': 1.0.2
dev: true
/@volar/vue-language-core/0.40.13:
resolution: {integrity: sha512-QkCb8msi2KUitTdM6Y4kAb7/ZlEvuLcbBFOC2PLBlFuoZwyxvSP7c/dBGmKGtJlEvMX0LdCyrg5V2aBYxD38/Q==}
/@volar/vue-language-core/1.0.2:
resolution: {integrity: sha512-SiFHeXu0yWl9N+sIrvQ+iIh/P2oMCBkUT6XFhhh6RCY01KNKc7/iO/xB/OJlO9yl/6mAiFwIVdtPcp6GNOkb3g==}
dependencies:
'@volar/code-gen': 0.40.13
'@volar/source-map': 0.40.13
'@vue/compiler-core': 3.2.40
'@volar/language-core': 1.0.2
'@volar/source-map': 1.0.2
'@vue/compiler-dom': 3.2.40
'@vue/compiler-sfc': 3.2.40
'@vue/reactivity': 3.2.40
'@vue/shared': 3.2.40
minimatch: 5.1.0
vue-template-compiler: 2.7.10
dev: true
/@volar/vue-typescript/0.40.13:
resolution: {integrity: sha512-o7bNztwjs8JmbQjVkrnbZUOfm7q4B8ZYssETISN1tRaBdun6cfNqgpkvDYd+VUBh1O4CdksvN+5BUNnwAz4oCQ==}
/@volar/vue-typescript/1.0.2:
resolution: {integrity: sha512-wOUebVHS8ENF0uMGvFdnkjVdVnf80SHUKbCoCkgngxeVFQBvAlEShcm/XT8bSX3QKoNPl1ZGqYN4UQRgZFyaow==}
dependencies:
'@volar/code-gen': 0.40.13
'@volar/typescript-faster': 0.40.13
'@volar/vue-language-core': 0.40.13
'@volar/typescript': 1.0.2
'@volar/vue-language-core': 1.0.2
dev: true
/@vue/compiler-core/3.2.40:
@ -3491,12 +3495,6 @@ packages:
estree-walker: 2.0.2
magic-string: 0.25.9
/@vue/reactivity/3.2.38:
resolution: {integrity: sha512-6L4myYcH9HG2M25co7/BSo0skKFHpAN8PhkNPM4xRVkyGl1K5M3Jx4rp5bsYhvYze2K4+l+pioN4e6ZwFLUVtw==}
dependencies:
'@vue/shared': 3.2.38
dev: true
/@vue/reactivity/3.2.40:
resolution: {integrity: sha512-N9qgGLlZmtUBMHF9xDT4EkD9RdXde1Xbveb+niWMXuHVWQP5BzgRmE3SFyUBBcyayG4y1lhoz+lphGRRxxK4RA==}
dependencies:
@ -3524,10 +3522,6 @@ packages:
'@vue/shared': 3.2.40
vue: 3.2.40
/@vue/shared/3.2.38:
resolution: {integrity: sha512-dTyhTIRmGXBjxJE+skC8tTWCGLCVc4wQgRRLt8+O9p5ewBAjoBwtCAkLPrtToSr1xltoe3st21Pv953aOZ7alg==}
dev: true
/@vue/shared/3.2.40:
resolution: {integrity: sha512-0PLQ6RUtZM0vO3teRfzGi4ltLUO5aO+kLgwh4Um3THSR03rpQWLTuRCkuO5A41ITzwdWeKdPHtSARuPkoo5pCQ==}
@ -5189,6 +5183,10 @@ packages:
/dayjs/1.11.5:
resolution: {integrity: sha512-CAdX5Q3YW3Gclyo5Vpqkgpj8fSdLQcRuzfX6mC6Phy0nfJ0eGYOeS7m4mt2plDWLAtA4TqTakvbboHvUxfe4iA==}
/de-indent/1.0.2:
resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==}
dev: true
/debounce/1.2.1:
resolution: {integrity: sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==}
dev: false
@ -9158,6 +9156,10 @@ packages:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
dev: true
/muggle-string/0.1.0:
resolution: {integrity: sha512-Tr1knR3d2mKvvWthlk7202rywKbiOm4rVFLsfAaSIhJ6dt9o47W4S+JMtWhd/PW9Wrdew2/S2fSvhz3E2gkfEg==}
dev: true
/multiparty/4.2.3:
resolution: {integrity: sha512-Ak6EUJZuhGS8hJ3c2fY6UW5MbkGUPMBEGd13djUzoY/BHqV/gTuFWtC6IuVA7A2+v3yjBS6c4or50xhzTQZImQ==}
engines: {node: '>= 0.10'}
@ -12761,14 +12763,21 @@ packages:
vue: 3.2.40
dev: false
/vue-tsc/0.40.13_typescript@4.8.4:
resolution: {integrity: sha512-xzuN3g5PnKfJcNrLv4+mAjteMd5wLm5fRhW0034OfNJZY4WhB07vhngea/XeGn7wNYt16r7syonzvW/54dcNiA==}
/vue-template-compiler/2.7.10:
resolution: {integrity: sha512-QO+8R9YRq1Gudm8ZMdo/lImZLJVUIAM8c07Vp84ojdDAf8HmPJc7XB556PcXV218k2AkKznsRz6xB5uOjAC4EQ==}
dependencies:
de-indent: 1.0.2
he: 1.2.0
dev: true
/vue-tsc/1.0.2_typescript@4.8.4:
resolution: {integrity: sha512-Atl8vBo1ThxZ8OjnCcquRCjRYczmQVF2nAjXVOqHA19Q4lvPX+bpbbDUhk8SDww6U10sttGc4Vjb+1xBwA4yow==}
hasBin: true
peerDependencies:
typescript: '*'
dependencies:
'@volar/vue-language-core': 0.40.13
'@volar/vue-typescript': 0.40.13
'@volar/vue-language-core': 1.0.2
'@volar/vue-typescript': 1.0.2
typescript: 4.8.4
dev: true

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,243 @@
<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 extends unknown>(obj: T) {
return obj instanceof Array ? obj : [obj];
}
function nullify<T extends unknown>(value: T) {
return (value && (value as unknown[]).length) ? value : null;
}
function cloneObject<T extends {}>(obj: T): T {
return Object.assign({}, obj)
}
// 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://chmln.github.io/flatpickr/options/
config: {
type: Object as PropType<Options>,
default: () => ({
wrap: false,
defaultDate: null
})
},
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)
let fp = ref<Flatpickr.Instance | null>(null)
const safeConfig = ref<Options>(cloneObject(props.config))
onMounted(() => {
// Don't mutate original object on parent component
let newConfig = cloneObject(props.config);
if (
fp.value || // Return early if flatpickr is already loaded
!root.value // our input needs to be mounted
) {
return
}
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
/**
* 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 as 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})
// watch(root, () => {
// if (
// fp.value || // Return early if flatpickr is already loaded
// !root.value // our input needs to be mounted
// ) {
// return
// }
// })
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 instanceand
// 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

@ -22,7 +22,7 @@
<script setup lang="ts">
import {nextTick, ref} from 'vue'
import type {ITask} from '@/models/task'
import type {ITask} from '@/modelTypes/ITask'
const emit = defineEmits<{
(e: 'create-task', title: string): Promise<ITask>

View File

@ -1,10 +1,14 @@
<template>
<Loading class="gantt-container" v-if="taskService.loading || taskCollectionService.loading"/>
<Loading
konrad marked this conversation as resolved Outdated

Use loading component. This way it's easier for us to refactor the is-loading styles from bulma later.

Use loading component. This way it's easier for us to refactor the `is-loading` styles from bulma later.

Done.

Done.
v-if="taskService.loading || taskCollectionService.loading || dayjsLanguageLoading"
class="gantt-container"
/>
<div class="gantt-container" v-else>
<GGanttChart
dateFormat="YYYY-MM-DDTHH:mm:ssZ[Z]"
:chart-start="`${dateFrom} 00:00`"
:chart-end="`${dateTo} 23:59`"
:precision="PRECISION"
precision="day"
bar-start="startDate"
konrad marked this conversation as resolved Outdated

Is it possible to use here simply inherit as value?

Is it possible to use here simply `inherit` as value?

Seems to work, yes.

Seems to work, yes.

Not necessary with lates release. Removed.

Not necessary with lates release. Removed.
bar-end="endDate"
:grid="true"
@ -37,15 +41,19 @@
import {computed, ref, watch, watchEffect, shallowReactive, type PropType} from 'vue'
import {useRouter} from 'vue-router'
import {format, parse} from 'date-fns'
import dayjs from 'dayjs'
import {useDayjsLanguageSync} from '@/i18n'
import TaskCollectionService from '@/services/taskCollection'
import TaskService from '@/services/task'
import TaskModel, { getHexColor } from '@/models/task'
import type ListModel from '@/models/list'
import {colorIsDark} from '@/helpers/color/colorIsDark'
import {RIGHTS} from '@/constants/rights'
import type {ITask} from '@/modelTypes/ITask'
import type {IList} from '@/modelTypes/IList'
import {
extendDayjs,
GGanttChart,
@ -58,33 +66,30 @@ import TaskForm from '@/components/tasks/TaskForm.vue'
konrad marked this conversation as resolved Outdated

picky: use DATE_FORMAT to make clear it's a 'config const'

picky: use `DATE_FORMAT` to make clear it's a 'config const'

But also: shouldn't this depend on the user setting / language?

But also: shouldn't this depend on the user setting / language?

shouldn't this depend on the user setting / language?

It's only used to pass the date in the correct format to the gantt chart libaray so it will always be the same. Not sure why they only take strings as input instead of Date objects but that's how it is.

> shouldn't this depend on the user setting / language? It's only used to pass the date in the correct format to the gantt chart libaray so it will always be the same. Not sure why they only take strings as input instead of `Date` objects but that's how it is.
import {useBaseStore} from '@/stores/base'
extendDayjs()
export type DateRange = {
dateFrom: string,
dateTo: string,
}
const PRECISION = 'day' as const
const DATE_FORMAT = 'yyyy-LL-dd HH:mm'
export interface GanttChartProps {
listId: IList['id']
dateRange: DateRange
showTasksWithoutDates: boolean
}
export const DATE_FORMAT = 'yyyy-LL-dd HH:mm'
const props = withDefaults(defineProps<GanttChartProps>(), {
showTasksWithoutDates: false,
})
// setup dayjs for vue-ganttastic
const dayjsLanguageLoading = useDayjsLanguageSync(dayjs)
extendDayjs()
const baseStore = useBaseStore()
konrad marked this conversation as resolved Outdated

picky: If you use a default value there is no need to define required

picky: If you use a default value there is no need to define `required`

Done.

Done.
const router = useRouter()
const props = defineProps({
listId: {
type: Number as PropType<ListModel['id']>,
required: true,
},
dateFrom: {
type: String as PropType<any>,
required: true,
},
dateTo: {
type: String as PropType<any>,
required: true,
},
showTasksWithoutDates: {
type: Boolean,
default: false,
},
})
const taskCollectionService = shallowReactive(new TaskCollectionService())
const taskService = shallowReactive(new TaskService())
@ -100,14 +105,11 @@ const ganttChartWidth = computed(() => {
const canWrite = computed(() => baseStore.currentList.maxRight > RIGHTS.READ)
dpschen marked this conversation as resolved Outdated

User TaskModel['id']

User `TaskModel['id']`

Done (I think you did that one?)

Done (I think you did that one?)
const tasks = ref<Map<TaskModel['id'], TaskModel>>(new Map())
const tasks = ref<Map<ITask['id'], ITask>>(new Map())
const ganttBars = ref<GanttBarObject[][]>([])
konrad marked this conversation as resolved Outdated

define types

define types

Done.

Done.
watch(
tasks,
// We need a "real" ref object for the gantt bars to instantly update the tasks when they are dragged on the chart.
// A computed won't work directly.
// function mapGanttBars() {
() => {
ganttBars.value = []
tasks.value.forEach(t => ganttBars.value.push(transformTaskToGanttBar(t)))
@ -115,14 +117,15 @@ watch(
{deep: true}
)
const defaultStartDate = format(new Date(), DATE_FORMAT)
const defaultEndDate = format(new Date((new Date()).setDate((new Date()).getDate() + 7)), DATE_FORMAT)
const defaultStartDate = new Date().toISOString()
const defaultEndDate = new Date((new Date()).setDate((new Date()).getDate() + 7)).toISOString()
function transformTaskToGanttBar(t: TaskModel) {
function transformTaskToGanttBar(t: ITask) {
const black = 'var(--grey-800)'
console.log(t)
return [{
startDate: t.startDate ? format(t.startDate, DATE_FORMAT) : defaultStartDate,
endDate: t.endDate ? format(t.endDate, DATE_FORMAT) : defaultEndDate,
startDate: t.startDate ? new Date(t.startDate).toISOString() : defaultStartDate,
endDate: t.endDate ? new Date(t.endDate).toISOString() : defaultEndDate,
ganttBarConfig: {
id: String(t.id),
label: t.title,
@ -137,8 +140,6 @@ function transformTaskToGanttBar(t: TaskModel) {
} as GanttBarObject]
}
// FIXME: unite with other filter params types
interface GetAllTasksParams {
konrad marked this conversation as resolved Outdated

define types

define types

Done.

Done.
sort_by: ('start_date' | 'done' | 'id')[],
@ -150,8 +151,8 @@ interface GetAllTasksParams {
filter_include_nulls: boolean,
}
async function getAllTasks(params: GetAllTasksParams, page = 1): Promise<TaskModel[]> {
const tasks = await taskCollectionService.getAll({listId: props.listId}, params, page) as TaskModel[]
async function getAllTasks(params: GetAllTasksParams, page = 1): Promise<ITask[]> {
const tasks = await taskCollectionService.getAll({listId: props.listId}, params, page) as ITask[]
if (page < taskCollectionService.totalPages) {
const nextTasks = await getAllTasks(params, page + 1)
return tasks.concat(nextTasks)
@ -170,7 +171,7 @@ async function loadTasks({
}) {
tasks.value = new Map()
const params = {
const params: GetAllTasksParams = {
sort_by: ['start_date', 'done', 'id'],
order_by: ['asc', 'asc', 'desc'],
konrad marked this conversation as resolved Outdated

This and the three lines below should be combined to one watcher that trigger immediately.

loadTasks should accept these three as params, so that it's clear that these are needed to reload.

Something like:

watchEffect(() => loadTasks({
	dateTo: props.dateTo,
    dateFrom: props.dateFrom,
    showTasksWithoutDates: props.showTasksWithoutDates,
})
This and the three lines below should be combined to one watcher that trigger immediately. `loadTasks` should accept these three as params, so that it's clear that these are needed to reload. Something like: ``` watchEffect(() => loadTasks({ dateTo: props.dateTo, dateFrom: props.dateFrom, showTasksWithoutDates: props.showTasksWithoutDates, }) ```

Done.

Done.
filter_by: ['start_date', 'start_date'],
@ -191,7 +192,7 @@ watchEffect(() => loadTasks({
showTasksWithoutDates: props.showTasksWithoutDates,
}))
async function createTask(title: TaskModel['title']) {
async function createTask(title: ITask['title']) {
konrad marked this conversation as resolved Outdated
Add type: https://vuejs.org/guide/typescript/composition-api.html#typing-template-refs

Done.

Done.
const newTask = await taskService.create(new TaskModel({
title,
listId: props.listId,
@ -212,14 +213,26 @@ async function updateTask(e: {
if (!task) return
task.startDate = e.bar.startDate
task.endDate = e.bar.endDate
console.log(task.startDate.toISOString())
console.log(dayjs(task.startDate), "YYYY-MM-DD HH:mm").toISOString()
console.log(dayjs(task.startDate, "YYYY-MM-DDTHH:mm:ssZ[Z]").toISOString())
console.log(task.startDate)
console.log(dayjs(e.bar.startDate).toDate())
console.log(e.bar.startDate)
console.log(task.endDate.toISOString())
console.log(task.endDate)
console.log(dayjs(e.bar.startDate).toDate())
console.log(e.bar.endDate)
// task.startDate = e.bar.startDate
// task.endDate = e.bar.endDate
const updatedTask = await taskService.update(task)
ganttBars.value.map(gantBar => {
return Number(gantBar[0].ganttBarConfig.id) === task.id
? transformTaskToGanttBar(updatedTask)
: gantBar
})
// ganttBars.value.map(gantBar => {
// return Number(gantBar[0].ganttBarConfig.id) === task.id
// ? transformTaskToGanttBar(updatedTask)
// : gantBar
// })
}
function openTask(e: {

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,19 +1,9 @@
import {computed, ref, watch} from 'vue'
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,41 +14,58 @@ 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 DAYJS_LOCALE_MAPPING = {
'de-swiss': 'de-AT',
} as Record<SupportedLocale, ISOLanguage>
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) {
return
throw new Error()
}
if (
// If the same language
i18n.global.locale === lang ||
// If the language was already loaded
loadedLanguages.includes(lang)
i18n.global.availableLocales.includes(lang)
) {
return setI18nLanguage(lang)
}
// 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)
},
)
const messages = await import(`./lang/${lang}.json`)
i18n.global.setLocaleMessage(lang, messages.default)
return setI18nLanguage(lang)
}
export const getCurrentLanguage = () => {
export function getCurrentLanguage(): SupportedLocale {
const savedLanguage = localStorage.getItem('language')
if (savedLanguage !== null) {
return savedLanguage
@ -66,20 +73,48 @@ export const getCurrentLanguage = () => {
const browserLanguage = navigator.language || navigator.userLanguage
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())
}
import type dayjs from 'dayjs'
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
}
console.log('foo')
await import(`../../node_modules/dayjs/locale/${dayjsLanguageCode}.js`)

This statement did not work in my tests. IMHO we could merge the gantt chart without proper locale support and rework that later. This PR is already a lot bigger than it should be.

This statement did not work in my tests. IMHO we could merge the gantt chart without proper locale support and rework that later. This PR is already a lot bigger than it should be.

Agree!

I found out how to make this work. Turned out that more work is needed since after the locale support works the weekdayFromTimeLabel doesn't anymore. So better to add this at a later point.

Agree! I found out how to make this work. Turned out that more work is needed since after the locale support works the `weekdayFromTimeLabel` doesn't anymore. So better to add this at a later point.
console.log('bar')
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

@ -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,12 @@ 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) }),
props: route => ({
listId: Number(route.params.listId as string),
dateFrom: route.query.dateFrom as string,
dateTo: route.query.dateTo as string,
showTasksWithoutDates: Boolean(route.query.showTasksWithoutDates),
}),
},
{
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

@ -2,7 +2,7 @@ import AbstractService from './abstractService'
import TaskModel from '../models/task'
import {formatISO} from 'date-fns'
export default class TaskCollectionService extends AbstractService {
export default class TaskCollectionService extends AbstractService<ITask> {
constructor() {
super({
getAll: '/lists/{listId}/tasks',

View File

@ -6,12 +6,13 @@
<div class="field">
<label class="label" for="range">{{ $t('list.gantt.range') }}</label>
<div class="control">
<flat-pickr
<Foo
ref="flatPickerEl"
:config="flatPickerConfig"
class="input"
id="range"
:placeholder="$t('list.gantt.range')"
v-model="range"
v-model="flatPickerDateRange"
/>
</div>
</div>
@ -25,13 +26,15 @@
<template #default>
<div class="gantt-chart-container">
<card :padding="false" class="has-overflow">
<gantt-chart
:date-from="dateFrom"
:date-to="dateTo"
<pre>{{dateRange}}</pre>
<pre>{{new Date(dateRange.dateFrom).toLocaleDateString()}}</pre>
<pre>{{new Date(dateRange.dateTo).toLocaleDateString()}}</pre>
<!-- <gantt-chart
v-if="false"
:date-range="dateRange"
:list-id="props.listId"
:show-tasks-without-dates="showTasksWithoutDates"
/>
/> -->
</card>
</div>
@ -40,41 +43,206 @@
</template>
<script setup lang="ts">
import {ref, computed} from 'vue'
import flatPickr from 'vue-flatpickr-component'
import {computed, ref, type PropType} from 'vue'
import Foo from '@/components/misc/flatpickr/Flatpickr.vue'
// import type FlatPickr from 'vue-flatpickr-component'
import {useI18n} from 'vue-i18n'
import {format} from 'date-fns'
import {useRoute, useRouter} from 'vue-router'
import {useAuthStore} from '@/stores/auth'
import ListWrapper from './ListWrapper.vue'
import GanttChart from '@/components/tasks/gantt-chart.vue'
import Fancycheckbox from '@/components/input/fancycheckbox.vue'
import {format} from 'date-fns'
import {createAsyncComponent} from '@/helpers/createAsyncComponent'
type DateKebab = `${string}-${string}-${string}`
const GanttChart = createAsyncComponent(() => import('@/components/tasks/gantt-chart.vue'))
const props = defineProps({
listId: {
type: Number,
required: true,
},
dateFrom: {
type: String as PropType<DateKebab>,
},
dateTo: {
type: String as PropType<DateKebab>,
},
showTasksWithoutDates: {
type: Boolean,
default: false,
},
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 showTasksWithoutDates = ref(false)
const router = useRouter()
const route = useRoute()

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.
const showTasksWithoutDates = computed({
get: () => props.showTasksWithoutDates,
set: (value) => router.push({ query: {
...route.query,
showTasksWithoutDates: String(value),
}}),
})
function parseKebabDate(kebabDate: DateKebab | undefined, fallback: () => Date): Date {
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)
} catch(e) {
// ignore nonsense route queries
return fallback()
}
}
const DEFAULT_DATEFROM_DAY_OFFSET = 0
// const DEFAULT_DATEFROM_DAY_OFFSET = -15
const DEFAULT_DATETO_DAY_OFFSET = +55
// const DEFAULT_DATETO_DAY_OFFSET = +55
const now = new Date()
const defaultFrom = format(new Date((new Date()).setDate(now.getDate() - 15)), 'yyyy-LL-dd')
const defaultTo = format(new Date((new Date()).setDate(now.getDate() + 55)), 'yyyy-LL-dd')
const range = ref(`${defaultFrom} to ${defaultTo}`)
// TODO: only update once both dates are available (maybe use a watcher + refs instead?)
const dateFrom = computed(() => range.value?.split(' to ')[0] ?? defaultFrom)
const dateTo = computed(() => range.value?.split(' to ')[1] ?? defaultTo)
function getDefaultDateFrom() {
return new Date(now.getFullYear(), now.getMonth(), now.getDate() + DEFAULT_DATEFROM_DAY_OFFSET)
}
function getDefaultDateTo() {
return new Date(now.getFullYear(), now.getMonth(), now.getDate() + DEFAULT_DATETO_DAY_OFFSET)
}
let isChangingRoute = ref<ReturnType<typeof router.push> | false>(false)
const count = ref(0)
const dateRange = computed<{
dateFrom: string
dateTo: string
}>({
get: () => ({
dateFrom: parseKebabDate(props.dateFrom, getDefaultDateFrom).toISOString(),
dateTo: parseKebabDate(props.dateTo, getDefaultDateTo).toISOString(),
}),
async set(range: {
dateFrom: string
dateTo: string
} | null) {
if (range === null) {
const query = {...route.query}
delete query?.dateFrom
delete query?.dateTo
console.log('set range to null. query is: ', query)
router.push(query)
return
}
const {
dateFrom,
dateTo,
} = range
count.value = count.value + 1
if (count.value >= 4) {
console.log('triggered ', count, ' times, stopping.')
return
}
if (isChangingRoute.value !== false) {
console.log('called again while changing route')
await isChangingRoute.value
console.log('changing route finished, continuing...')
}
const queryDateFrom = format(new Date(dateFrom || getDefaultDateFrom()), 'yyyy-LL-dd')
const queryDateTo = format(new Date(dateTo || getDefaultDateTo()), 'yyyy-LL-dd')
console.log(dateFrom, 'dateFrom')
console.log(dateRange.value.dateFrom, 'dateRange.value.dateFrom')
console.log(dateTo, 'dateTo')
console.log(dateRange.value.dateTo, 'dateRange.value.dateTo')
if (queryDateFrom === route.query.dateFrom || queryDateTo === route.query.dateTo) {
console.log('is same date')
// only set if the value has changed
return
}
console.log('change url to', {
query: {
...route.query,
dateFrom: format(new Date(dateFrom), 'yyyy-LL-dd'),
dateTo: format(new Date(dateTo), 'yyyy-LL-dd'),
}
})
isChangingRoute.value = router.push({
query: {
...route.query,
dateFrom: format(new Date(dateFrom), 'yyyy-LL-dd'),
dateTo: format(new Date(dateTo), 'yyyy-LL-dd'),
}
})
},
})
const initialDateRange = [dateRange.value.dateFrom, dateRange.value.dateTo]
function getCurrentDateRangeFromFlatpicker() {
return flatPickerEl.value.fp.selectedDates.map((date: Date) => date?.toISOString())
}
const flatPickerEl = ref<typeof FlatPickr | null>(null)
const flatPickerDateRange = computed({
get: () => ([
dateRange.value.dateFrom,
dateRange.value.dateTo
]),
set(newVal) {
// set([dateFrom, dateTo]) {
// newVal from event does only contain the wrong format
console.log(newVal)
const [dateFrom, dateTo] = newVal
// const [dateFrom, dateTo] = getCurrentDateRangeFromFlatpicker()
if (
// only set after whole range has been selected
!dateTo ||
// only set if the value has changed
dateRange.value.dateFrom === dateFrom &&
dateRange.value.dateTo === dateTo
) {
return
}
// dateRange.value = {dateFrom, dateTo}
}
})
const ISO_DATE_FORMAT = "YYYY-MM-DDTHH:mm:ssZ[Z]"
const {t} = useI18n({useScope: 'global'})
const authStore = useAuthStore()
const flatPickerConfig = computed(() => ({
altFormat: t('date.altFormatShort'),
altInput: true,
dateFormat: 'Y-m-d',
// dateFornat: ISO_DATE_FORMAT,
// dateFormat: 'Y-m-d',
defaultDate: initialDateRange,
enableTime: false,
mode: 'range',
locale: {

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,