WIP: feat: create KanbanBucket from ListKanban #1571
439
src/components/tasks/partials/KanbanBucket.vue
Normal 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
|
||||
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)
|
||||
|
|
|
@ -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
konrad
commented
Please merge the two 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
konrad
commented
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...
dpschen
commented
I will recheck the styles =) I will recheck the styles =)
dpschen
commented
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>
|
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