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/features/kanban/Bucket.vue

373 lines
8.3 KiB
Vue

<template>
<div
class="bucket"
:class="{'is-collapsed': isCollapsed}"
>
<div class="bucket-header" @click="isCollapsed = false">
<span
v-if="bucket.isDoneBucket"
class="icon is-small has-text-success mr-2"
v-tooltip="$t('list.kanban.doneBucketHint')"
>
<icon icon="check-double"/>
</span>
<h2
@keydown.enter.prevent.stop="($event.target as HTMLInputElement).blur()"
@keydown.esc.prevent.stop="($event.target as HTMLInputElement).blur()"
@blur="saveBucketTitle(($event.target as HTMLInputElement).textContent as string)"
@click="focusBucketTitle"
class="title input"
:contenteditable="bucketTitleEditable && canWrite && !isCollapsed || undefined"
:spellcheck="false">{{ bucket.title }}</h2>
<span
:class="{'is-max': bucket.tasks.length >= bucket.limit}"
class="limit"
v-if="bucket.limit > 0">
{{ bucket.tasks.length }}/{{ bucket.limit }}
</span>
<dropdown
class="is-right options"
v-if="canWrite && !isCollapsed"
trigger-icon="ellipsis-v"
@close="hasLimitInput =false"
>
<a
v-if="hasLimitInput"
@click.stop="hasLimitInput = true"
class="dropdown-item"
>
<div class="field has-addons">
<div class="control">
<input
@keyup.esc="hasLimitInput = false"
@keyup.enter="hasLimitInput = false"
:value="bucket.limit"
@input="setBucketLimit(parseInt(($event.target as HTMLInputElement).value))"
class="input"
type="number"
min="0"
v-focus.always
/>
</div>
<div class="control">
<x-button
:disabled="bucket.limit < 0"
:icon="['far', 'save']"
:shadow="false"
v-cy="'setBucketLimit'"
/>
</div>
</div>
</a>
<div
v-else
@click.stop="hasLimitInput = true"
class="dropdown-item"
>
{{ $t('list.kanban.limit', {
limit: bucket.limit > 0
? bucket.limit
: $t('list.kanban.noLimit')
})
}}
</div>
<a
@click.stop="toggleDoneBucket()"
class="dropdown-item"
v-tooltip="$t('list.kanban.doneBucketHintExtended')"
>
<span class="icon is-small" :class="{'has-text-success': bucket.isDoneBucket}">
<icon icon="check-double"/>
</span>
{{ $t('list.kanban.doneBucket') }}
</a>
<a
class="dropdown-item"
@click.stop="isCollapsed = true"
>
{{ $t('list.kanban.collapse') }}
</a>
<a
:class="{'is-disabled': isOnlyBucketLeft}"
@click.stop="emit('openDeleteBucketModal', bucket.id)"
class="dropdown-item has-text-danger"
v-tooltip="isOnlyBucketLeft && $t('list.kanban.deleteLast')"
>
<span class="icon is-small">
<icon icon="trash-alt"/>
</span>
{{ $t('misc.delete') }}
</a>
</dropdown>
</div>
<Draggable
v-bind="dragOptions"
:modelValue="bucket.tasks"
@update:modelValue="updateTasks"
@start="emit('dragstart', bucket.id)"
@end="emit('dragend', $event)"
:group="{name: 'tasks', put: shouldAcceptDrop}"
:disabled="!canWrite"
:data-bucket-index="bucketIndex"
tag="transition-group"
:item-key="(task: TaskModel) => `bucket${bucket.id}-task${task.id}`"
:component-data="taskDraggableTaskComponentData"
>
<template #item="{element: task}">
<div class="task-item">
<TaskCard
class="kanban-card"
:task="task"
:loading="tasksUpdating[task.id]"
/>
</div>
</template>
<template #footer>
<TaskNew
v-if="canWrite"
:bucketId="bucket.id"
:listId="bucket.listId"
:bucketIsEmpty="bucket.tasks.length === 0"
@scrollTaskContainerToBottom="scrollTaskContainerToBottom"
/>
</template>
</Draggable>
</div>
</template>
<script setup lang="ts">
import {ref, computed, nextTick} from 'vue'
import {useStore} from 'vuex'
import {useI18n} from 'vue-i18n'
import Draggable from 'vuedraggable'
import { useVModels } from '@vueuse/core'
import {success} from '@/message'
import BucketModel from '@/models/bucket'
import TaskModel from '@/models/task'
import Dropdown from '@/components/misc/dropdown.vue'
import TaskCard from '@/features/kanban/TaskCard.vue'
import TaskNew from '@/features/kanban/TaskNew.vue'
const MIN_SCROLL_HEIGHT_PERCENT = 0.25
const props = defineProps<{
bucketIndex: number
isCollapsed: boolean
canWrite: boolean
bucket: BucketModel
isOnlyBucketLeft: boolean
dragOptions: Object
params: Object
shouldAcceptDrop: boolean
isDraggingTask: boolean
tasksUpdating: { [taskId: `${TaskModel['id']}`]: boolean },
}>()
const emit = defineEmits([
'openDeleteBucketModal',
'dragstart',
'dragend',
'update:isCollapsed',
])
const {t} = useI18n()
const store = useStore()
const { isCollapsed } = useVModels(props, emit)
const bucketTitleEditable = ref(true)
async function saveBucketTitle(bucketTitle: string) {
await store.dispatch('kanban/updateBucketTitle', {
id: props.bucket.id,
title: bucketTitle,
})
bucketTitleEditable.value = false
success({message: t('list.kanban.bucketTitleSavedSuccess')})
}
async function focusBucketTitle(e: Event) {
// This little helper allows us to drag a bucket around at the title without focusing on it right away.
bucketTitleEditable.value = true
await nextTick();
(e.target as HTMLInputElement).focus()
}
const hasLimitInput = ref(false)
async function setBucketLimit(limit: number) {
if (limit < 0) {
return
}
await store.dispatch('kanban/updateBucket', {
...props.bucket,
limit,
})
success({message: t('list.kanban.bucketLimitSavedSuccess')})
}
async function toggleDoneBucket() {
await store.dispatch('kanban/updateBucket', {
...props.bucket,
isDoneBucket: !props.bucket.isDoneBucket,
})
success({message: t('list.kanban.doneBucketSavedSuccess')})
}
function updateTasks(tasks: TaskModel[]) {
store.commit('kanban/setBucketById', {
...props.bucket,
tasks,
})
}
function handleTaskContainerScroll(el: HTMLElement) {
if (!el) {
return
}
const scrollTopMax = el.scrollHeight - el.clientHeight
const threshold = el.scrollTop + el.scrollTop * MIN_SCROLL_HEIGHT_PERCENT
if (scrollTopMax > threshold) {
return
}
store.dispatch('kanban/loadNextTasksForBucket', {
listId: props.bucket.listId,
params: props.params,
bucketId: props.bucket.id,
})
}
const taskContainer = ref<HTMLElement>()
const taskDraggableTaskComponentData = computed(() => ({
ref: 'taskContainer',
onScroll: (event: Event) => handleTaskContainerScroll(event.target as HTMLElement),
type: 'transition',
tag: 'div',
name: !props.isDraggingTask ? 'move-card' : null,
class: [
'tasks',
{'dragging-disabled': !props.canWrite},
],
}))
function scrollTaskContainerToBottom() {
if (!taskContainer.value) {
return
}
taskContainer.value.scrollTop = taskContainer.value.scrollHeight
}
</script>
<style lang="scss" scoped>
.bucket {
--bucket-header-height: 60px;
border-radius: $radius;
position: relative;
display: flex;
flex-direction: column;
}
.is-collapsed {
align-self: flex-start;
transform: rotate(90deg) translateY(-100%);
transform-origin: top left;
// Using negative margins instead of translateY here to make all other buckets fill the empty space
margin-right: calc((var(--bucket-width) - var(--bucket-header-height) - var(--bucket-right-margin)) * -1);
cursor: pointer;
.tasks, .bucket-footer {
display: none;
}
}
.tasks {
overflow: hidden auto;
height: 100%;
}
.task-item {
background-color: var(--grey-100);
padding: .25rem .5rem;
&:first-of-type {
padding-top: .5rem;
}
&:last-of-type {
padding-bottom: .5rem;
}
}
.no-move {
transition: transform 0s;
}
h2 {
font-size: 1rem;
margin: 0;
font-weight: 600 !important;
}
a.dropdown-item {
padding-right: 1rem;
}
.bucket-header {
background-color: var(--grey-100);
height: min-content;
display: flex;
align-items: center;
justify-content: space-between;
padding: .5rem;
height: var(--bucket-header-height);
}
.limit {
padding: 0 .5rem;
font-weight: bold;
&.is-max {
color: var(--danger);
}
}
.title.input {
height: auto;
padding: .4rem .5rem;
display: inline-block;
cursor: pointer;
}
:deep(.dropdown-trigger) {
cursor: pointer;
padding: .5rem;
}
.bucket-footer {
position: sticky;
bottom: 0;
height: min-content;
}
.move-card-move {
transform: rotateZ(3deg);
transition: transform $transition-duration;
}
.move-card-leave-from,
.move-card-leave-to,
.move-card-leave-active {
display: none;
}
</style>