392 lines
11 KiB
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> |