Dominik Pschenitschni
193212ad05
# Conflicts: # src/features/kanban/TaskCard.vue # src/helpers/saveCollapsedBucketState.ts
374 lines
9.7 KiB
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> |