feat: replace our home-grown gantt implementation with ganttastic #2180
|
@ -1,19 +1,19 @@
|
|||
<template>
|
||||
<div>
|
||||
konrad marked this conversation as resolved
Outdated
|
||||
<Loading
|
||||
v-if="taskCollectionService.loading || dayjsLanguageLoading"
|
||||
v-if="props.isLoading || dayjsLanguageLoading"
|
||||
class="gantt-container"
|
||||
/>
|
||||
<div class="gantt-container" v-else>
|
||||
<GGanttChart
|
||||
:date-format="DAYJS_ISO_DATE_FORMAT"
|
||||
:chart-start="isoToKebabDate(props.dateFrom)"
|
||||
:chart-end="isoToKebabDate(props.dateTo)"
|
||||
:chart-start="isoToKebabDate(filters.dateFrom)"
|
||||
:chart-end="isoToKebabDate(filters.dateTo)"
|
||||
precision="day"
|
||||
konrad marked this conversation as resolved
Outdated
dpschen
commented
Is it possible to use here simply Is it possible to use here simply `inherit` as value?
konrad
commented
Seems to work, yes. Seems to work, yes.
dpschen
commented
Not necessary with lates release. Removed. Not necessary with lates release. Removed.
|
||||
bar-start="startDate"
|
||||
bar-end="endDate"
|
||||
:grid="true"
|
||||
@dragend-bar="updateTask"
|
||||
@dragend-bar="updateGanttTask"
|
||||
@dblclick-bar="openTask"
|
||||
:width="ganttChartWidth + 'px'"
|
||||
>
|
||||
|
@ -36,30 +36,26 @@
|
|||
/>
|
||||
</GGanttChart>
|
||||
</div>
|
||||
<TaskForm v-if="canWrite" @create-task="createTask" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, ref, watch, watchEffect, shallowReactive} from 'vue'
|
||||
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 cloneDeep from 'lodash.clonedeep'
|
||||
|
||||
import {useDayjsLanguageSync} from '@/i18n'
|
||||
import TaskCollectionService from '@/services/taskCollection'
|
||||
import TaskService from '@/services/task'
|
||||
import TaskModel, {getHexColor} from '@/models/task'
|
||||
import {getHexColor} from '@/models/task'
|
||||
|
||||
import {colorIsDark} from '@/helpers/color/colorIsDark'
|
||||
import {isoToKebabDate} from '@/helpers/time/isoToKebabDate'
|
||||
import {parseKebabDate} from '@/helpers/time/parseKebabDate'
|
||||
import {RIGHTS} from '@/constants/rights'
|
||||
|
||||
import type {ITask} from '@/modelTypes/ITask'
|
||||
import type {IList} from '@/modelTypes/IList'
|
||||
import type {ITask, ITaskPartialWithId} from '@/modelTypes/ITask'
|
||||
import type {DateISO} from '@/types/DateISO'
|
||||
import type {GanttFilter} from '@/views/list/helpers/useGanttFilter'
|
||||
|
||||
import {
|
||||
extendDayjs,
|
||||
|
@ -69,37 +65,34 @@ import {
|
|||
} from '@infectoone/vue-ganttastic'
|
||||
|
||||
konrad marked this conversation as resolved
Outdated
dpschen
commented
picky: use picky: use `DATE_FORMAT` to make clear it's a 'config const'
dpschen
commented
But also: shouldn't this depend on the user setting / language? But also: shouldn't this depend on the user setting / language?
konrad
commented
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 > 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 Loading from '@/components/misc/loading.vue'
|
||||
import TaskForm from '@/components/tasks/TaskForm.vue'
|
||||
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
import {error, success} from '@/message'
|
||||
|
||||
export interface GanttChartProps {
|
||||
listId: IList['id']
|
||||
showTasksWithoutDates: boolean
|
||||
dateFrom: string,
|
||||
dateTo: string,
|
||||
isLoading: boolean,
|
||||
filters: GanttFilter,
|
||||
tasks: Map<ITask['id'], ITask>,
|
||||
defaultTaskStartDate: DateISO
|
||||
defaultTaskEndDate: DateISO
|
||||
}
|
||||
|
||||
const DAYJS_ISO_DATE_FORMAT = 'YYYY-MM-DD'
|
||||
|
||||
const props = withDefaults(defineProps<GanttChartProps>(), {
|
||||
showTasksWithoutDates: false,
|
||||
})
|
||||
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 = useDayjsLanguageSync(dayjs)
|
||||
dayjs.extend(isToday)
|
||||
extendDayjs()
|
||||
konrad marked this conversation as resolved
Outdated
dpschen
commented
picky: If you use a default value there is no need to define picky: If you use a default value there is no need to define `required`
|
||||
|
||||
const baseStore = useBaseStore()
|
||||
const router = useRouter()
|
||||
|
||||
const taskCollectionService = shallowReactive(new TaskCollectionService())
|
||||
const taskService = shallowReactive(new TaskService())
|
||||
|
||||
const dateFromDate = computed(() => new Date(new Date(props.dateFrom).setHours(0,0,0,0)))
|
||||
const dateToDate = computed(() => new Date(new Date(props.dateTo).setHours(23,59,0,0)))
|
||||
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(() => {
|
||||
|
@ -108,11 +101,11 @@ const ganttChartWidth = computed(() => {
|
|||
return dateDiff * DAY_WIDTH_PIXELS
|
||||
})
|
||||
|
||||
const canWrite = computed(() => baseStore.currentList.maxRight > RIGHTS.READ)
|
||||
|
||||
const tasks = ref<Map<ITask['id'], ITask>>(new Map())
|
||||
const ganttBars = ref<GanttBarObject[][]>([])
|
||||
|
||||
/**
|
||||
* Update ganttBars when tasks change
|
||||
dpschen marked this conversation as resolved
Outdated
dpschen
commented
User User `TaskModel['id']`
konrad
commented
Done (I think you did that one?) Done (I think you did that one?)
|
||||
*/
|
||||
watch(
|
||||
tasks,
|
||||
konrad marked this conversation as resolved
Outdated
dpschen
commented
define types define types
konrad
commented
Done. Done.
|
||||
() => {
|
||||
|
@ -122,15 +115,11 @@ watch(
|
|||
{deep: true},
|
||||
)
|
||||
|
||||
const today = new Date(new Date(props.dateFrom).setHours(0,0,0,0))
|
||||
const defaultTaskStartDate = new Date(today)
|
||||
const defaultTaskEndDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 7, 23,59,0,0)
|
||||
|
||||
function transformTaskToGanttBar(t: ITask) {
|
||||
const black = 'var(--grey-800)'
|
||||
return [{
|
||||
startDate: isoToKebabDate(t.startDate ? t.startDate.toISOString() : defaultTaskStartDate.toISOString()),
|
||||
endDate: isoToKebabDate(t.endDate ? t.endDate.toISOString() : defaultTaskEndDate.toISOString()),
|
||||
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,
|
||||
|
@ -145,95 +134,16 @@ function transformTaskToGanttBar(t: ITask) {
|
|||
} as GanttBarObject]
|
||||
}
|
||||
|
||||
// FIXME: unite with other filter params types
|
||||
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,
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
return tasks
|
||||
}
|
||||
|
||||
async function loadTasks({
|
||||
dateTo,
|
||||
dateFrom,
|
||||
showTasksWithoutDates,
|
||||
}: {
|
||||
dateTo: string;
|
||||
dateFrom: string;
|
||||
showTasksWithoutDates: boolean;
|
||||
async function updateGanttTask(e: {
|
||||
bar: GanttBarObject;
|
||||
e: MouseEvent;
|
||||
datetime?: string | undefined;
|
||||
}) {
|
||||
tasks.value = new Map()
|
||||
|
||||
const params: GetAllTasksParams = {
|
||||
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(dateFrom), isoToKebabDate(dateTo)],
|
||||
filter_concat: 'and',
|
||||
filter_include_nulls: showTasksWithoutDates,
|
||||
}
|
||||
|
||||
const loadedTasks = await getAllTasks(params)
|
||||
loadedTasks.forEach(t => tasks.value.set(t.id, t))
|
||||
}
|
||||
|
||||
watchEffect(() => loadTasks({
|
||||
dateTo: props.dateTo,
|
||||
dateFrom: props.dateFrom,
|
||||
showTasksWithoutDates: props.showTasksWithoutDates,
|
||||
}))
|
||||
|
||||
async function createTask(title: ITask['title']) {
|
||||
const newTask = await taskService.create(new TaskModel({
|
||||
title,
|
||||
listId: props.listId,
|
||||
startDate: defaultTaskStartDate.toISOString(),
|
||||
endDate: defaultTaskEndDate.toISOString(),
|
||||
}))
|
||||
tasks.value.set(newTask.id, newTask)
|
||||
|
||||
return newTask
|
||||
}
|
||||
|
||||
async function updateTask(e: {
|
||||
bar: GanttBarObject;
|
||||
e: MouseEvent;
|
||||
datetime?: string | undefined;
|
||||
}) {
|
||||
const task = tasks.value.get(Number(e.bar.ganttBarConfig.id))
|
||||
|
||||
if (!task) return
|
||||
|
||||
const oldTask = cloneDeep(task)
|
||||
const newTask: ITask = {
|
||||
...task,
|
||||
emit('update:task', {
|
||||
id: Number(e.bar.ganttBarConfig.id),
|
||||
startDate: new Date(parseKebabDate(e.bar.startDate).setHours(0,0,0,0)),
|
||||
konrad marked this conversation as resolved
Outdated
dpschen
commented
define types define types
|
||||
endDate: new Date(parseKebabDate(e.bar.endDate).setHours(23,59,0,0)),
|
||||
}
|
||||
|
||||
tasks.value.set(newTask.id, newTask)
|
||||
|
||||
try {
|
||||
const updatedTask = await taskService.update(newTask)
|
||||
tasks.value.set(updatedTask.id, updatedTask)
|
||||
success('Saved')
|
||||
} catch(e: any) {
|
||||
error('Something went wrong saving the task')
|
||||
tasks.value.set(task.id, oldTask)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function openTask(e: {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import {i18n} from '@/i18n'
|
||||
import { notify } from '@kyvg/vue3-notification'
|
||||
import {notify} from '@kyvg/vue3-notification'
|
||||
|
||||
export const getErrorText = (r) => {
|
||||
|
||||
|
|
|
@ -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>
|
|
@ -0,0 +1 @@
|
|||
export type PartialWithId<T extends { id: any }> = Pick<T, 'id'> & Omit<Partial<T>, 'id'>
|
|
@ -27,11 +27,14 @@
|
|||
<div class="gantt-chart-container">
|
||||
<card :padding="false" class="has-overflow">
|
||||
<gantt-chart
|
||||
:list-id="filters.listId"
|
||||
:date-from="filters.dateFrom"
|
||||
:date-to="filters.dateTo"
|
||||
:show-tasks-without-dates="filters.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>
|
||||
|
@ -45,13 +48,17 @@ 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 ListWrapper from './ListWrapper.vue'
|
||||
import Fancycheckbox from '@/components/input/fancycheckbox.vue'
|
||||
import TaskForm from '@/components/tasks/TaskForm.vue'
|
||||
|
||||
import {createAsyncComponent} from '@/helpers/createAsyncComponent'
|
||||
import {useGanttFilter} from './helpers/useGanttFilter'
|
||||
import {RIGHTS} from '@/constants/rights'
|
||||
import type { DateISO } from '@/types/DateISO'
|
||||
|
||||
type Options = Flatpickr.Options.Options
|
||||
|
||||
|
@ -59,8 +66,30 @@ const GanttChart = createAsyncComponent(() => import('@/components/tasks/gantt-c
|
|||
|
||||
const props = defineProps<{route: RouteLocationNormalized}>()
|
||||
|
||||
const baseStore = useBaseStore()
|
||||
const canWrite = computed(() => baseStore.currentList.maxRight > RIGHTS.READ)
|
||||
|
||||
const {route} = toRefs(props)
|
||||
const {filters} = useGanttFilter(route)
|
||||
const {
|
||||
filters,
|
||||
tasks,
|
||||
isLoading,
|
||||
addTask,
|
||||
updateTask,
|
||||
konrad marked this conversation as resolved
Outdated
dpschen
commented
Should this update? Should this update?
konrad
commented
No, doesn't even need to be ref. No, doesn't even need to be ref.
|
||||
} = useGanttFilter(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()
|
||||
dpschen
commented
Can you explain in a different way? Can you explain in a different way?
konrad
commented
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.
dpschen
commented
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?
konrad
commented
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?
dpschen
commented
That seems like the right approach That seems like the right approach
dpschen
commented
Will check this out again. Shouldn't be too hard. Will check this out again. Shouldn't be too hard.
|
||||
|
||||
async function addGanttTask(title: ITask['title']) {
|
||||
return await addTask({
|
||||
title,
|
||||
listId: filters.listId,
|
||||
startDate: defaultTaskStartDate,
|
||||
endDate: defaultTaskEndDate,
|
||||
})
|
||||
}
|
||||
|
||||
const flatPickerEl = ref<typeof Foo | null>(null)
|
||||
const flatPickerDateRange = computed<Date[]>({
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import type {Ref} from 'vue'
|
||||
import {computed, ref, shallowReactive, watchEffect, type Ref} from 'vue'
|
||||
import type {RouteLocationNormalized, RouteLocationRaw} from 'vue-router'
|
||||
import cloneDeep from 'lodash.clonedeep'
|
||||
|
||||
import {isoToKebabDate} from '@/helpers/time/isoToKebabDate'
|
||||
import {parseDateProp} from '@/helpers/time/parseDateProp'
|
||||
|
@ -7,9 +8,17 @@ import {parseBooleanProp} from '@/helpers/time/parseBooleanProp'
|
|||
import {useRouteFilter} from '@/composables/useRouteFilter'
|
||||
|
||||
import type {IList} from '@/modelTypes/IList'
|
||||
import type {ITask, ITaskPartialWithId} from '@/modelTypes/ITask'
|
||||
|
||||
import type {DateISO} from '@/types/DateISO'
|
||||
import type {DateKebab} from '@/types/DateKebab'
|
||||
|
||||
import TaskCollectionService from '@/services/taskCollection'
|
||||
import TaskService from '@/services/task'
|
||||
|
||||
import TaskModel from '@/models/task'
|
||||
import {error, success} from '@/message'
|
||||
|
||||
// convenient internal filter object
|
||||
export interface GanttFilter {
|
||||
listId: IList['id']
|
||||
|
@ -18,6 +27,17 @@ export interface GanttFilter {
|
|||
showTasksWithoutDates: boolean
|
||||
}
|
||||
|
||||
// FIXME: unite with other filter params types
|
||||
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,
|
||||
}
|
||||
|
||||
const DEFAULT_SHOW_TASKS_WITHOUT_DATES = false
|
||||
|
||||
const DEFAULT_DATEFROM_DAY_OFFSET = -15
|
||||
|
@ -66,5 +86,84 @@ function filterToRoute(filters: GanttFilter): RouteLocationRaw {
|
|||
}
|
||||
|
||||
export function useGanttFilter(route: Ref<RouteLocationNormalized>) {
|
||||
return useRouteFilter<GanttFilter>(route, routeToFilter, filterToRoute)
|
||||
const {filters} = useRouteFilter<GanttFilter>(route, routeToFilter, filterToRoute)
|
||||
|
||||
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 getAllTasks(params: GetAllTasksParams, page = 1): Promise<ITask[]> {
|
||||
const tasks = await taskCollectionService.getAll({listId: filters.listId}, params, page) as ITask[]
|
||||
if (page < taskCollectionService.totalPages) {
|
||||
const nextTasks = await getAllTasks(params, page + 1)
|
||||
return tasks.concat(nextTasks)
|
||||
}
|
||||
return tasks
|
||||
}
|
||||
|
||||
async function loadTasks(filters: GanttFilter) {
|
||||
const params: GetAllTasksParams = {
|
||||
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,
|
||||
}
|
||||
|
||||
const loadedTasks = await getAllTasks(params)
|
||||
tasks.value = new Map()
|
||||
loadedTasks.forEach(t => tasks.value.set(t.id, t))
|
||||
}
|
||||
|
||||
watchEffect(() => loadTasks(filters))
|
||||
|
||||
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 {
|
||||
filters,
|
||||
|
||||
tasks,
|
||||
|
||||
isLoading,
|
||||
addTask,
|
||||
updateTask,
|
||||
}
|
||||
}
|
Use loading component. This way it's easier for us to refactor the
is-loading
styles from bulma later.Done.