Compare commits

...

50 commits

Author SHA1 Message Date
Dominik Pschenitschni
1d495b8603
feat: update ganttastic version 2022-10-09 13:29:27 +02:00
kolaente
0f1c5e9394
fix(tests): adjust gantt rows identifier 2022-10-05 15:21:19 +02:00
kolaente
7745f16893
chore: update lockfile 2022-10-05 15:15:55 +02:00
kolaente
97a6b2f3d1
Merge branch 'main' into feature/ganttastic
# Conflicts:
#	package.json
#	pnpm-lock.yaml
#	src/components/tasks/gantt-component.vue
2022-10-05 15:15:03 +02:00
kolaente
62dadc791c
fix: correctly import all components 2022-10-02 14:12:10 +02:00
kolaente
bb280b811c
fix: use base store 2022-10-02 14:09:18 +02:00
kolaente
6db97d1ba1
Merge branch 'main' into feature/ganttastic 2022-10-02 14:01:23 +02:00
kolaente
1bbdd3b117
Merge branch 'main' into feature/ganttastic
# Conflicts:
#	pnpm-lock.yaml
#	src/components/tasks/gantt-component.vue
2022-10-02 00:24:10 +02:00
Dominik Pschenitschni
6cb8d7a3f5
fix imports 2022-09-30 19:17:36 +02:00
Dominik Pschenitschni
c7a3c18972
Merge branch 'main' into feature/ganttastic
# Conflicts:
#	pnpm-lock.yaml
#	src/main.ts
2022-09-30 19:15:16 +02:00
Dominik Pschenitschni
a1e280e47b
Merge branch 'main' into feature/ganttastic
# Conflicts:
#	pnpm-lock.yaml
2022-09-28 18:00:32 +02:00
Dominik Pschenitschni
879dfb74b6
Merge branch 'main' into feature/ganttastic
# Conflicts:
#	src/main.ts
#	yarn.lock
2022-09-27 16:14:12 +02:00
kolaente
bd06f725be
Merge branch 'main' into feature/ganttastic
# Conflicts:
#	package.json
#	src/components/tasks/gantt-component.vue
#	src/main.ts
2022-09-08 13:53:36 +02:00
Dominik Pschenitschni
7ab4ff2d9e
feat: review changes
move TaskForm in separate component, improve types
2022-08-16 23:25:24 +02:00
kolaente
c80e3b57e4
fix: lint 2022-08-16 23:25:24 +02:00
kolaente
daaa7d3864
fix: remove precision setting 2022-08-16 23:25:24 +02:00
kolaente
cf67edb4a6
chore: don't use ref when not nessecary 2022-08-16 23:25:24 +02:00
kolaente
79e332e518
chore: add types for template ref 2022-08-16 23:25:24 +02:00
kolaente
7c9e98fdf6
chore: don't use for..in 2022-08-16 23:25:24 +02:00
kolaente
5dac96a2d5
feat: only use one watcher 2022-08-16 23:25:23 +02:00
kolaente
f5c7b5be82
chore: define types 2022-08-16 23:25:23 +02:00
kolaente
f3bb23cf14
chore: don't set required if there's a default value 2022-08-16 23:25:23 +02:00
kolaente
9431c13a7f
chore: uppercase const 2022-08-16 23:25:23 +02:00
kolaente
c4d5d409d4
chore: use @/models 2022-08-16 23:25:23 +02:00
kolaente
77ed7a5d91
fix: use inherit for font family 2022-08-16 23:25:23 +02:00
kolaente
ede3dec1d6
chore: use Loading component 2022-08-16 23:25:23 +02:00
kolaente
09bdf76aa4
chore: update ganttastic 2022-08-16 23:25:23 +02:00
kolaente
fb56e890e6
feat: increase the default date range 2022-08-16 23:25:23 +02:00
kolaente
3a32501064
feat: create task when pressing the button 2022-08-16 23:25:23 +02:00
kolaente
7d61635182
fix: make tests work again with new selectors 2022-08-16 23:25:23 +02:00
kolaente
0a914e37ed
chore: remove old component and dependencies 2022-08-16 23:25:22 +02:00
kolaente
f142d72da1
feat: loading animation 2022-08-16 23:25:22 +02:00
kolaente
4e0c69d751
feat: handle changing props 2022-08-16 23:25:22 +02:00
kolaente
c39e9d5c62
feat: show done tasks strikethrough 2022-08-16 23:25:22 +02:00
kolaente
af529eef0a
feat: update task in gantt bar after dragging to make sure it changes its color 2022-08-16 23:25:22 +02:00
kolaente
f77f14a91f
fix: make sure the date format is actually valid 2022-08-16 23:25:22 +02:00
kolaente
edaead1d8f
fix: handle bar styling so they can actually be used 2022-08-16 23:25:22 +02:00
kolaente
90a06019ce
chore: cleanup 2022-08-16 23:25:22 +02:00
kolaente
da853a914c
feat: styling 2022-08-16 23:25:22 +02:00
kolaente
ecaa09285b
chore: use width property 2022-08-16 23:25:22 +02:00
kolaente
40f7871f1b
feat: scroll 2022-08-16 23:25:22 +02:00
kolaente
c9c9056baf
feat: add open task detail when double clicking 2022-08-16 23:25:21 +02:00
kolaente
7f3c754389
fix: new task input styling 2022-08-16 23:25:21 +02:00
kolaente
ed338cefcd
chore: use flatpickr range instead of two datepickers 2022-08-16 23:25:21 +02:00
kolaente
b6c776592b
feat: create new tasks 2022-08-16 23:25:21 +02:00
kolaente
e711df758c
feat: dynamically set default date 2022-08-16 23:25:21 +02:00
kolaente
3f4509a6f9
feat: dynamically set default date 2022-08-16 23:25:21 +02:00
kolaente
5e3e79c01b
feat: only load tasks which start in the currently selected range 2022-08-16 23:25:21 +02:00
kolaente
9501592719
feat: allow passing props down to the gantt component 2022-08-16 23:25:21 +02:00
kolaente
a0e6b9643b
feat: add basic implementation of ganttastic 2022-08-16 23:25:19 +02:00
12 changed files with 2022 additions and 2272 deletions

View file

@ -11,7 +11,7 @@ describe('List View Gantt', () => {
const tasks = TaskFactory.create(1) const tasks = TaskFactory.create(1)
cy.visit('/lists/1/gantt') cy.visit('/lists/1/gantt')
cy.get('.gantt-chart .tasks') cy.get('.g-gantt-rows-container')
.should('not.contain', tasks[0].title) .should('not.contain', tasks[0].title)
}) })
@ -25,7 +25,7 @@ describe('List View Gantt', () => {
cy.visit('/lists/1/gantt') cy.visit('/lists/1/gantt')
cy.get('.gantt-chart .months') cy.get('.g-timeunits-container')
.should('contain', format(now, 'MMMM')) .should('contain', format(now, 'MMMM'))
.should('contain', format(nextMonth, 'MMMM')) .should('contain', format(nextMonth, 'MMMM'))
}) })
@ -38,14 +38,13 @@ describe('List View Gantt', () => {
}) })
cy.visit('/lists/1/gantt') cy.visit('/lists/1/gantt')
cy.get('.gantt-chart .tasks') cy.get('.g-gantt-rows-container')
.should('not.be.empty') .should('not.be.empty')
cy.get('.gantt-chart .tasks')
.should('contain', tasks[0].title) .should('contain', tasks[0].title)
}) })
it('Shows tasks with no dates after enabling them', () => { it('Shows tasks with no dates after enabling them', () => {
TaskFactory.create(1, { const tasks = TaskFactory.create(1, {
start_date: null, start_date: null,
end_date: null, end_date: null,
}) })
@ -55,13 +54,15 @@ describe('List View Gantt', () => {
.contains('Show tasks which don\'t have dates set') .contains('Show tasks which don\'t have dates set')
.click() .click()
cy.get('.gantt-chart .tasks') cy.get('.g-gantt-rows-container')
.should('not.be.empty') .should('not.be.empty')
cy.get('.gantt-chart .tasks .task.nodate') .should('contain', tasks[0].title)
.should('exist')
}) })
it('Drags a task around', () => { it('Drags a task around', () => {
cy.intercept('**/api/v1/tasks/*')
.as('taskUpdate')
const now = new Date() const now = new Date()
TaskFactory.create(1, { TaskFactory.create(1, {
start_date: formatISO(now), start_date: formatISO(now),
@ -69,10 +70,11 @@ describe('List View Gantt', () => {
}) })
cy.visit('/lists/1/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() .first()
.trigger('mousedown', {which: 1}) .trigger('mousedown', {which: 1})
.trigger('mousemove', {clientX: 500, clientY: 0}) .trigger('mousemove', {clientX: 500, clientY: 0})
.trigger('mouseup', {force: true}) .trigger('mouseup', {force: true})
cy.wait('@taskUpdate')
}) })
}) })

View file

@ -23,6 +23,7 @@
"@fortawesome/free-solid-svg-icons": "6.2.0", "@fortawesome/free-solid-svg-icons": "6.2.0",
"@fortawesome/vue-fontawesome": "3.0.1", "@fortawesome/vue-fontawesome": "3.0.1",
"@github/hotkey": "2.0.1", "@github/hotkey": "2.0.1",
"@infectoone/vue-ganttastic": "./vendor/infectoone-vue-ganttastic-2.1.1.tgz",
"@kyvg/vue3-notification": "2.4.1", "@kyvg/vue3-notification": "2.4.1",
"@sentry/tracing": "7.14.1", "@sentry/tracing": "7.14.1",
"@sentry/vue": "7.14.1", "@sentry/vue": "7.14.1",

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,78 @@
<template>
<form
@submit.prevent="createTask"
class="add-new-task"
>
<transition name="width">
<input
v-if="newTaskFieldActive"
v-model="newTaskTitle"
@blur="hideCreateNewTask"
@keyup.esc="newTaskFieldActive = false"
class="input"
ref="newTaskTitleField"
type="text"
/>
</transition>
<x-button @click="showCreateTaskOrCreate" :shadow="false" icon="plus">
{{ $t('task.new') }}
</x-button>
</form>
</template>
<script setup lang="ts">
import {nextTick, ref} from 'vue'
import type {ITask} from '@/models/task'
const emit = defineEmits<{
(e: 'create-task', title: string): Promise<ITask>
}>()
const newTaskFieldActive = ref(false)
const newTaskTitleField = ref()
const newTaskTitle = ref('')
function showCreateTaskOrCreate() {
if (!newTaskFieldActive.value) {
// Timeout to not send the form if the field isn't even shown
setTimeout(() => {
newTaskFieldActive.value = true
nextTick(() => newTaskTitleField.value.focus())
}, 100)
} else {
createTask()
}
}
function hideCreateNewTask() {
if (newTaskTitle.value === '') {
nextTick(() => (newTaskFieldActive.value = false))
}
}
async function createTask() {
if (!newTaskFieldActive.value) {
return
}
await emit('create-task', newTaskTitle.value)
newTaskTitle.value = ''
hideCreateNewTask()
}
</script>
<style scoped lang="scss">
.add-new-task {
padding: 1rem .7rem .4rem .7rem;
display: flex;
max-width: 450px;
.input {
margin-right: .7rem;
font-size: .8rem;
}
.button {
font-size: .68rem;
}
}
</style>

View file

@ -0,0 +1,319 @@
<template>
<Loading class="gantt-container" v-if="taskService.loading || taskCollectionService.loading"/>
<div class="gantt-container" v-else>
<GGanttChart
:chart-start="`${dateFrom} 00:00`"
:chart-end="`${dateTo} 23:59`"
:precision="PRECISION"
bar-start="startDate"
bar-end="endDate"
:grid="true"
@dragend-bar="updateTask"
@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>
<TaskForm v-if="canWrite" @create-task="createTask" />
</template>
<script setup lang="ts">
import {computed, ref, watch, watchEffect, shallowReactive, type PropType} from 'vue'
import {useRouter} from 'vue-router'
import {format, parse} from 'date-fns'
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 {
extendDayjs,
GGanttChart,
GGanttRow,
type GanttBarObject,
} from '@infectoone/vue-ganttastic'
import Loading from '@/components/misc/loading.vue'
import TaskForm from '@/components/tasks/TaskForm.vue'
import {useBaseStore} from '@/stores/base'
extendDayjs()
const PRECISION = 'day' as const
const DATE_FORMAT = 'yyyy-LL-dd HH:mm'
const baseStore = useBaseStore()
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())
const dateFromDate = computed(() => parse(props.dateFrom, 'yyyy-LL-dd', new Date()))
const dateToDate = computed(() => parse(props.dateTo, 'yyyy-LL-dd', new Date()))
const DAY_WIDTH_PIXELS = 30
const ganttChartWidth = computed(() => {
const dateDiff = Math.floor((dateToDate.value - dateFromDate.value) / (1000 * 60 * 60 * 24))
return dateDiff * DAY_WIDTH_PIXELS
})
const canWrite = computed(() => baseStore.currentList.maxRight > RIGHTS.READ)
const tasks = ref<Map<TaskModel['id'], TaskModel>>(new Map())
const ganttBars = ref<GanttBarObject[][]>([])
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)))
},
{deep: true}
)
const defaultStartDate = format(new Date(), DATE_FORMAT)
const defaultEndDate = format(new Date((new Date()).setDate((new Date()).getDate() + 7)), DATE_FORMAT)
function transformTaskToGanttBar(t: TaskModel) {
const black = 'var(--grey-800)'
return [{
startDate: t.startDate ? format(t.startDate, DATE_FORMAT) : defaultStartDate,
endDate: t.endDate ? format(t.endDate, DATE_FORMAT) : defaultEndDate,
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]
}
// 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<TaskModel[]> {
const tasks = await taskCollectionService.getAll({listId: props.listId}, params, page) as TaskModel[]
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;
}) {
tasks.value = new Map()
const params = {
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: [dateFrom, 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: TaskModel['title']) {
const newTask = await taskService.create(new TaskModel({
title,
listId: props.listId,
startDate: defaultStartDate,
endDate: defaultEndDate,
}))
tasks.value.set(newTask.id, newTask)
return newTask
}
async function updateTask(e) {
const task = tasks.value.get(e.bar.ganttBarConfig.id)
if (!task) return
task.startDate = e.bar.startDate
task.endDate = e.bar.endDate
const updatedTask = await taskService.update(task)
ganttBars.value.map(gantBar => {
return gantBar[0].ganttBarConfig.id === task.id
? transformTaskToGanttBar(updatedTask)
: gantBar
})
}
function openTask(e) {
router.push({
name: 'task.detail',
params: {id: e.bar.ganttBarConfig.id},
state: {backdropView: router.currentRoute.value.fullPath},
})
}
function weekdayFromTimeLabel(label: string): string {
const parsed = parse(label, 'dd.MMM', dateFromDate.value)
return format(parsed, 'E')
}
function dayIsToday(label: string): boolean {
const parsed = parse(label, 'dd.MMM', dateFromDate.value)
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: 2000px;
}
.g-gantt-row-label {
display: none;
}
.g-upper-timeunit, .g-timeunit {
background: var(--white);
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;
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;
box-shadow: none;
}
.g-gantt-row > .g-gantt-row-bars-container {
border-bottom: none;
border-top: none;
}
.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;
height: 75%;
opacity: .75;
border-radius: $radius;
margin-top: 4px;
}
}
</style>

View file

@ -1,642 +0,0 @@
<template>
<div class="gantt-chart">
<div class="filter-container">
<div class="items">
<filter-popup
v-model="params"
@update:modelValue="loadTasks()"
/>
</div>
</div>
<div class="dates">
<template v-for="(y, yk) in days" :key="yk + 'year'">
<div class="months">
<div
:key="mk + 'month'"
class="month"
v-for="(m, mk) in days[yk]"
>
{{ formatMonthAndYear(yk, parseInt(mk) + 1) }}
<div class="days">
<div
:class="{ today: d.toDateString() === now.toDateString() }"
:key="dk + 'day'"
:style="{ width: dayWidth + 'px' }"
class="day"
v-for="(d, dk) in days[yk][mk]"
>
<span class="theday" v-if="dayWidth > 25">
{{ d.getDate() }}
</span>
<span class="weekday" v-if="dayWidth > 25">
{{
d.toLocaleString('en-us', {
weekday: 'short',
})
}}
</span>
</div>
</div>
</div>
</div>
</template>
</div>
<div :style="{ width: fullWidth + 'px' }" class="tasks">
<div
v-for="(t, k) in theTasks"
:key="t ? t.id : 0"
:style="{
background:
'repeating-linear-gradient(90deg, #ededed, #ededed 1px, ' +
(k % 2 === 0
? '#fafafa 1px, #fafafa '
: '#fff 1px, #fff ') +
dayWidth +
'px)',
}"
class="row"
>
<VueDragResize
:class="{
done: t ? t.done : false,
'is-current-edit': taskToEdit !== null && taskToEdit.id === t.id,
'has-light-text': !colorIsDark(t.getHexColor()),
'has-dark-text': colorIsDark(t.getHexColor()),
}"
:gridX="dayWidth"
:h="31"
:isActive="canWrite"
:minw="dayWidth"
:parentLimitation="true"
:parentW="fullWidth"
:snapToGrid="true"
:sticks="['mr', 'ml']"
:style="{
'border-color': t.getHexColor(),
'background-color': t.getHexColor(),
}"
:w="t.durationDays * dayWidth"
:x="t.offsetDays * dayWidth - 6"
:y="0"
@dragstop="(e) => resizeTask(t, e)"
@resizestop="(e) => resizeTask(t, e)"
axis="x"
class="task"
>
<span
:class="{
'has-high-priority': t.priority >= priorities.HIGH,
'has-not-so-high-priority':
t.priority === priorities.HIGH,
'has-super-high-priority':
t.priority === priorities.DO_NOW,
}"
>
{{ t.title }}
</span>
<priority-label :priority="t.priority" :done="t.done"/>
<!-- using the key here forces vue to use the updated version model and not the response returned by the api -->
<!-- FIXME: add label -->
<BaseButton @click="editTask(theTasks[k])" class="edit-toggle">
<icon icon="pen"/>
</BaseButton>
</VueDragResize>
</div>
<template v-if="showTaskswithoutDates">
<div
:key="t.id"
:style="{
background:
'repeating-linear-gradient(90deg, #ededed, #ededed 1px, ' +
(k % 2 === 0
? '#fafafa 1px, #fafafa '
: '#fff 1px, #fff ') +
dayWidth +
'px)',
}"
class="row"
v-for="(t, k) in tasksWithoutDates"
>
<VueDragResize
:gridX="dayWidth"
:h="31"
:isActive="canWrite"
:minw="dayWidth"
:parentLimitation="true"
:parentW="fullWidth"
:snapToGrid="true"
:sticks="['mr', 'ml']"
:x="dayOffsetUntilToday * dayWidth - 6"
:y="0"
@dragstop="(e) => resizeTask(t, e)"
@resizestop="(e) => resizeTask(t, e)"
axis="x"
class="task nodate"
v-tooltip="$t('list.gantt.noDates')"
>
<span>{{ t.title }}</span>
</VueDragResize>
</div>
</template>
</div>
<form
@submit.prevent="addNewTask()"
class="add-new-task"
v-if="canWrite"
>
<transition name="width">
<input
@blur="hideCrateNewTask"
@keyup.esc="newTaskFieldActive = false"
class="input"
ref="newTaskTitleField"
type="text"
v-if="newTaskFieldActive"
v-model="newTaskTitle"
/>
</transition>
<x-button @click="showCreateNewTask" :shadow="false" icon="plus">
{{ $t('list.list.newTaskCta') }}
</x-button>
</form>
<transition name="fade">
<edit-task
v-if="isTaskEdit"
class="taskedit"
:title="$t('list.list.editTask')"
@close="() => {isTaskEdit = false;taskToEdit = null}"
:task="taskToEdit"
/>
</transition>
</div>
</template>
<script lang="ts">
import {defineComponent} from 'vue'
import {mapState} from 'pinia'
import VueDragResize from 'vue-drag-resize'
import EditTask from './edit-task.vue'
import TaskService from '../../services/task'
import TaskModel from '../../models/task'
import {PRIORITIES as priorities} from '@/constants/priorities'
import PriorityLabel from './partials/priorityLabel.vue'
import TaskCollectionService from '../../services/taskCollection'
import {RIGHTS as Rights} from '@/constants/rights'
import FilterPopup from '@/components/list/partials/filter-popup.vue'
import BaseButton from '@/components/base/BaseButton.vue'
import {colorIsDark} from '@/helpers/color/colorIsDark'
import {formatDate} from '@/helpers/time/formatDate'
import {useBaseStore} from '@/stores/base'
export default defineComponent({
name: 'GanttChart',
components: {
BaseButton,
FilterPopup,
PriorityLabel,
EditTask,
VueDragResize,
},
props: {
listId: {
type: Number,
required: true,
},
showTaskswithoutDates: {
type: Boolean,
default: false,
},
dateFrom: {
default: () => new Date(new Date().setDate(new Date().getDate() - 15)),
},
dateTo: {
default: () => new Date(new Date().setDate(new Date().getDate() + 30)),
},
// The width of a day in pixels, used to calculate all sorts of things.
dayWidth: {
type: Number,
default: 35,
},
},
data() {
return {
days: [],
startDate: null,
endDate: null,
theTasks: [], // Pretty much a copy of the prop, since we cant mutate the prop directly
tasksWithoutDates: [],
taskService: new TaskService(),
fullWidth: 0,
now: new Date(),
dayOffsetUntilToday: 0,
isTaskEdit: false,
taskToEdit: null,
newTaskTitle: '',
newTaskFieldActive: false,
priorities: priorities,
taskCollectionService: new TaskCollectionService(),
params: {
sort_by: ['done', 'id'],
order_by: ['asc', 'desc'],
filter_by: ['done'],
filter_value: ['false'],
filter_comparator: ['equals'],
filter_concat: 'and',
},
}
},
watch: {
dateFrom: 'buildTheGanttChart',
dateTo: 'buildTheGanttChart',
listId: 'parseTasks',
},
mounted() {
this.buildTheGanttChart()
},
computed: mapState(useBaseStore, {
canWrite: (state) => state.currentList.maxRight > Rights.READ,
}),
methods: {
colorIsDark,
buildTheGanttChart() {
this.setDates()
this.prepareGanttDays()
this.parseTasks()
},
setDates() {
this.startDate = new Date(this.dateFrom)
this.endDate = new Date(this.dateTo)
console.debug('setDates; start date: ', this.startDate, 'end date:', this.endDate, 'date from:', this.dateFrom, 'date to:', this.dateTo)
this.dayOffsetUntilToday = Math.floor((this.now - this.startDate) / 1000 / 60 / 60 / 24) + 1
},
prepareGanttDays() {
console.debug('prepareGanttDays; start date: ', this.startDate, 'end date:', this.endDate)
// Layout: years => [months => [days]]
const years = {}
for (
let d = this.startDate;
d <= this.endDate;
d.setDate(d.getDate() + 1)
) {
const date = new Date(d)
if (years[date.getFullYear() + ''] === undefined) {
years[date.getFullYear() + ''] = {}
}
if (years[date.getFullYear() + ''][date.getMonth() + ''] === undefined) {
years[date.getFullYear() + ''][date.getMonth() + ''] = []
}
years[date.getFullYear() + ''][date.getMonth() + ''].push(date)
this.fullWidth += this.dayWidth
}
console.debug('prepareGanttDays; years:', years)
this.days = years
},
parseTasks() {
this.setDates()
this.loadTasks()
},
async loadTasks() {
this.theTasks = []
this.tasksWithoutDates = []
const getAllTasks = async (page = 1) => {
const tasks = await this.taskCollectionService.getAll({listId: this.listId}, this.params, page)
if (page < this.taskCollectionService.totalPages) {
const nextTasks = await getAllTasks(page + 1)
return tasks.concat(nextTasks)
}
return tasks
}
const tasks = await getAllTasks()
this.theTasks = tasks
.filter((t) => {
if (t.startDate === null && !t.done) {
this.tasksWithoutDates.push(t)
}
return (
t.startDate >= this.startDate &&
t.endDate <= this.endDate
)
})
.map((t) => this.addGantAttributes(t))
.sort(function (a, b) {
if (a.startDate < b.startDate) return -1
if (a.startDate > b.startDate) return 1
return 0
})
},
addGantAttributes(t) {
if (typeof t.durationDays !== 'undefined' && typeof t.offsetDays !== 'undefined') {
return t
}
t.endDate === null ? this.endDate : t.endDate
t.durationDays = Math.floor((t.endDate - t.startDate) / 1000 / 60 / 60 / 24)
t.offsetDays = Math.floor((t.startDate - this.startDate) / 1000 / 60 / 60 / 24)
return t
},
async resizeTask(taskDragged, newRect) {
if (this.isTaskEdit) {
return
}
let newTask = {...taskDragged}
const didntHaveDates = newTask.startDate === null ? true : false
const startDate = new Date(this.startDate)
startDate.setDate(
startDate.getDate() + newRect.left / this.dayWidth,
)
startDate.setUTCHours(0)
startDate.setUTCMinutes(0)
startDate.setUTCSeconds(0)
startDate.setUTCMilliseconds(0)
newTask.startDate = startDate
const endDate = new Date(startDate)
endDate.setDate(
startDate.getDate() + newRect.width / this.dayWidth,
)
newTask.startDate = startDate
newTask.endDate = endDate
// We take the task from the overall tasks array because the one in it has bad data after it was updated once.
// FIXME: This is a workaround. We should use a better mechanism to get the task or, even better,
// prevent it from containing outdated Data in the first place.
for (const tt in this.theTasks) {
if (this.theTasks[tt].id === newTask.id) {
newTask = this.theTasks[tt]
break
}
}
const ganttData = {
endDate: newTask.endDate,
durationDays: newTask.durationDays,
offsetDays: newTask.offsetDays,
}
const r = await this.taskService.update(newTask)
r.endDate = ganttData.endDate
r.durationDays = ganttData.durationDays
r.offsetDays = ganttData.offsetDays
// If the task didn't have dates before, we'll update the list
if (didntHaveDates) {
for (const t in this.tasksWithoutDates) {
if (this.tasksWithoutDates[t].id === r.id) {
this.tasksWithoutDates.splice(t, 1)
break
}
}
this.theTasks.push(this.addGantAttributes(r))
} else {
for (const tt in this.theTasks) {
if (this.theTasks[tt].id === r.id) {
this.theTasks[tt] = this.addGantAttributes(r)
break
}
}
}
},
editTask(task) {
this.taskToEdit = task
this.isTaskEdit = true
},
showCreateNewTask() {
if (!this.newTaskFieldActive) {
// Timeout to not send the form if the field isn't even shown
setTimeout(() => {
this.newTaskFieldActive = true
this.$nextTick(() => this.$refs.newTaskTitleField.focus())
}, 100)
}
},
hideCrateNewTask() {
if (this.newTaskTitle === '') {
this.$nextTick(() => (this.newTaskFieldActive = false))
}
},
async addNewTask() {
if (!this.newTaskFieldActive) {
return
}
const task = new TaskModel({
title: this.newTaskTitle,
listId: this.listId,
})
const r = await this.taskService.create(task)
this.tasksWithoutDates.push(this.addGantAttributes(r))
this.newTaskTitle = ''
this.hideCrateNewTask()
},
formatMonthAndYear(year, month) {
month = month < 10 ? '0' + month : month
const date = new Date(`${year}-${month}-01`)
return formatDate(date, 'MMMM, yyyy')
},
},
})
</script>
<style lang="scss" scoped>
$gantt-border: 1px solid var(--grey-200);
$gantt-vertical-border-color: var(--grey-100);
.gantt-chart {
overflow-x: auto;
border-top: 1px solid var(--grey-200);
.dates {
display: flex;
text-align: center;
.months {
display: flex;
.month {
padding: 0.5rem 0 0;
border-right: $gantt-border;
font-family: $vikunja-font;
font-weight: bold;
&:last-child {
border-right: none;
}
.days {
display: flex;
.day {
padding: 0.5rem 0;
font-weight: normal;
&.today {
background: var(--primary);
color: var(--white);
border-radius: 5px 5px 0 0;
font-weight: bold;
}
.theday {
padding: 0 .5rem;
width: 100%;
display: block;
}
.weekday {
font-size: 0.8rem;
}
}
}
}
}
}
.tasks {
max-width: unset !important;
border-top: $gantt-border;
.row {
height: 45px;
.task {
display: inline-block;
border: 2px solid var(--primary);
font-size: 0.85rem;
margin: 0.5rem;
border-radius: 6px;
padding: 0.25rem 0.5rem;
cursor: grab;
position: relative;
height: 31px !important;
-webkit-touch-callout: none; // iOS Safari
user-select: none; // Non-prefixed version
&.is-current-edit {
border-color: var(--warning) !important;
}
&.has-light-text {
color: var(--grey-100);
&.done span:after {
border-top: 1px solid var(--grey-100);
}
.edit-toggle {
color: var(--grey-100);
}
}
&.has-dark-text {
color: var(--text);
&.done span:after {
border-top: 1px solid var(--dark);
}
.edit-toggle {
color: var(--text);
}
}
&.done span {
position: relative;
&::after {
content: '';
position: absolute;
right: 0;
left: 0;
top: 57%;
}
}
span:not(.high-priority) {
max-width: calc(100% - 20px);
display: inline-block;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
&.has-high-priority {
max-width: calc(100% - 90px);
}
&.has-not-so-high-priority {
max-width: calc(100% - 70px);
}
&.has-super-high-priority {
max-width: calc(100% - 111px);
}
&.icon {
width: 10px;
text-align: center;
}
}
.high-priority {
margin: 0 0 0 .5rem;
vertical-align: bottom;
}
.edit-toggle {
float: right;
cursor: pointer;
margin-right: 4px;
}
&.nodate {
border: 2px dashed var(--grey-300);
background: var(--grey-100);
}
&:active {
cursor: grabbing;
}
}
}
}
.taskedit {
position: fixed;
top: 10vh;
right: 10vw;
z-index: 5;
// FIXME: should be an option of the card, e.g. overflow
:deep(.card-content) {
max-height: 60vh;
overflow-y: auto;
}
}
.add-new-task {
padding: 1rem .7rem .4rem .7rem;
display: flex;
max-width: 450px;
.input {
margin-right: .7rem;
font-size: .8rem;
}
.button {
font-size: .68rem;
}
}
}
</style>

View file

@ -4,7 +4,7 @@
* @param color * @param color
* @returns {string} * @returns {string}
*/ */
export function colorFromHex(color) { export function colorFromHex(color: string) {
if (color.substring(0, 1) === '#') { if (color.substring(0, 1) === '#') {
color = color.substring(1, 7) color = color.substring(1, 7)
} }

View file

@ -285,8 +285,8 @@
"default": "Default", "default": "Default",
"month": "Month", "month": "Month",
"day": "Day", "day": "Day",
"from": "From", "hour": "Hour",
"to": "To", "range": "Range",
"noDates": "This task has no dates set." "noDates": "This task has no dates set."
}, },
"table": { "table": {

View file

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

View file

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

View file

@ -1,47 +1,23 @@
<template> <template>
<ListWrapper class="list-gantt" :list-id="props.listId" viewName="gantt"> <ListWrapper class="list-gantt" :list-id="props.listId" viewName="gantt">
<template #header> <template #header>
<card class="gantt-options"> <card>
<fancycheckbox class="is-block" v-model="showTaskswithoutDates"> <div class="gantt-options">
{{ $t('list.gantt.showTasksWithoutDates') }}
</fancycheckbox>
<div class="range-picker">
<div class="field"> <div class="field">
<label class="label" for="dayWidth">{{ $t('list.gantt.size') }}</label> <label class="label" for="range">{{ $t('list.gantt.range') }}</label>
<div class="control">
<div class="select">
<select id="dayWidth" v-model.number="dayWidth">
<option value="35">{{ $t('list.gantt.default') }}</option>
<option value="10">{{ $t('list.gantt.month') }}</option>
<option value="80">{{ $t('list.gantt.day') }}</option>
</select>
</div>
</div>
</div>
<div class="field">
<label class="label" for="fromDate">{{ $t('list.gantt.from') }}</label>
<div class="control"> <div class="control">
<flat-pickr <flat-pickr
:config="flatPickerConfig" :config="flatPickerConfig"
class="input" class="input"
id="fromDate" id="range"
:placeholder="$t('list.gantt.from')" :placeholder="$t('list.gantt.range')"
v-model="dateFrom" v-model="range"
/>
</div>
</div>
<div class="field">
<label class="label" for="toDate">{{ $t('list.gantt.to') }}</label>
<div class="control">
<flat-pickr
:config="flatPickerConfig"
class="input"
id="toDate"
:placeholder="$t('list.gantt.to')"
v-model="dateTo"
/> />
</div> </div>
</div> </div>
<fancycheckbox class="is-block" v-model="showTasksWithoutDates">
{{ $t('list.gantt.showTasksWithoutDates') }}
</fancycheckbox>
</div> </div>
</card> </card>
</template> </template>
@ -53,9 +29,8 @@
<gantt-chart <gantt-chart
:date-from="dateFrom" :date-from="dateFrom"
:date-to="dateTo" :date-to="dateTo"
:day-width="dayWidth"
:list-id="props.listId" :list-id="props.listId"
:show-taskswithout-dates="showTaskswithoutDates" :show-tasks-without-dates="showTasksWithoutDates"
/> />
</card> </card>
@ -72,8 +47,9 @@ import {useI18n} from 'vue-i18n'
import {useAuthStore} from '@/stores/auth' import {useAuthStore} from '@/stores/auth'
import ListWrapper from './ListWrapper.vue' import ListWrapper from './ListWrapper.vue'
import GanttChart from '@/components/tasks/gantt-component.vue' import GanttChart from '@/components/tasks/gantt-chart.vue'
import Fancycheckbox from '@/components/input/fancycheckbox.vue' import Fancycheckbox from '@/components/input/fancycheckbox.vue'
import {format} from 'date-fns'
const props = defineProps({ const props = defineProps({
listId: { listId: {
@ -82,14 +58,16 @@ const props = defineProps({
}, },
}) })
const DEFAULT_DAY_COUNT = 35 const showTasksWithoutDates = ref(false)
const showTaskswithoutDates = ref(false) const now = new Date()
const dayWidth = ref(DEFAULT_DAY_COUNT) 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}`)
const now = ref(new Date()) // TODO: only update once both dates are available (maybe use a watcher + refs instead?)
const dateFrom = ref(new Date((new Date()).setDate(now.value.getDate() - 15))) const dateFrom = computed(() => range.value?.split(' to ')[0] ?? defaultFrom)
const dateTo = ref(new Date((new Date()).setDate(now.value.getDate() + 30))) const dateTo = computed(() => range.value?.split(' to ')[1] ?? defaultTo)
const {t} = useI18n({useScope: 'global'}) const {t} = useI18n({useScope: 'global'})
const authStore = useAuthStore() const authStore = useAuthStore()
@ -98,6 +76,7 @@ const flatPickerConfig = computed(() => ({
altInput: true, altInput: true,
dateFormat: 'Y-m-d', dateFormat: 'Y-m-d',
enableTime: false, enableTime: false,
mode: 'range',
locale: { locale: {
firstDayOfWeek: authStore.settings.weekStart, firstDayOfWeek: authStore.settings.weekStart,
}, },
@ -119,46 +98,35 @@ const flatPickerConfig = computed(() => ({
flex-direction: column; flex-direction: column;
} }
.range-picker { .field {
display: flex; margin-bottom: 0;
margin-bottom: 1rem; width: 33%;
width: 50%;
@media screen and (max-width: $tablet) { &:not(:last-child) {
flex-direction: column; padding-right: .5rem;
width: 100%;
} }
.field { @media screen and (max-width: $tablet) {
margin-bottom: 0; width: 100%;
width: 33%; max-width: 100%;
margin-top: .5rem;
padding-right: 0 !important;
}
&:not(:last-child) { &, .input {
padding-right: .5rem; font-size: .8rem;
} }
@media screen and (max-width: $tablet) { .select, .select select {
width: 100%; height: auto;
max-width: 100%; width: 100%;
margin-top: .5rem; font-size: .8rem;
padding-right: 0 !important; }
}
&, .input {
font-size: .8rem;
}
.select, .select select {
height: auto;
width: 100%;
font-size: .8rem;
}
.label { .label {
font-size: .9rem; font-size: .9rem;
padding-left: .4rem; padding-left: .4rem;
}
} }
} }
} }

Binary file not shown.