WIP: feat: create KanbanBucket from ListKanban #1571

Closed
dpschen wants to merge 3 commits from dpschen/frontend:feature/feat-kanban-bucket-component into main
4 changed files with 763 additions and 754 deletions
Showing only changes of commit bef0142db5 - Show all commits

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
dpschen marked this conversation as resolved Outdated

number or Number? (What is the difference between them?)

`number` or `Number`? (What is the difference between them?)
`number` is the Typescript **type** of number, while `Number` is a [primitive object wrapper](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number). See: https://www.typescriptlang.org/docs/handbook/declaration-files/do-s-and-don-ts.html#number-string-boolean-symbol-and-object
isCollapsed: boolean
dpschen marked this conversation as resolved Outdated

Does this need to be a prop? Since we now have everything bucket-related in a single component, shouldn't it be enough to use a ref?

Does this need to be a prop? Since we now have everything bucket-related in a single component, shouldn't it be enough to use a ref?

Yes and no. Since we currently save the collapsed state of the whole list, when a bucket collapses we need to change that in order to make this bucket specific. I'm still working on this, this is one of the reasosn this is still WIP.

Yes and no. Since we currently save the collapsed state of the whole list, when a bucket collapses we need to change that in order to make this bucket specific. I'm still working on this, this is one of the reasosn this is still WIP.

If we want to let's refactor this in a future pull request.
Want to keep this pull request as simple as possible.
Main target was to separate KanbanBucket from ListKanban

If we want to let's refactor this in a future pull request. Want to keep this pull request as simple as possible. Main target was to separate KanbanBucket from ListKanban
canWrite: boolean
bucket: BucketModel
isOnlyBucketLeft: boolean
dragOptions: Object
params: Object
shouldAcceptDrop: boolean
isDraggingTask: boolean
dpschen marked this conversation as resolved Outdated

Does this need to be a prop?

Does this need to be a prop?

Will check

Will check

Maybe. But let's go the same way as with #1571 (comment)

Maybe. But let's go the same way as with https://kolaente.dev/vikunja/frontend/pulls/1571#issuecomment-30384
}>()
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>
dpschen marked this conversation as resolved Outdated

Could you add the <template> before the <script>? Just to keep it consistent.

Could you add the `<template>` before the `<script>`? Just to keep it consistent.

I was following here the new recommended order. See https://eslint.vuejs.org/rules/component-tags-order.html

When we enable the new settings this will change everywhere.

I was following here the new recommended order. See https://eslint.vuejs.org/rules/component-tags-order.html When we enable the new settings this will change everywhere.

Doesn't the doc you linked say switching <template> and <script> blocks as long as the <style> tag comes last is allowed?

Doesn't the doc you linked say switching `<template>` and `<script>` blocks as long as the `<style>` tag comes last is allowed?

Fromt reading it seems like it, strange!
Because when I ran #930 this reordering was applied automatically.

I will change it back for this pull request. If #930 changes it we'll handle it there.

Fromt reading it seems like it, strange! Because when I ran https://kolaente.dev/vikunja/frontend/pulls/930 this reordering was applied automatically. I will change it back for this pull request. If https://kolaente.dev/vikunja/frontend/pulls/930 changes it we'll handle it there.
<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},
],
})
},
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,
}),
},
methods: {
isSavedFilter,
loadBuckets() {
const {listId, params} = this.loadBucketParameter
this.collapsedBuckets = getCollapsedBucketState(listId)
console.debug(`Loading buckets, loadedListId = ${this.loadedListId}, $attrs = ${this.$attrs} $route.params =`, this.$route.params)
useKanbanStore().loadBucketsForList({listId, params})
},
setTaskContainerRef(id, el) {
if (!el) return
this.taskContainerRefs[id] = el
},
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
interface LoadBucketsParams {
listId: number,
params: Object
}
useKanbanStore().loadNextTasksForBucket({
listId: listId,
params: this.params,
bucketId: id,
})
},
watch(() => ({
listId: props.listId,
params: params.value,
} as LoadBucketsParams), loadBuckets, {immediate: true})
updateTasks(bucketId, tasks) {
const kanbanStore = useKanbanStore()
const newBucket = {
...kanbanStore.getBucketById(bucketId),
tasks,
function loadBuckets({listId, params} : LoadBucketsParams) {
store.dispatch('kanban/loadBucketsForList', {listId, params})
collapsedBuckets.value = getCollapsedBucketState(listId)
}
kanbanStore.setBucketById(newBucket)
},
const isSavedFilter = computed(() => list.value.isSavedFilter && !list.value.isSavedFilter())
async updateTaskPosition(e) {
this.drag = false
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 = 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]
},
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)
},
const hasNewBucketInput = ref(false)
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())
},
async saveBucketTitle(bucketId, bucketTitle) {
const updatedBucketData = {
id: bucketId,
title: bucketTitle,
}
await useKanbanStore().updateBucketTitle(updatedBucketData)
this.bucketTitleEditable = 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},
],
}))
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);
dpschen marked this conversation as resolved Outdated

Please merge the two <style> tags since they are the same

Please merge the two `<style>` tags since they are the same
$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;
}
.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;
@ -677,55 +367,12 @@ $filter-container-height: '1rem - #{$switch-view-height}';
}
}
.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.
dpschen marked this conversation as resolved Outdated

Now that I read that comment again, I feel like this might have something to do with margin collapse...

Now that I read that comment again, I feel like this might have something to do with margin collapse...

I will recheck the styles =)

I will recheck the styles =)

Let's do this in an additional pull request. I want to refactor all these components either way. For now I just wanted to separate the logic. Makes future steps simpler.

Let's do this in an additional pull request. I want to refactor all these components either way. For now I just wanted to separate the logic. Makes future steps simpler.
// 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;
@ -735,87 +382,11 @@ $filter-container-height: '1rem - #{$switch-view-height}';
}
}
&.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
.task-dragging {
transform: rotateZ(3deg);
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>