This repository has been archived on 2024-02-08. You can view files and clone it, but cannot push or open issues or pull requests.
frontend/src/views/list/ListKanban.vue

374 lines
9.7 KiB
Vue

<template>
<ListWrapper class="list-kanban" :list-id="listId" viewName="kanban">
<template #header>
<div class="filter-container" v-if="!isSavedFilter">
<div class="items">
<filter-popup v-model="params" />
</div>
</div>
</template>
<template #default>
<div
:class="{ 'is-loading': loading && !oneTaskUpdating}"
class="kanban kanban-bucket-container loader-container"
>
<draggable
v-bind="DRAG_OPTIONS"
:modelValue="buckets"
@update:modelValue="updateBuckets"
@start="() => isDraggingBucket = true"
@end="updateBucketPosition"
group="buckets"
:disabled="!canWrite"
tag="transition-group"
:item-key="(id: number) => `bucket${id}`"
:component-data="bucketDraggableComponentData"
>
<template #item="{element: bucket, index: bucketIndex }">
<Bucket
class="bucket"
:bucket-index="bucketIndex"
:is-collapsed="collapsedBuckets[bucket.id]"
:can-write="canWrite"
:bucket="bucket"
:isOnlyBucketLeft="buckets.length <= 1"
:drag-options="DRAG_OPTIONS"
:params="params"
:should-accept-drop="shouldAcceptDrop(bucket)"
:isDraggingTask="isDraggingTask"
:taskUpdating="tasksUpdating"
@dragstart="dragstart"
@dragend="updateTaskPosition"
@openDeleteBucketModal="openDeleteBucketModal"
/>
</template>
</draggable>
<BucketNew
v-if="canWrite && !loading && buckets.length > 0"
class="bucket"
:listId="listId"
/>
</div>
<transition name="modal">
<modal
v-if="hasBucketDeleteModal"
@close="hasBucketDeleteModal = false"
@submit="deleteBucket()"
>
<template #header><span>{{ $t('list.kanban.deleteHeaderBucket') }}</span></template>
<template #text>
<p>{{ $t('list.kanban.deleteBucketText1') }}<br/>
{{ $t('list.kanban.deleteBucketText2') }}</p>
</template>
</modal>
</transition>
</template>
</ListWrapper>
</template>
<script setup lang="ts">
import {ref, computed, watch} from 'vue'
import draggable from 'zhyswan-vuedraggable'
import cloneDeep from 'lodash.clonedeep'
import {useI18n} from 'vue-i18n'
import {SortableEvent} from 'sortablejs'
import {success} from '@/message'
import {calculateItemPosition} from '@/helpers/calculateItemPosition'
import {getCollapsedBucketState, saveCollapsedBucketState} from '@/helpers/saveCollapsedBucketState'
import ListModel from '@/models/list'
import BucketModel from '@/models/bucket'
import TaskModel from '@/models/task'
import Rights from '@/models/constants/rights.json'
import ListWrapper from './ListWrapper.vue'
import FilterPopup from '@/components/list/partials/filter-popup.vue'
import Bucket from '@/features/kanban/Bucket.vue'
import BucketNew from '@/features/kanban/BucketNew.vue'
const DRAG_OPTIONS = {
// sortable options
animation: 150,
ghostClass: 'ghost',
dragClass: 'task-dragging',
delayOnTouchOnly: true,
delay: 150,
}
const props = defineProps<{
listId: number
}>()
const {t} = useI18n()
/**
* Load collapsed Buckets
*/
const collapsedBuckets = ref<{ [listId: number]: any}>({})
watch(collapsedBuckets, (collapsedBuckets) => saveCollapsedBucketState(props.listId, collapsedBuckets))
/**
* Load Bucket Data
*/
const params = ref({
filter_by: [],
filter_value: [],
filter_comparator: [],
filter_concat: 'and',
})
watch(() => ({
listId: props.listId,
params: params.value,
}), ({listId, params}) => {
store.dispatch('kanban/loadBucketsForList', {listId, params})
collapsedBuckets.value = getCollapsedBucketState(listId)
}, {immediate: true, deep: true})
const list = computed(() => store.state.currentList as ListModel)
const buckets = computed({
get: () => store.state.kanban.buckets,
set(value: BucketModel[]) {
// (1) buckets get updated in store and tasks positions get invalidated
store.commit('kanban/setBuckets', value)
},
})
/**
* Template helpers
*/
const isSavedFilter = computed(() => list.value.isSavedFilter && list.value.isSavedFilter())
const canWrite = computed(() => store.state.currentList.maxRight > Rights.READ)
const loading = computed(() => store.state.loading && store.state.loadingModule === 'kanban')
// FIXME: seems unused ?
// const taskLoading = computed(() => store.state.loading && store.state.loadingModule === 'tasks')
/**
* Manage list of updateing tasks
*/
// FIXME: save globally if a specific task is updated.
// We're using this to show the loading animation only at the task when updating it
const tasksUpdating = ref<{ [taskId: TaskModel['id']]: boolean }>({})
function setTaskUpdating(id: TaskModel['id'], isUpdating: boolean) {
tasksUpdating.value[id] = isUpdating
}
const oneTaskUpdating = computed(() => Object.values(tasksUpdating.value).some((isUpdating) => isUpdating))
/**
* Delete Bucket
*/
const bucketToDeleteId = ref<BucketModel['id']>(0)
const hasBucketDeleteModal = ref(false)
function openDeleteBucketModal(bucketId: BucketModel['id']) {
if (buckets.value.length <= 1) {
return
}
bucketToDeleteId.value = bucketId
hasBucketDeleteModal.value = true
}
async function deleteBucket() {
try {
await store.dispatch('kanban/deleteBucket', {
bucket: new BucketModel({
id: bucketToDeleteId.value,
listId: props.listId,
}),
params: params.value,
})
success({message: t('list.kanban.deleteBucketSuccess')})
} finally {
hasBucketDeleteModal.value = false
}
}
/**
* Move / Drag Bucket
*/
const isDraggingBucket = ref(false)
const bucketDraggableComponentData = computed(() => ({
type: 'transition',
tag: 'div',
name: !isDraggingBucket.value ? 'move-bucket' : null,
class: [
'kanban-bucket-container',
{'dragging-disabled': !canWrite.value},
],
}))
function updateBucketPosition({newDraggableIndex}: SortableEvent) {
// (2) bucket positon is changed
isDraggingBucket.value = false
const bucket = buckets.value[newDraggableIndex]
const bucketBefore = buckets.value[newDraggableIndex - 1] ?? null
const bucketAfter = buckets.value[newDraggableIndex + 1] ?? null
store.dispatch('kanban/updateBucket', {
id: bucket.id,
position: calculateItemPosition(
bucketBefore !== null ? bucketBefore.position : null,
bucketAfter !== null ? bucketAfter.position : null,
),
})
}
/**
* Move / Drag Task
*/
const sourceBucketId = ref<number>()
function shouldAcceptDrop(bucket: BucketModel) {
return !isDraggingBucket.value && (
// It's always possible to drag a task inside the same a bucket
bucket.id === sourceBucketId.value ||
// If there is no limit set, dragging & dropping should always work
bucket.limit === 0 ||
// Disallow dropping to buckets which have their limit reached
bucket.tasks.length < bucket.limit
)
}
const isDraggingTask = ref(false)
function dragstart(bucketId: BucketModel['id']) {
isDraggingTask.value = true
sourceBucketId.value = bucketId
}
async function updateTaskPosition({newDraggableIndex, to}: SortableEvent) {
isDraggingTask.value = false
// FIXME: Cases
// e.to.dataset.bucketIndex === undefined
// e.newIndex === undefined
// While we could just pass the bucket index in through the function call, this would not give us the
// new bucket id when a task has been moved between buckets, only the new bucket. Using the data-bucket-id
// of the drop target works all the time.
const bucketIndex = parseInt(to.dataset.bucketIndex)
const newBucket = buckets.value[bucketIndex]
// HACK:
// this is a hacky workaround for a known problem of vue.draggable.next when using the footer slot
// the problem: https://github.com/SortableJS/vue.draggable.next/issues/108
// This hack doesn't remove the problem that the ghost item is still displayed below the footer
// It just makes releasing the item possible.
// The newIndex of the event doesn't count in the elements of the footer slot.
// This is why in case the length of the tasks is identical with the newIndex
// we have to remove 1 to get the correct index.
// const newTaskIndex = newBucket.tasks.length === e.newIndex
// ? e.newIndex - 1
// : e.newIndex
const taskBefore = newBucket.tasks[newDraggableIndex - 1] ?? null
const taskAfter = newBucket.tasks[newDraggableIndex + 1] ?? null
const task = {
// cloning the task to avoid vuex store mutations
...cloneDeep(newBucket.tasks[newDraggableIndex]),
bucketId: newBucket.id,
kanbanPosition: calculateItemPosition(
taskBefore !== null ? taskBefore.kanbanPosition : null,
taskAfter !== null ? taskAfter.kanbanPosition : null,
),
} as TaskModel
setTaskUpdating(task.id, false)
try {
await store.dispatch('tasks/update', task)
} finally {
setTaskUpdating(task.id, false)
}
}
</script>
<style lang="scss">
// FIXME:
.app-content.list\.kanban {
padding-bottom: 0 !important;
}
$ease-out: all .3s cubic-bezier(0.23, 1, 0.32, 1);
// $crazy-height-calculation: '100vh - 4.5rem - 1.5rem - 1rem - 1.5rem - 11px';
// $filter-container-height: '1rem - #{$switch-view-height}';
.kanban {
--bucket-width: 300px;
--bucket-right-margin: 1rem;
overflow-x: auto;
overflow-y: hidden;
// height: calc(#{$crazy-height-calculation});
margin: 0 -1.5rem;
padding: 0 1.5rem;
scroll-snap-type: x mandatory;
// @media screen and (max-width: $tablet) {
// height: calc(#{$crazy-height-calculation} - #{$filter-container-height});
// }
}
.kanban-bucket-container {
display: flex;
}
.bucket {
margin-right: var(--bucket-right-margin);
max-height: 100%;
min-height: 20px;
width: var(--bucket-width);
}
.ghost {
position: relative;
* {
opacity: 0;
}
&::after {
content: '';
position: absolute;
display: block;
top: 0.25rem;
right: 0.5rem;
bottom: 0.25rem;
left: 0.5rem;
border: 3px dashed var(--grey-300);
border-radius: $radius;
}
}
.task-dragging {
transform: rotateZ(3deg);
transition: transform 0.18s ease;
}
@include modal-transition();
</style>