2022-02-18 12:24:23 +00:00
|
|
|
<template>
|
|
|
|
<div
|
|
|
|
class="bucket"
|
|
|
|
:class="{'is-collapsed': isCollapsed}"
|
|
|
|
>
|
2022-02-21 15:48:59 +00:00
|
|
|
<div class="bucket-header" @click="isCollapsed = false">
|
2022-02-18 12:24:23 +00:00
|
|
|
<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"
|
2022-02-21 15:48:59 +00:00
|
|
|
@click.stop="isCollapsed = true"
|
2022-02-18 12:24:23 +00:00
|
|
|
>
|
|
|
|
{{ $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)"
|
2022-02-21 15:48:59 +00:00
|
|
|
@end="emit('dragend', $event)"
|
2022-02-18 12:24:23 +00:00
|
|
|
: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">
|
2022-05-10 23:56:14 +00:00
|
|
|
<TaskCard
|
2022-02-21 15:48:59 +00:00
|
|
|
class="kanban-card"
|
|
|
|
:task="task"
|
|
|
|
:loading="tasksUpdating[task.id]"
|
|
|
|
/>
|
2022-02-18 12:24:23 +00:00
|
|
|
</div>
|
|
|
|
</template>
|
2022-02-21 15:48:59 +00:00
|
|
|
|
|
|
|
<template #footer>
|
2022-05-10 23:56:14 +00:00
|
|
|
<TaskNew
|
2022-02-21 15:48:59 +00:00
|
|
|
v-if="canWrite"
|
|
|
|
:bucketId="bucket.id"
|
|
|
|
:listId="bucket.listId"
|
|
|
|
:bucketIsEmpty="bucket.tasks.length === 0"
|
|
|
|
@scrollTaskContainerToBottom="scrollTaskContainerToBottom"
|
|
|
|
/>
|
|
|
|
</template>
|
2022-02-18 12:24:23 +00:00
|
|
|
</Draggable>
|
|
|
|
</div>
|
|
|
|
</template>
|
|
|
|
|
2022-05-10 23:56:14 +00:00
|
|
|
<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
|
2022-05-19 20:13:34 +00:00
|
|
|
tasksUpdating: { [taskId: `${TaskModel['id']}`]: boolean },
|
2022-05-10 23:56:14 +00:00
|
|
|
}>()
|
|
|
|
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>
|
|
|
|
|
2022-02-18 12:24:23 +00:00
|
|
|
<style lang="scss" scoped>
|
|
|
|
.bucket {
|
|
|
|
--bucket-header-height: 60px;
|
|
|
|
|
|
|
|
border-radius: $radius;
|
|
|
|
position: relative;
|
|
|
|
|
|
|
|
display: flex;
|
|
|
|
flex-direction: column;
|
2022-02-21 15:48:59 +00:00
|
|
|
}
|
2022-02-18 12:24:23 +00:00
|
|
|
|
2022-02-21 15:48:59 +00:00
|
|
|
.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;
|
2022-02-18 12:24:23 +00:00
|
|
|
}
|
2022-02-21 15:48:59 +00:00
|
|
|
}
|
2022-02-18 12:24:23 +00:00
|
|
|
|
2022-02-21 15:48:59 +00:00
|
|
|
.tasks {
|
|
|
|
overflow: hidden auto;
|
|
|
|
height: 100%;
|
|
|
|
}
|
2022-02-18 12:24:23 +00:00
|
|
|
|
2022-02-21 15:48:59 +00:00
|
|
|
.task-item {
|
|
|
|
background-color: var(--grey-100);
|
|
|
|
padding: .25rem .5rem;
|
2022-02-18 12:24:23 +00:00
|
|
|
|
2022-02-21 15:48:59 +00:00
|
|
|
&:first-of-type {
|
|
|
|
padding-top: .5rem;
|
2022-02-18 12:24:23 +00:00
|
|
|
}
|
2022-02-21 15:48:59 +00:00
|
|
|
&:last-of-type {
|
|
|
|
padding-bottom: .5rem;
|
|
|
|
}
|
|
|
|
}
|
2022-02-18 12:24:23 +00:00
|
|
|
|
2022-02-21 15:48:59 +00:00
|
|
|
.no-move {
|
|
|
|
transition: transform 0s;
|
|
|
|
}
|
2022-02-18 12:24:23 +00:00
|
|
|
|
2022-02-21 15:48:59 +00:00
|
|
|
h2 {
|
|
|
|
font-size: 1rem;
|
|
|
|
margin: 0;
|
|
|
|
font-weight: 600 !important;
|
|
|
|
}
|
2022-02-18 12:24:23 +00:00
|
|
|
|
2022-02-21 15:48:59 +00:00
|
|
|
a.dropdown-item {
|
|
|
|
padding-right: 1rem;
|
2022-02-18 12:24:23 +00:00
|
|
|
}
|
|
|
|
|
2022-02-21 15:48:59 +00:00
|
|
|
|
|
|
|
|
2022-02-18 12:24:23 +00:00
|
|
|
.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);
|
2022-02-21 15:48:59 +00:00
|
|
|
}
|
2022-02-18 12:24:23 +00:00
|
|
|
|
2022-02-21 15:48:59 +00:00
|
|
|
.limit {
|
|
|
|
padding: 0 .5rem;
|
|
|
|
font-weight: bold;
|
2022-02-18 12:24:23 +00:00
|
|
|
|
2022-02-21 15:48:59 +00:00
|
|
|
&.is-max {
|
|
|
|
color: var(--danger);
|
2022-02-18 12:24:23 +00:00
|
|
|
}
|
2022-02-21 15:48:59 +00:00
|
|
|
}
|
2022-02-18 12:24:23 +00:00
|
|
|
|
2022-02-21 15:48:59 +00:00
|
|
|
.title.input {
|
|
|
|
height: auto;
|
|
|
|
padding: .4rem .5rem;
|
|
|
|
display: inline-block;
|
|
|
|
cursor: pointer;
|
2022-02-18 12:24:23 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
: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>
|