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