feat: create KanbanBucket from ListKanban

This commit is contained in:
Dominik Pschenitschni 2022-02-18 13:24:23 +01:00
parent ef3f19d046
commit bef0142db5
Signed by: dpschen
GPG Key ID: B257AC0149F43A77
4 changed files with 763 additions and 754 deletions

View File

@ -0,0 +1,439 @@
<script setup lang="ts">
import {ref, computed, nextTick} from 'vue'
import {useStore} from 'vuex'
import {useI18n} from 'vue-i18n'
import Draggable from 'vuedraggable'
import {success} from '@/message'
import BucketModel from '@/models/bucket'
import TaskModel from '@/models/task'
import {getCollapsedBucketState, saveCollapsedBucketState} from '@/helpers/saveCollapsedBucketState'
import Dropdown from '@/components/misc/dropdown.vue'
import KanbanCard from '@/components/tasks/partials/KanbanCard.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
}>()
const emit = defineEmits(['openDeleteBucketModal', 'dragstart', 'updateTaskPosition'])
const {t} = useI18n()
const store = useStore()
const loading = computed(() =>
store.state.loading && store.state.loadingModule === 'kanban',
)
const isCollapsed = ref(false)
function collapseBucket() {
isCollapsed.value = true
// TODO:
// saveCollapsedBucketState(this.listId, this.collapsedBuckets)
}
function unCollapseBucket() {
if (!isCollapsed.value) {
return
}
isCollapsed.value = false
// TODO:
// saveCollapsedBucketState(this.listId, this.collapsedBuckets)
}
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
}
const hasNewTaskInput = ref(false)
function toggleShowNewTaskInput() {
hasNewTaskInput.value = !hasNewTaskInput.value
}
const newTaskText = ref('')
const newTaskError = ref(false)
async function addTaskToBucket() {
if (newTaskText.value === '') {
newTaskError.value = true
return
}
newTaskError.value = false
const task = await store.dispatch('tasks/createNewTask', {
title: newTaskText.value,
bucketId: props.bucket.id,
listId: props.bucket.listId,
})
newTaskText.value = ''
store.commit('kanban/addTaskToBucket', task)
scrollTaskContainerToBottom()
}
</script>
<template>
<div
class="bucket"
:class="{'is-collapsed': isCollapsed}"
>
<div class="bucket-header" @click="unCollapseBucket()">
<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="collapseBucket()"
>
{{ $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('updateTaskPosition', $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 #footer>
<div class="bucket-footer" v-if="canWrite">
<div class="field" v-if="hasNewTaskInput">
<div class="control" :class="{'is-loading': loading}">
<input
class="input"
:disabled="loading || undefined"
@focusout="toggleShowNewTaskInput()"
@keyup.esc="toggleShowNewTaskInput()"
@keyup.enter="addTaskToBucket()"
:placeholder="$t('list.kanban.addTaskPlaceholder')"
type="text"
v-focus.always
v-model="newTaskText"
/>
</div>
<p class="help is-danger" v-if="newTaskError && newTaskText === ''">
{{ $t('list.create.addTitleRequired') }}
</p>
</div>
<x-button
@click="toggleShowNewTaskInput()"
class="is-fullwidth has-text-centered"
:shadow="false"
v-else
icon="plus"
variant="secondary"
>
{{ bucket.tasks.length === 0 ? $t('list.kanban.addTask') : $t('list.kanban.addAnotherTask') }}
</x-button>
</div>
</template>
<template #item="{element: task}">
<div class="task-item">
<kanban-card class="kanban-card" :task="task"/>
</div>
</template>
</Draggable>
</div>
</template>
<style lang="scss" scoped>
.bucket {
--bucket-header-height: 60px;
border-radius: $radius;
position: relative;
display: flex;
flex-direction: column;
.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;
}
&.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;
}
}
}
.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;
padding: .5rem;
background-color: var(--grey-100);
border-bottom-left-radius: $radius;
border-bottom-right-radius: $radius;
.button {
background-color: transparent;
&:hover {
background-color: var(--white);
}
}
}
.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>

View File

@ -7,13 +7,13 @@ import type {ITask} from '@/modelTypes/ITask'
import type {IUser} from '@/modelTypes/IUser'
export default class BucketModel extends AbstractModel<IBucket> implements IBucket {
id = 0
title = ''
listId = ''
limit = 0
id: number = 0
title: string = ''
listId: number = ''
limit: number = 0
tasks: ITask[] = []
isDoneBucket = false
position = 0
isDoneBucket: boolean = false
position: boolean = 0
createdBy: IUser = null
created: Date = null
@ -24,7 +24,6 @@ export default class BucketModel extends AbstractModel<IBucket> implements IBuck
this.assignData(data)
this.tasks = this.tasks.map(t => new TaskModel(t))
this.createdBy = new UserModel(this.createdBy)
this.created = new Date(this.created)
this.updated = new Date(this.updated)

View File

@ -1,7 +1,7 @@
<template>
<ListWrapper class="list-kanban" :list-id="listId" viewName="kanban">
<template #header>
<div class="filter-container" v-if="!isSavedFilter(listId)">
<div class="filter-container" v-if="isSavedFilter">
<div class="items">
<filter-popup
v-model="params"
@ -18,182 +18,53 @@
class="kanban kanban-bucket-container loader-container"
>
<draggable
v-bind="dragOptions"
v-bind="DRAG_OPTIONS"
:modelValue="buckets"
@update:modelValue="updateBuckets"
@end="updateBucketPosition"
@start="() => dragBucket = true"
@start="() => isDraggingBucket = true"
group="buckets"
:disabled="!canWrite"
tag="ul"
:item-key="({id}) => `bucket${id}`"
tag="transition-group"
:item-key="(id: number) => `bucket${id}`"
:component-data="bucketDraggableComponentData"
>
<template #item="{element: bucket, index: bucketIndex }">
<div
<KanbanBucket
class="bucket"
:class="{'is-collapsed': collapsedBuckets[bucket.id]}"
>
<div class="bucket-header" @click="() => unCollapseBucket(bucket)">
<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.blur()"
@keydown.esc.prevent.stop="$event.target.blur()"
@blur="saveBucketTitle(bucket.id, $event.target.textContent)"
@click="focusBucketTitle"
class="title input"
:contenteditable="(bucketTitleEditable && canWrite && !collapsedBuckets[bucket.id]) ? true : 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 && !collapsedBuckets[bucket.id]"
trigger-icon="ellipsis-v"
@close="() => showSetLimitInput = false"
>
<dropdown-item
@click.stop="showSetLimitInput = true"
>
<div class="field has-addons" v-if="showSetLimitInput">
<div class="control">
<input
@keyup.esc="() => showSetLimitInput = false"
@keyup.enter="() => showSetLimitInput = false"
:value="bucket.limit"
@input="(event) => setBucketLimit(bucket.id, parseInt(event.target.value))"
class="input"
type="number"
min="0"
v-focus.always
: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"
/>
</div>
<div class="control">
<x-button
:disabled="bucket.limit < 0"
:icon="['far', 'save']"
:shadow="false"
v-cy="'setBucketLimit'"
/>
</div>
</div>
<template v-else>
{{
$t('list.kanban.limit', {limit: bucket.limit > 0 ? bucket.limit : $t('list.kanban.noLimit')})
}}
</template>
</dropdown-item>
<dropdown-item
@click.stop="toggleDoneBucket(bucket)"
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') }}
</dropdown-item>
<dropdown-item
@click.stop="() => collapseBucket(bucket)"
>
{{ $t('list.kanban.collapse') }}
</dropdown-item>
<dropdown-item
:class="{'is-disabled': buckets.length <= 1}"
@click.stop="() => deleteBucketModal(bucket.id)"
class="has-text-danger"
v-tooltip="buckets.length <= 1 ? $t('list.kanban.deleteLast') : ''"
>
<span class="icon is-small">
<icon icon="trash-alt"/>
</span>
{{ $t('misc.delete') }}
</dropdown-item>
</dropdown>
</div>
<draggable
v-bind="dragOptions"
:modelValue="bucket.tasks"
@update:modelValue="(tasks) => updateTasks(bucket.id, tasks)"
@start="() => dragstart(bucket)"
@end="updateTaskPosition"
:group="{name: 'tasks', put: shouldAcceptDrop(bucket) && !dragBucket}"
:disabled="!canWrite"
:data-bucket-index="bucketIndex"
tag="ul"
:item-key="(task) => `bucket${bucket.id}-task${task.id}`"
:component-data="getTaskDraggableTaskComponentData(bucket)"
>
<template #footer>
<div class="bucket-footer" v-if="canWrite">
<div class="field" v-if="showNewTaskInput[bucket.id]">
<div class="control" :class="{'is-loading': loading || taskLoading}">
<input
class="input"
:disabled="loading || taskLoading || undefined"
@focusout="toggleShowNewTaskInput(bucket.id)"
@keyup.enter="addTaskToBucket(bucket.id)"
@keyup.esc="toggleShowNewTaskInput(bucket.id)"
:placeholder="$t('list.kanban.addTaskPlaceholder')"
type="text"
v-focus.always
v-model="newTaskText"
/>
</div>
<p class="help is-danger" v-if="newTaskError[bucket.id] && newTaskText === ''">
{{ $t('list.create.addTitleRequired') }}
</p>
</div>
<x-button
@click="toggleShowNewTaskInput(bucket.id)"
class="is-fullwidth has-text-centered"
:shadow="false"
v-else
icon="plus"
variant="secondary"
>
{{ bucket.tasks.length === 0 ? $t('list.kanban.addTask') : $t('list.kanban.addAnotherTask') }}
</x-button>
</div>
</template>
<template #item="{element: task}">
<div class="task-item">
<kanban-card class="kanban-card" :task="task" :loading="taskUpdating[task.id] ?? false"/>
</div>
</template>
</draggable>
</div>
</template>
</draggable>
<div class="bucket new-bucket" v-if="canWrite && !loading && buckets.length > 0">
<input
v-if="hasNewBucketInput"
:class="{'is-loading': loading}"
:disabled="loading || undefined"
@blur="() => showNewBucketInput = false"
:disabled="loading || null"
@blur="hasNewBucketInput = false"
@keyup.enter="createNewBucket"
@keyup.esc="$event.target.blur()"
@keyup.esc="($event.target as HTMLInputElement).blur()"
class="input"
:placeholder="$t('list.kanban.addBucketPlaceholder')"
type="text"
v-focus.always
v-if="showNewBucketInput"
v-model="newBucketTitle"
/>
<x-button
v-else
@click="() => showNewBucketInput = true"
@click="hasNewBucketInput = true"
:shadow="false"
class="is-transparent is-fullwidth has-text-centered"
variant="secondary"
@ -206,8 +77,8 @@
<transition name="modal">
<modal
v-if="showBucketDeleteModal"
@close="showBucketDeleteModal = false"
v-if="hasBucketDeleteModal"
@close="hasBucketDeleteModal = false"
@submit="deleteBucket()"
>
<template #header><span>{{ $t('list.kanban.deleteHeaderBucket') }}</span></template>
@ -223,26 +94,23 @@
</ListWrapper>
</template>
<script lang="ts">
import {defineComponent} from 'vue'
<script setup lang="ts">
import {ref, computed, watch} from 'vue'
import draggable from 'zhyswan-vuedraggable'
import cloneDeep from 'lodash.clonedeep'
import {mapState} from 'pinia'
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 BucketModel from '../../models/bucket'
import {RIGHTS as Rights} from '@/constants/rights'
import ListWrapper from './ListWrapper.vue'
import FilterPopup from '@/components/list/partials/filter-popup.vue'
import Dropdown from '@/components/misc/dropdown.vue'
import {getCollapsedBucketState, saveCollapsedBucketState} from '@/helpers/saveCollapsedBucketState'
import {calculateItemPosition} from '../../helpers/calculateItemPosition'
import KanbanCard from '@/components/tasks/partials/kanban-card.vue'
import DropdownItem from '@/components/misc/dropdown-item.vue'
import {isSavedFilter} from '@/helpers/savedFilter'
import {useBaseStore} from '@/stores/base'
import {useTaskStore} from '@/stores/tasks'
import {useKanbanStore} from '@/stores/kanban'
import KanbanBucket from '@/components/tasks/partials/KanbanBucket.vue'
const DRAG_OPTIONS = {
// sortable options
@ -253,165 +121,60 @@ const DRAG_OPTIONS = {
delay: 150,
}
const MIN_SCROLL_HEIGHT_PERCENT = 0.25
const props = defineProps<{
listId: number
}>()
export default defineComponent({
name: 'Kanban',
components: {
DropdownItem,
ListWrapper,
KanbanCard,
Dropdown,
FilterPopup,
draggable,
},
const {t} = useI18n()
props: {
listId: {
type: Number,
required: true,
},
},
const collapsedBuckets = ref({})
data() {
return {
taskContainerRefs: {},
dragOptions: DRAG_OPTIONS,
drag: false,
dragBucket: false,
sourceBucket: 0,
showBucketDeleteModal: false,
bucketToDelete: 0,
bucketTitleEditable: false,
newTaskText: '',
showNewTaskInput: {},
newBucketTitle: '',
showNewBucketInput: false,
newTaskError: {},
showSetLimitInput: false,
collapsedBuckets: {},
// We're using this to show the loading animation only at the task when updating it
taskUpdating: {},
oneTaskUpdating: false,
params: {
const params = ref({
filter_by: [],
filter_value: [],
filter_comparator: [],
filter_concat: 'and',
},
}
},
})
watch: {
loadBucketParameter: {
handler: 'loadBuckets',
immediate: true,
},
},
computed: {
getTaskDraggableTaskComponentData() {
return (bucket) => ({
ref: (el) => this.setTaskContainerRef(bucket.id, el),
onScroll: (event) => this.handleTaskContainerScroll(bucket.id, bucket.listId, event.target),
type: 'transition-group',
name: !this.drag ? 'move-card' : null,
class: [
'tasks',
{'dragging-disabled': !this.canWrite},
],
})
},
interface LoadBucketsParams {
listId: number,
params: Object
}
loadBucketParameter() {
return {
listId: this.listId,
params: this.params,
}
},
bucketDraggableComponentData() {
return {
type: 'transition-group',
name: !this.dragBucket ? 'move-bucket' : null,
class: [
'kanban-bucket-container',
{'dragging-disabled': !this.canWrite},
],
}
},
...mapState(useBaseStore, {
canWrite: state => state.currentList.maxRight > Rights.READ,
list: state => state.currentList,
}),
...mapState(useKanbanStore, {
buckets: state => state.buckets,
loadedListId: state => state.listId,
loading: state => state.isLoading,
}),
...mapState(useTaskStore, {
taskLoading: state => state.isLoading,
}),
},
watch(() => ({
listId: props.listId,
params: params.value,
} as LoadBucketsParams), loadBuckets, {immediate: true})
methods: {
isSavedFilter,
function loadBuckets({listId, params} : LoadBucketsParams) {
store.dispatch('kanban/loadBucketsForList', {listId, params})
collapsedBuckets.value = getCollapsedBucketState(listId)
}
loadBuckets() {
const {listId, params} = this.loadBucketParameter
const isSavedFilter = computed(() => list.value.isSavedFilter && !list.value.isSavedFilter())
this.collapsedBuckets = getCollapsedBucketState(listId)
const buckets = computed(() => store.state.kanban.buckets)
console.debug(`Loading buckets, loadedListId = ${this.loadedListId}, $attrs = ${this.$attrs} $route.params =`, this.$route.params)
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)
useKanbanStore().loadBucketsForList({listId, params})
},
// 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)
setTaskContainerRef(id, el) {
if (!el) return
this.taskContainerRefs[id] = el
},
const isDraggingTask = ref(false)
handleTaskContainerScroll(id, listId, el) {
if (!el) {
return
}
const scrollTopMax = el.scrollHeight - el.clientHeight
const threshold = el.scrollTop + el.scrollTop * MIN_SCROLL_HEIGHT_PERCENT
if (scrollTopMax > threshold) {
return
}
useKanbanStore().loadNextTasksForBucket({
listId: listId,
params: this.params,
bucketId: id,
})
},
updateTasks(bucketId, tasks) {
const kanbanStore = useKanbanStore()
const newBucket = {
...kanbanStore.getBucketById(bucketId),
tasks,
}
kanbanStore.setBucketById(newBucket)
},
async updateTaskPosition(e) {
this.drag = 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 = this.buckets[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
@ -426,244 +189,171 @@ export default defineComponent({
? e.newIndex - 1
: e.newIndex
const task = newBucket.tasks[newTaskIndex]
const taskBefore = newBucket.tasks[newTaskIndex - 1] ?? null
const taskAfter = newBucket.tasks[newTaskIndex + 1] ?? null
this.taskUpdating[task.id] = true
const newTask = cloneDeep(task) // cloning the task to avoid pinia store manipulation
newTask.bucketId = newBucket.id
newTask.kanbanPosition = calculateItemPosition(
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 {
const taskStore = useTaskStore()
await taskStore.update(newTask)
// Make sure the first and second task don't both get position 0 assigned
if(newTaskIndex === 0 && taskAfter !== null && taskAfter.kanbanPosition === 0) {
const taskAfterAfter = newBucket.tasks[newTaskIndex + 2] ?? null
const newTaskAfter = cloneDeep(taskAfter) // cloning the task to avoid pinia store manipulation
newTaskAfter.bucketId = newBucket.id
newTaskAfter.kanbanPosition = calculateItemPosition(
0,
taskAfterAfter !== null ? taskAfterAfter.kanbanPosition : null,
)
await taskStore.update(newTaskAfter)
}
await store.dispatch('tasks/update', task)
} finally {
this.taskUpdating[task.id] = false
this.oneTaskUpdating = false
taskUpdating.value[task.id] = false
oneTaskUpdating.value = false
}
},
}
toggleShowNewTaskInput(bucketId) {
this.showNewTaskInput[bucketId] = !this.showNewTaskInput[bucketId]
},
const hasNewBucketInput = ref(false)
async addTaskToBucket(bucketId) {
if (this.newTaskText === '') {
this.newTaskError[bucketId] = true
return
}
this.newTaskError[bucketId] = false
const task = await useTaskStore().createNewTask({
title: this.newTaskText,
bucketId,
listId: this.listId,
})
this.newTaskText = ''
useKanbanStore().addTaskToBucket(task)
this.scrollTaskContainerToBottom(bucketId)
},
scrollTaskContainerToBottom(bucketId) {
const bucketEl = this.taskContainerRefs[bucketId]
if (!bucketEl) {
return
}
bucketEl.scrollTop = bucketEl.scrollHeight
},
async createNewBucket() {
if (this.newBucketTitle === '') {
const newBucketTitle = ref('')
async function createNewBucket() {
if (newBucketTitle.value === '') {
return
}
const newBucket = new BucketModel({
title: this.newBucketTitle,
listId: this.listId,
})
await store.dispatch('kanban/createBucket', new BucketModel({
title: newBucketTitle.value,
listId: props.listId,
}))
newBucketTitle.value = ''
hasNewBucketInput.value = false
}
await useKanbanStore().createBucket(newBucket)
this.newBucketTitle = ''
this.showNewBucketInput = false
},
const bucketToDeleteId = ref<BucketModel['id']>(0)
const hasBucketDeleteModal = ref(false)
deleteBucketModal(bucketId) {
if (this.buckets.length <= 1) {
function openDeleteBucketModal(bucketId: BucketModel['id']) {
if (buckets.value.length <= 1) {
return
}
this.bucketToDelete = bucketId
this.showBucketDeleteModal = true
},
async deleteBucket() {
const bucket = new BucketModel({
id: this.bucketToDelete,
listId: this.listId,
})
bucketToDeleteId.value = bucketId
hasBucketDeleteModal.value = true
}
async function deleteBucket() {
try {
await useKanbanStore().deleteBucket({
bucket,
params: this.params,
await store.dispatch('kanban/deleteBucket', {
bucket: new BucketModel({
id: bucketToDeleteId.value,
listId: props.listId,
}),
params: params.value,
})
this.$message.success({message: this.$t('list.kanban.deleteBucketSuccess')})
success({message: t('list.kanban.deleteBucketSuccess')})
} finally {
this.showBucketDeleteModal = false
hasBucketDeleteModal.value = false
}
},
}
focusBucketTitle(e) {
// This little helper allows us to drag a bucket around at the title without focusing on it right away.
this.bucketTitleEditable = true
this.$nextTick(() => e.target.focus())
},
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},
],
}))
async saveBucketTitle(bucketId, bucketTitle) {
const updatedBucketData = {
id: bucketId,
title: bucketTitle,
}
await useKanbanStore().updateBucketTitle(updatedBucketData)
this.bucketTitleEditable = false
},
updateBuckets(value) {
function updateBuckets(value: BucketModel[]) {
// (1) buckets get updated in store and tasks positions get invalidated
useKanbanStore().setBuckets(value)
},
store.commit('kanban/setBuckets', value)
}
updateBucketPosition(e) {
function updateBucketPosition(e) {
// (2) bucket positon is changed
this.dragBucket = false
isDraggingBucket.value = false
const bucket = this.buckets[e.newIndex]
const bucketBefore = this.buckets[e.newIndex - 1] ?? null
const bucketAfter = this.buckets[e.newIndex + 1] ?? null
const bucket = buckets.value[e.newIndex]
const bucketBefore = buckets.value[e.newIndex - 1] ?? null
const bucketAfter = buckets.value[e.newIndex + 1] ?? null
const updatedData = {
store.dispatch('kanban/updateBucket', {
id: bucket.id,
position: calculateItemPosition(
bucketBefore !== null ? bucketBefore.position : null,
bucketAfter !== null ? bucketAfter.position : null,
),
}
})
}
useKanbanStore().updateBucket(updatedData)
},
const sourceBucketId = ref(0)
async setBucketLimit(bucketId, limit) {
if (limit < 0) {
return
}
const kanbanStore = useKanbanStore()
const newBucket = {
...kanbanStore.getBucketById(bucketId),
limit,
}
await kanbanStore.updateBucket(newBucket)
this.$message.success({message: this.$t('list.kanban.bucketLimitSavedSuccess')})
},
shouldAcceptDrop(bucket) {
return (
function shouldAcceptDrop(bucket: BucketModel) {
return !isDraggingBucket.value && (
// When dragging from a bucket who has its limit reached, dragging should still be possible
bucket.id === this.sourceBucket ||
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
)
},
}
dragstart(bucket) {
this.drag = true
this.sourceBucket = bucket.id
},
async toggleDoneBucket(bucket) {
const newBucket = {
...bucket,
isDoneBucket: !bucket.isDoneBucket,
}
await useKanbanStore().updateBucket(newBucket)
this.$message.success({message: this.$t('list.kanban.doneBucketSavedSuccess')})
},
collapseBucket(bucket) {
this.collapsedBuckets[bucket.id] = true
saveCollapsedBucketState(this.listId, this.collapsedBuckets)
},
unCollapseBucket(bucket) {
if (!this.collapsedBuckets[bucket.id]) {
return
}
this.collapsedBuckets[bucket.id] = false
saveCollapsedBucketState(this.listId, this.collapsedBuckets)
},
},
})
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);
$bucket-width: 300px;
$bucket-header-height: 60px;
$bucket-right-margin: 1rem;
$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}';
// FIXME:
.app-content.list\.kanban, .app-content.task\.detail {
padding-bottom: 0 !important;
}
.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});
scroll-snap-type: x mandatory;
}
}
&-bucket-container {
.kanban-bucket-container {
display: flex;
}
}
.ghost {
.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;
@ -675,57 +365,14 @@ $filter-container-height: '1rem - #{$switch-view-height}';
border: 3px dashed var(--grey-300);
border-radius: $radius;
}
}
}
.bucket {
border-radius: $radius;
position: relative;
margin: 0 $bucket-right-margin 0 0;
max-height: calc(100% - 1rem); // 1rem spacing to the bottom
min-height: 20px;
width: $bucket-width;
display: flex;
flex-direction: column;
overflow: hidden; // Make sure the edges are always rounded
@media screen and (max-width: $tablet) {
scroll-snap-align: center;
}
.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;
}
&.new-bucket {
.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(#{$bucket-width} + 1rem);
min-width: calc(var(--bucket-width) + 1rem);
background: transparent;
padding-right: 1rem;
@ -733,71 +380,6 @@ $filter-container-height: '1rem - #{$switch-view-height}';
background: var(--grey-100);
width: 100%;
}
}
&.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((#{$bucket-width} - #{$bucket-header-height} - #{$bucket-right-margin}) * -1);
cursor: pointer;
.tasks, .bucket-footer {
display: none;
}
}
}
.bucket-header {
background-color: var(--grey-100);
height: min-content;
display: flex;
align-items: center;
justify-content: space-between;
padding: .5rem;
height: $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;
padding: .5rem;
background-color: var(--grey-100);
border-bottom-left-radius: $radius;
border-bottom-right-radius: $radius;
transform: none;
.button {
background-color: transparent;
&:hover {
background-color: var(--white);
}
}
}
}
// FIXME: This does not seem to work
@ -806,16 +388,5 @@ $filter-container-height: '1rem - #{$switch-view-height}';
transition: transform 0.18s ease;
}
.move-card-move {
transform: rotateZ(3deg);
transition: transform $transition-duration;
}
.move-card-leave-from,
.move-card-leave-to,
.move-card-leave-active {
display: none;
}
@include modal-transition();
</style>