WIP: feat: create KanbanBucket from ListKanban #1571
|
@ -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
|
||||
isCollapsed: boolean
|
||||
dpschen marked this conversation as resolved
Outdated
konrad
commented
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?
dpschen
commented
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.
dpschen
commented
If we want to let's refactor this in a future pull request. 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
konrad
commented
Does this need to be a prop? Does this need to be a prop?
dpschen
commented
Will check Will check
dpschen
commented
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
konrad
commented
Could you add the Could you add the `<template>` before the `<script>`? Just to keep it consistent.
dpschen
commented
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.
konrad
commented
Doesn't the doc you linked say switching Doesn't the doc you linked say switching `<template>` and `<script>` blocks as long as the `<style>` tag comes last is allowed?
dpschen
commented
Fromt reading it seems like it, strange! 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>
|
|
@ -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)
|
||||
|
|
number
orNumber
? (What is the difference between them?)number
is the Typescript type of number, whileNumber
is a primitive object wrapper.See: https://www.typescriptlang.org/docs/handbook/declaration-files/do-s-and-don-ts.html#number-string-boolean-symbol-and-object