WIP: feat: create KanbanBucket from ListKanban #1571

Closed
dpschen wants to merge 3 commits from dpschen/frontend:feature/feat-kanban-bucket-component into main
7 changed files with 438 additions and 359 deletions
Showing only changes of commit 193212ad05 - Show all commits

View File

@ -3,15 +3,15 @@ import {ref, computed, nextTick} from 'vue'
import {useStore} from 'vuex' import {useStore} from 'vuex'
import {useI18n} from 'vue-i18n' import {useI18n} from 'vue-i18n'
import Draggable from 'vuedraggable' import Draggable from 'vuedraggable'
import { useVModels } from '@vueuse/core'
import {success} from '@/message' import {success} from '@/message'
import BucketModel from '@/models/bucket' import BucketModel from '@/models/bucket'
import TaskModel from '@/models/task' import TaskModel from '@/models/task'
import {getCollapsedBucketState, saveCollapsedBucketState} from '@/helpers/saveCollapsedBucketState'
import Dropdown from '@/components/misc/dropdown.vue' import Dropdown from '@/components/misc/dropdown.vue'
import KanbanCard from '@/components/tasks/partials/KanbanCard.vue' import KanbanCard from '@/feature/kanban/KanbanCard.vue'
import KanbanTaskNew from '@/feature/kanban/KanbanTaskNew.vue'
const MIN_SCROLL_HEIGHT_PERCENT = 0.25 const MIN_SCROLL_HEIGHT_PERCENT = 0.25
@ -25,33 +25,19 @@ const props = defineProps<{
params: Object params: Object
shouldAcceptDrop: boolean shouldAcceptDrop: boolean
isDraggingTask: boolean isDraggingTask: boolean
tasksUpdating: { [taskId: TaskModel['id']]: boolean },
}>() }>()
const emit = defineEmits(['openDeleteBucketModal', 'dragstart', 'updateTaskPosition']) const emit = defineEmits([
'openDeleteBucketModal',
'dragstart',
'dragend',
'update:isCollapsed',
])
const {t} = useI18n() const {t} = useI18n()
const store = useStore() const store = useStore()
const loading = computed(() => const { isCollapsed } = useVModels(props, emit)
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) const bucketTitleEditable = ref(true)
async function saveBucketTitle(bucketTitle: string) { async function saveBucketTitle(bucketTitle: string) {
@ -138,30 +124,7 @@ function scrollTaskContainerToBottom() {
taskContainer.value.scrollTop = taskContainer.value.scrollHeight 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> </script>
<template> <template>
@ -169,7 +132,7 @@ async function addTaskToBucket() {
class="bucket" class="bucket"
:class="{'is-collapsed': isCollapsed}" :class="{'is-collapsed': isCollapsed}"
> >
<div class="bucket-header" @click="unCollapseBucket()"> <div class="bucket-header" @click="isCollapsed = false">
<span <span
v-if="bucket.isDoneBucket" v-if="bucket.isDoneBucket"
class="icon is-small has-text-success mr-2" class="icon is-small has-text-success mr-2"
@ -249,7 +212,7 @@ async function addTaskToBucket() {
</a> </a>
<a <a
class="dropdown-item" class="dropdown-item"
@click.stop="collapseBucket()" @click.stop="isCollapsed = true"
> >
{{ $t('list.kanban.collapse') }} {{ $t('list.kanban.collapse') }}
</a> </a>
@ -272,7 +235,7 @@ async function addTaskToBucket() {
:modelValue="bucket.tasks" :modelValue="bucket.tasks"
@update:modelValue="updateTasks" @update:modelValue="updateTasks"
@start="emit('dragstart', bucket.id)" @start="emit('dragstart', bucket.id)"
@end="emit('updateTaskPosition', $event)" @end="emit('dragend', $event)"
:group="{name: 'tasks', put: shouldAcceptDrop}" :group="{name: 'tasks', put: shouldAcceptDrop}"
:disabled="!canWrite" :disabled="!canWrite"
:data-bucket-index="bucketIndex" :data-bucket-index="bucketIndex"
@ -280,43 +243,24 @@ async function addTaskToBucket() {
:item-key="(task: TaskModel) => `bucket${bucket.id}-task${task.id}`" :item-key="(task: TaskModel) => `bucket${bucket.id}-task${task.id}`"
:component-data="taskDraggableTaskComponentData" :component-data="taskDraggableTaskComponentData"
> >
<template #footer> <template #item="{element: task}">
<div class="bucket-footer" v-if="canWrite"> <div class="task-item">
<div class="field" v-if="hasNewTaskInput"> <kanban-card
<div class="control" :class="{'is-loading': loading}"> class="kanban-card"
<input :task="task"
class="input" :loading="tasksUpdating[task.id]"
: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> </div>
</template> </template>
<template #item="{element: task}"> <template #footer>
<div class="task-item"> <KanbanTaskNew
<kanban-card class="kanban-card" :task="task"/> v-if="canWrite"
</div> :bucketId="bucket.id"
:listId="bucket.listId"
:bucketIsEmpty="bucket.tasks.length === 0"
@scrollTaskContainerToBottom="scrollTaskContainerToBottom"
/>
</template> </template>
</Draggable> </Draggable>
</div> </div>
@ -331,52 +275,54 @@ async function addTaskToBucket() {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
}
.tasks { .is-collapsed {
overflow: hidden auto; align-self: flex-start;
height: 100%; 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;
.task-item { .tasks, .bucket-footer {
background-color: var(--grey-100); display: none;
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;
}
} }
} }
.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 { .bucket-header {
background-color: var(--grey-100); background-color: var(--grey-100);
height: min-content; height: min-content;
@ -385,22 +331,22 @@ async function addTaskToBucket() {
justify-content: space-between; justify-content: space-between;
padding: .5rem; padding: .5rem;
height: var(--bucket-header-height); height: var(--bucket-header-height);
}
.limit { .limit {
padding: 0 .5rem; padding: 0 .5rem;
font-weight: bold; font-weight: bold;
&.is-max { &.is-max {
color: var(--danger); color: var(--danger);
}
} }
}
.title.input { .title.input {
height: auto; height: auto;
padding: .4rem .5rem; padding: .4rem .5rem;
display: inline-block; display: inline-block;
cursor: pointer; cursor: pointer;
}
} }
:deep(.dropdown-trigger) { :deep(.dropdown-trigger) {
@ -412,18 +358,6 @@ async function addTaskToBucket() {
position: sticky; position: sticky;
bottom: 0; bottom: 0;
height: min-content; 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 { .move-card-move {

View File

@ -0,0 +1,73 @@
<template>
<div class="new-bucket">
<input
v-if="hasNewBucketInput"
:class="{'is-loading': loading}"
:disabled="loading || undefined"
@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>
</template>
<script setup lang="ts">
import {ref, computed} from 'vue'
import BucketModel from '@/models/bucket'
import {useStore} from 'vuex'
const props = defineProps<{
listId: number
}>()
const store = useStore()
const loading = computed(() => store.state.loading && store.state.loadingModule === 'kanban')
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
}
</script>
<style lang="scss" scoped>
.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%;
}
}
</style>

View File

@ -81,7 +81,7 @@ import PriorityLabel from '@/components/tasks/partials/priorityLabel.vue'
import User from '@/components/misc/user.vue' import User from '@/components/misc/user.vue'
import Done from '@/components/misc/Done.vue' import Done from '@/components/misc/Done.vue'
import Labels from '@/components/tasks/partials/labels.vue' import Labels from '@/components/tasks/partials/labels.vue'
import ChecklistSummary from './checklist-summary.vue' import ChecklistSummary from '@/components/tasks/partials/checklist-summary.vue'
import {TASK_DEFAULT_COLOR, getHexColor} from '@/models/task' import {TASK_DEFAULT_COLOR, getHexColor} from '@/models/task'
import type {ITask} from '@/modelTypes/ITask' import type {ITask} from '@/modelTypes/ITask'

View File

@ -0,0 +1,94 @@
<template>
<div class="bucket-footer">
<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"
>
{{ bucketIsEmpty ? $t('list.kanban.addTask') : $t('list.kanban.addAnotherTask') }}
</x-button>
</div>
</template>
<script setup lang="ts">
import {ref, computed} from 'vue'
import {useStore} from 'vuex'
import BucketModel from '@/models/bucket'
const props = defineProps<{
bucketId: BucketModel['id']
listId: BucketModel['listId']
bucketIsEmpty: boolean
}>()
const emit = defineEmits(['scrollTaskContainerToBottom'])
const loading = computed(() =>
store.state.loading && store.state.loadingModule === 'kanban',
)
const hasNewTaskInput = ref(false)
function toggleShowNewTaskInput() {
hasNewTaskInput.value = !hasNewTaskInput.value
}
const newTaskText = ref('')
const newTaskError = ref(false)
const store = useStore()
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.bucketId,
listId: props.listId,
})
newTaskText.value = ''
store.commit('kanban/addTaskToBucket', task)
emit('scrollTaskContainerToBottom')
}
</script>
<style lang="scss">
.bucket-footer {
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);
}
}
}
</style>

View File

@ -2,19 +2,17 @@ import type {IList} from '@/modelTypes/IList'
const key = 'collapsedBuckets' const key = 'collapsedBuckets'
const getAllState = () => { function getAllState() {
const saved = localStorage.getItem(key) const saved = localStorage.getItem(key)
if (saved === null) { return saved === null
return {} ? {}
} : JSON.parse(saved)
return JSON.parse(saved)
} }
export const saveCollapsedBucketState = ( export function saveCollapsedBucketState(
listId: IList['id'], listId: IList['id'],
collapsedBuckets, collapsedBuckets: any,
) => { ) {
const state = getAllState() const state = getAllState()
state[listId] = collapsedBuckets state[listId] = collapsedBuckets
for (const bucketId in state[listId]) { for (const bucketId in state[listId]) {
@ -25,11 +23,7 @@ export const saveCollapsedBucketState = (
localStorage.setItem(key, JSON.stringify(state)) localStorage.setItem(key, JSON.stringify(state))
} }
export const getCollapsedBucketState = (listId : IList['id']) => { export function getCollapsedBucketState(listId: IList['id']) {
const state = getAllState() const state = getAllState()
if (typeof state[listId] !== 'undefined') { return state[listId] ?? {}
return state[listId]
}
return {}
} }

View File

@ -133,6 +133,8 @@ export default class TaskModel extends AbstractModel<ITask> implements ITask {
this.updated = new Date(this.updated) this.updated = new Date(this.updated)
this.listId = Number(this.listId) this.listId = Number(this.listId)
this.kanbanPosition
} }
getTextIdentifier() { getTextIdentifier() {

View File

@ -1,95 +1,71 @@
<template> <template>
<ListWrapper class="list-kanban" :list-id="listId" viewName="kanban"> <ListWrapper class="list-kanban" :list-id="listId" viewName="kanban">
<template #header> <template #header>
<div class="filter-container" v-if="isSavedFilter"> <div class="filter-container" v-if="!isSavedFilter">
<div class="items"> <div class="items">
<filter-popup <filter-popup v-model="params" />
v-model="params"
@update:modelValue="loadBuckets"
/>
</div> </div>
</div> </div>
</template> </template>
<template #default> <template #default>
<div class="kanban-view"> <div
<div :class="{ 'is-loading': loading && !oneTaskUpdating}"
:class="{ 'is-loading': loading && !oneTaskUpdating}" class="kanban kanban-bucket-container loader-container"
class="kanban kanban-bucket-container loader-container" >
<draggable
v-bind="DRAG_OPTIONS"
:modelValue="buckets"
@update:modelValue="updateBuckets"
@start="() => isDraggingBucket = true"
@end="updateBucketPosition"
group="buckets"
:disabled="!canWrite"
tag="transition-group"
:item-key="(id: number) => `bucket${id}`"
:component-data="bucketDraggableComponentData"
> >
<draggable <template #item="{element: bucket, index: bucketIndex }">
v-bind="DRAG_OPTIONS" <Bucket
:modelValue="buckets" class="bucket"
@update:modelValue="updateBuckets" :bucket-index="bucketIndex"
@end="updateBucketPosition" :is-collapsed="collapsedBuckets[bucket.id]"
@start="() => isDraggingBucket = true" :can-write="canWrite"
group="buckets" :bucket="bucket"
:disabled="!canWrite" :isOnlyBucketLeft="buckets.length <= 1"
tag="transition-group" :drag-options="DRAG_OPTIONS"
:item-key="(id: number) => `bucket${id}`" :params="params"
:component-data="bucketDraggableComponentData" :should-accept-drop="shouldAcceptDrop(bucket)"
> :isDraggingTask="isDraggingTask"
<template #item="{element: bucket, index: bucketIndex }"> :taskUpdating="tasksUpdating"
<KanbanBucket @dragstart="dragstart"
class="bucket" @dragend="updateTaskPosition"
:bucket-index="bucketIndex" @openDeleteBucketModal="openDeleteBucketModal"
: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 </template>
v-else </draggable>
@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"> <BucketNew
<modal v-if="canWrite && !loading && buckets.length > 0"
v-if="hasBucketDeleteModal" class="bucket"
@close="hasBucketDeleteModal = false" :listId="listId"
@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> </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>
</template> </template>
</ListWrapper> </ListWrapper>
</template> </template>
@ -101,16 +77,19 @@ import cloneDeep from 'lodash.clonedeep'
import {useI18n} from 'vue-i18n' import {useI18n} from 'vue-i18n'
import {SortableEvent} from 'sortablejs' import {SortableEvent} from 'sortablejs'
import { success } from '@/message' import {success} from '@/message'
import {calculateItemPosition} from '@/helpers/calculateItemPosition' import {calculateItemPosition} from '@/helpers/calculateItemPosition'
import {getCollapsedBucketState, saveCollapsedBucketState} from '@/helpers/saveCollapsedBucketState'
import ListModel from '@/models/list'
import BucketModel from '@/models/bucket' import BucketModel from '@/models/bucket'
import TaskModel from '@/models/task' import TaskModel from '@/models/task'
import Rights from '@/models/constants/rights.json' import Rights from '@/models/constants/rights.json'
import ListWrapper from './ListWrapper.vue' import ListWrapper from './ListWrapper.vue'
import FilterPopup from '@/components/list/partials/filter-popup.vue' import FilterPopup from '@/components/list/partials/filter-popup.vue'
import KanbanBucket from '@/components/tasks/partials/KanbanBucket.vue' import Bucket from '@/features/kanban/Bucket.vue'
import BucketNew from '@/features/kanban/BucketNew.vue'
const DRAG_OPTIONS = { const DRAG_OPTIONS = {
// sortable options // sortable options
@ -127,7 +106,16 @@ const props = defineProps<{
const {t} = useI18n() const {t} = useI18n()
const collapsedBuckets = ref({}) /**
* Load collapsed Buckets
*/
const collapsedBuckets = ref<{ [listId: number]: any}>({})
watch(collapsedBuckets, (collapsedBuckets) => saveCollapsedBucketState(props.listId, collapsedBuckets))
/**
* Load Bucket Data
*/
const params = ref({ const params = ref({
filter_by: [], filter_by: [],
@ -136,95 +124,53 @@ const params = ref({
filter_concat: 'and', filter_concat: 'and',
}) })
interface LoadBucketsParams {
listId: number,
params: Object
}
watch(() => ({ watch(() => ({
listId: props.listId, listId: props.listId,
params: params.value, params: params.value,
} as LoadBucketsParams), loadBuckets, {immediate: true}) }), ({listId, params}) => {
function loadBuckets({listId, params} : LoadBucketsParams) {
store.dispatch('kanban/loadBucketsForList', {listId, params}) store.dispatch('kanban/loadBucketsForList', {listId, params})
collapsedBuckets.value = getCollapsedBucketState(listId) collapsedBuckets.value = getCollapsedBucketState(listId)
} }, {immediate: true, deep: true})
const isSavedFilter = computed(() => list.value.isSavedFilter && !list.value.isSavedFilter()) const list = computed(() => store.state.currentList as ListModel)
const buckets = computed(() => store.state.kanban.buckets) const buckets = computed({
get: () => store.state.kanban.buckets,
set(value: BucketModel[]) {
// (1) buckets get updated in store and tasks positions get invalidated
store.commit('kanban/setBuckets', value)
},
})
const loading = computed(() => store.state.loading && store.state.loadingModule === 'kanban')
// const taskLoading = computed(() => store.state.loading && store.state.loadingModule === 'tasks') // unused ? /**
* Template helpers
*/
const isSavedFilter = computed(() => list.value.isSavedFilter && list.value.isSavedFilter())
const canWrite = computed(() => store.state.currentList.maxRight > Rights.READ) const canWrite = computed(() => store.state.currentList.maxRight > Rights.READ)
const list = computed(() => store.state.currentList) const loading = computed(() => store.state.loading && store.state.loadingModule === 'kanban')
// FIXME: seems unused ?
// const taskLoading = computed(() => store.state.loading && store.state.loadingModule === 'tasks')
/**
* Manage list of updateing tasks
*/
// FIXME: save globally if a specific task is updated.
// We're using this to show the loading animation only at the task when updating it // We're using this to show the loading animation only at the task when updating it
const taskUpdating = ref<{ [id: TaskModel['id']]: boolean }>({}) const tasksUpdating = ref<{ [taskId: TaskModel['id']]: boolean }>({})
const oneTaskUpdating = ref(false)
const isDraggingTask = ref(false) function setTaskUpdating(id: TaskModel['id'], isUpdating: boolean) {
tasksUpdating.value[id] = isUpdating
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 oneTaskUpdating = computed(() => Object.values(tasksUpdating.value).some((isUpdating) => isUpdating))
const newBucketTitle = ref('') /**
async function createNewBucket() { * Delete Bucket
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 bucketToDeleteId = ref<BucketModel['id']>(0)
const hasBucketDeleteModal = ref(false) const hasBucketDeleteModal = ref(false)
@ -253,6 +199,10 @@ async function deleteBucket() {
} }
} }
/**
* Move / Drag Bucket
*/
const isDraggingBucket = ref(false) const isDraggingBucket = ref(false)
const bucketDraggableComponentData = computed(() => ({ const bucketDraggableComponentData = computed(() => ({
type: 'transition', type: 'transition',
@ -264,18 +214,13 @@ const bucketDraggableComponentData = computed(() => ({
], ],
})) }))
function updateBuckets(value: BucketModel[]) { function updateBucketPosition({newDraggableIndex}: SortableEvent) {
// (1) buckets get updated in store and tasks positions get invalidated
store.commit('kanban/setBuckets', value)
}
function updateBucketPosition(e) {
// (2) bucket positon is changed // (2) bucket positon is changed
isDraggingBucket.value = false isDraggingBucket.value = false
const bucket = buckets.value[e.newIndex] const bucket = buckets.value[newDraggableIndex]
const bucketBefore = buckets.value[e.newIndex - 1] ?? null const bucketBefore = buckets.value[newDraggableIndex - 1] ?? null
const bucketAfter = buckets.value[e.newIndex + 1] ?? null const bucketAfter = buckets.value[newDraggableIndex + 1] ?? null
store.dispatch('kanban/updateBucket', { store.dispatch('kanban/updateBucket', {
id: bucket.id, id: bucket.id,
@ -286,11 +231,15 @@ function updateBucketPosition(e) {
}) })
} }
const sourceBucketId = ref(0) /**
* Move / Drag Task
*/
const sourceBucketId = ref<number>()
function shouldAcceptDrop(bucket: BucketModel) { function shouldAcceptDrop(bucket: BucketModel) {
return !isDraggingBucket.value && ( return !isDraggingBucket.value && (
// When dragging from a bucket who has its limit reached, dragging should still be possible // It's always possible to drag a task inside the same a bucket
bucket.id === sourceBucketId.value || bucket.id === sourceBucketId.value ||
// If there is no limit set, dragging & dropping should always work // If there is no limit set, dragging & dropping should always work
bucket.limit === 0 || bucket.limit === 0 ||
@ -299,10 +248,62 @@ function shouldAcceptDrop(bucket: BucketModel) {
) )
} }
const isDraggingTask = ref(false)
function dragstart(bucketId: BucketModel['id']) { function dragstart(bucketId: BucketModel['id']) {
isDraggingTask.value = true isDraggingTask.value = true
sourceBucketId.value = bucketId sourceBucketId.value = bucketId
} }
async function updateTaskPosition({newDraggableIndex, to}: SortableEvent) {
isDraggingTask.value = false
// FIXME: Cases
// e.to.dataset.bucketIndex === undefined
// e.newIndex === undefined
// 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(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[newDraggableIndex - 1] ?? null
const taskAfter = newBucket.tasks[newDraggableIndex + 1] ?? null
const task = {
// cloning the task to avoid vuex store mutations
...cloneDeep(newBucket.tasks[newDraggableIndex]),
bucketId: newBucket.id,
kanbanPosition: calculateItemPosition(
taskBefore !== null ? taskBefore.kanbanPosition : null,
taskAfter !== null ? taskAfter.kanbanPosition : null,
),
} as TaskModel
setTaskUpdating(task.id, false)
try {
await store.dispatch('tasks/update', task)
} finally {
setTaskUpdating(task.id, false)
}
}
</script> </script>
@ -312,28 +313,25 @@ function dragstart(bucketId: BucketModel['id']) {
.app-content.list\.kanban { .app-content.list\.kanban {
padding-bottom: 0 !important; padding-bottom: 0 !important;
} }
</style>
<style lang="scss">
$ease-out: all .3s cubic-bezier(0.23, 1, 0.32, 1); $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
$crazy-height-calculation: '100vh - 4.5rem - 1.5rem - 1rem - 1.5rem - 11px'; // $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}';
$filter-container-height: '1rem - #{$switch-view-height}';
.kanban { .kanban {
--bucket-width: 300px; --bucket-width: 300px;
--bucket-right-margin: 1rem; --bucket-right-margin: 1rem;
overflow-x: auto; overflow-x: auto;
overflow-y: hidden; overflow-y: hidden;
height: calc(#{$crazy-height-calculation}); // height: calc(#{$crazy-height-calculation});
margin: 0 -1.5rem; margin: 0 -1.5rem;
padding: 0 1.5rem; padding: 0 1.5rem;
scroll-snap-type: x mandatory; scroll-snap-type: x mandatory;
@media screen and (max-width: $tablet) { // @media screen and (max-width: $tablet) {
height: calc(#{$crazy-height-calculation} - #{$filter-container-height}); // height: calc(#{$crazy-height-calculation} - #{$filter-container-height});
} // }
} }
.kanban-bucket-container { .kanban-bucket-container {
@ -341,7 +339,7 @@ $filter-container-height: '1rem - #{$switch-view-height}';
} }
.bucket { .bucket {
margin: 0 var(--bucket-right-margin) 0 0; margin-right: var(--bucket-right-margin);
max-height: 100%; max-height: 100%;
min-height: 20px; min-height: 20px;
width: var(--bucket-width); width: var(--bucket-width);
@ -367,22 +365,6 @@ $filter-container-height: '1rem - #{$switch-view-height}';
} }
} }
.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 { .task-dragging {
transform: rotateZ(3deg); transform: rotateZ(3deg);
transition: transform 0.18s ease; transition: transform 0.18s ease;