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

392 lines
11 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"
@update:modelValue="loadBuckets"
/>
</div>
</div>
</template>
<template #default>
<div class="kanban-view">
<div
:class="{ 'is-loading': loading && !oneTaskUpdating}"
class="kanban kanban-bucket-container loader-container"
>
<draggable
v-bind="DRAG_OPTIONS"
:modelValue="buckets"
@update:modelValue="updateBuckets"
@end="updateBucketPosition"
@start="() => isDraggingBucket = true"
group="buckets"
:disabled="!canWrite"
tag="transition-group"
:item-key="(id: number) => `bucket${id}`"
:component-data="bucketDraggableComponentData"
>
<template #item="{element: bucket, index: bucketIndex }">
<KanbanBucket
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"
@dragstart="dragstart"
@updateTaskPosition="updateTaskPosition"
@openDeleteBucketModal="openDeleteBucketModal"
/>
</template>
</draggable>
<div class="bucket new-bucket" v-if="canWrite && !loading && buckets.length > 0">
<input
v-if="hasNewBucketInput"
:class="{'is-loading': loading}"
:disabled="loading || null"
@blur="hasNewBucketInput = false"
@keyup.enter="createNewBucket"
@keyup.esc="($event.target as HTMLInputElement).blur()"
class="input"
:placeholder="$t('list.kanban.addBucketPlaceholder')"
type="text"
v-focus.always
v-model="newBucketTitle"
/>
<x-button
v-else
@click="hasNewBucketInput = true"
:shadow="false"
class="is-transparent is-fullwidth has-text-centered"
variant="secondary"
icon="plus"
>
{{ $t('list.kanban.addBucket') }}
</x-button>
</div>
</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>
</div>
</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 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 KanbanBucket from '@/components/tasks/partials/KanbanBucket.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()
const collapsedBuckets = ref({})
const params = ref({
filter_by: [],
filter_value: [],
filter_comparator: [],
filter_concat: 'and',
})
interface LoadBucketsParams {
listId: number,
params: Object
}
watch(() => ({
listId: props.listId,
params: params.value,
} as LoadBucketsParams), loadBuckets, {immediate: true})
function loadBuckets({listId, params} : LoadBucketsParams) {
store.dispatch('kanban/loadBucketsForList', {listId, params})
collapsedBuckets.value = getCollapsedBucketState(listId)
}
const isSavedFilter = computed(() => list.value.isSavedFilter && !list.value.isSavedFilter())
const buckets = computed(() => store.state.kanban.buckets)
const loading = computed(() => store.state.loading && store.state.loadingModule === 'kanban')
// const taskLoading = computed(() => store.state.loading && store.state.loadingModule === 'tasks') // unused ?
const canWrite = computed(() => store.state.currentList.maxRight > Rights.READ)
const list = computed(() => store.state.currentList)
// We're using this to show the loading animation only at the task when updating it
const taskUpdating = ref<{ [id: TaskModel['id']]: boolean }>({})
const oneTaskUpdating = ref(false)
const isDraggingTask = ref(false)
async function updateTaskPosition(e) {
isDraggingTask.value = false
// 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(e.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[newTaskIndex - 1] ?? null
const taskAfter = newBucket.tasks[newTaskIndex + 1] ?? null
const task = {
// cloning the task to avoid vuex store mutations
...cloneDeep(newBucket.tasks[newTaskIndex]),
bucketId: newBucket.id,
kanbanPosition: calculateItemPosition(
taskBefore !== null ? taskBefore.kanbanPosition : null,
taskAfter !== null ? taskAfter.kanbanPosition : null,
),
}
try {
await store.dispatch('tasks/update', task)
} finally {
taskUpdating.value[task.id] = false
oneTaskUpdating.value = false
}
}
const hasNewBucketInput = ref(false)
const newBucketTitle = ref('')
async function createNewBucket() {
if (newBucketTitle.value === '') {
return
}
await store.dispatch('kanban/createBucket', new BucketModel({
title: newBucketTitle.value,
listId: props.listId,
}))
newBucketTitle.value = ''
hasNewBucketInput.value = false
}
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
}
}
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 updateBuckets(value: BucketModel[]) {
// (1) buckets get updated in store and tasks positions get invalidated
store.commit('kanban/setBuckets', value)
}
function updateBucketPosition(e) {
// (2) bucket positon is changed
isDraggingBucket.value = false
const bucket = buckets.value[e.newIndex]
const bucketBefore = buckets.value[e.newIndex - 1] ?? null
const bucketAfter = buckets.value[e.newIndex + 1] ?? null
store.dispatch('kanban/updateBucket', {
id: bucket.id,
position: calculateItemPosition(
bucketBefore !== null ? bucketBefore.position : null,
bucketAfter !== null ? bucketAfter.position : null,
),
})
}
const sourceBucketId = ref(0)
function shouldAcceptDrop(bucket: BucketModel) {
return !isDraggingBucket.value && (
// When dragging from a bucket who has its limit reached, dragging should still be possible
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
)
}
function dragstart(bucketId: BucketModel['id']) {
isDraggingTask.value = true
sourceBucketId.value = bucketId
}
</script>
<style lang="scss">
// FIXME:
.app-content.list\.kanban {
padding-bottom: 0 !important;
}
</style>
<style lang="scss">
$ease-out: all .3s cubic-bezier(0.23, 1, 0.32, 1);
$crazy-height-calculation: '100vh - 4.5rem - 1.5rem - 1rem - 1.5rem - 11px';
$crazy-height-calculation-tasks: '#{$crazy-height-calculation} - 1rem - 2.5rem - 2rem - #{$button-height} - 1rem';
$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: 0 var(--bucket-right-margin) 0 0;
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;
}
}
.new-bucket {
// Because of reasons, this button ignores the margin we gave it to the right.
// To make it still look like it has some, we modify the container to have a padding of 1rem,
// which is the same as the margin it should have. Then we make the container itself bigger
// to hide the fact we just made the button smaller.
min-width: calc(var(--bucket-width) + 1rem);
background: transparent;
padding-right: 1rem;
.button {
background: var(--grey-100);
width: 100%;
}
}
// FIXME: This does not seem to work
.task-dragging {
transform: rotateZ(3deg);
transition: transform 0.18s ease;
}
@include modal-transition();
</style>