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)

File diff suppressed because it is too large Load Diff