Compare commits
10 Commits
main
...
feat-list-
| Author | SHA1 | Date |
|---|---|---|
|
|
3765f9877c | |
|
|
f4883ef7c4 | |
|
|
46676289e0 | |
|
|
b392d130eb | |
|
|
f09201ff3b | |
|
|
13b10255c6 | |
|
|
a32f759228 | |
|
|
939eba7b44 | |
|
|
a0cc76c4c8 | |
|
|
b18b859b71 |
|
|
@ -304,7 +304,7 @@ import type {ITask} from '@/modelTypes/ITask'
|
||||||
|
|
||||||
import {useBaseStore} from '@/stores/base'
|
import {useBaseStore} from '@/stores/base'
|
||||||
import {useTaskStore} from '@/stores/tasks'
|
import {useTaskStore} from '@/stores/tasks'
|
||||||
import {useKanbanStore} from '@/stores/kanban'
|
import {useBucketStore} from '@/stores/buckets'
|
||||||
import {useAuthStore} from '@/stores/auth'
|
import {useAuthStore} from '@/stores/auth'
|
||||||
|
|
||||||
import ProjectWrapper from '@/components/project/ProjectWrapper.vue'
|
import ProjectWrapper from '@/components/project/ProjectWrapper.vue'
|
||||||
|
|
@ -356,7 +356,7 @@ const MIN_SCROLL_HEIGHT_PERCENT = 0.25
|
||||||
const {t} = useI18n({useScope: 'global'})
|
const {t} = useI18n({useScope: 'global'})
|
||||||
|
|
||||||
const baseStore = useBaseStore()
|
const baseStore = useBaseStore()
|
||||||
const kanbanStore = useKanbanStore()
|
const bucketStore = useBucketStore()
|
||||||
const taskStore = useTaskStore()
|
const taskStore = useTaskStore()
|
||||||
const projectStore = useProjectStore()
|
const projectStore = useProjectStore()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|
@ -480,8 +480,8 @@ function onHandleTouchMove(e: TouchEvent) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const buckets = computed(() => kanbanStore.buckets)
|
const buckets = computed(() => bucketStore.buckets)
|
||||||
const loading = computed(() => kanbanStore.isLoading)
|
const loading = computed(() => bucketStore.isLoading)
|
||||||
const projectIdWithFallback = computed<number>(() => project.value?.id || projectId.value)
|
const projectIdWithFallback = computed<number>(() => project.value?.id || projectId.value)
|
||||||
|
|
||||||
const taskLoading = computed(() => taskStore.isLoading || taskPositionService.value.loading)
|
const taskLoading = computed(() => taskStore.isLoading || taskPositionService.value.loading)
|
||||||
|
|
@ -497,7 +497,7 @@ watch(
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
collapsedBuckets.value = getCollapsedBucketState(projectId)
|
collapsedBuckets.value = getCollapsedBucketState(projectId)
|
||||||
kanbanStore.loadBucketsForProject(projectId, viewId, params)
|
bucketStore.loadBucketsForProject(projectId, viewId, params)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
immediate: true,
|
immediate: true,
|
||||||
|
|
@ -520,7 +520,7 @@ function handleTaskContainerScroll(id: IBucket['id'], el: HTMLElement) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
kanbanStore.loadNextTasksForBucket(
|
bucketStore.loadNextTasksForBucket(
|
||||||
projectId.value,
|
projectId.value,
|
||||||
props.viewId,
|
props.viewId,
|
||||||
params.value,
|
params.value,
|
||||||
|
|
@ -529,13 +529,13 @@ function handleTaskContainerScroll(id: IBucket['id'], el: HTMLElement) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateTasks(bucketId: IBucket['id'], tasks: IBucket['tasks']) {
|
function updateTasks(bucketId: IBucket['id'], tasks: IBucket['tasks']) {
|
||||||
const bucket = kanbanStore.getBucketById(bucketId)
|
const bucket = bucketStore.getBucketById(bucketId)
|
||||||
|
|
||||||
if (bucket === undefined) {
|
if (bucket === undefined) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
kanbanStore.setBucketById({
|
bucketStore.setBucketById({
|
||||||
...bucket,
|
...bucket,
|
||||||
tasks,
|
tasks,
|
||||||
})
|
})
|
||||||
|
|
@ -546,7 +546,7 @@ async function updateTaskPosition(e) {
|
||||||
|
|
||||||
// Check if dropped on a sidebar project
|
// Check if dropped on a sidebar project
|
||||||
const {moved} = await handleTaskDropToProject(e, (task) => {
|
const {moved} = await handleTaskDropToProject(e, (task) => {
|
||||||
kanbanStore.removeTaskInBucket(task)
|
bucketStore.removeTaskInBucket(task)
|
||||||
})
|
})
|
||||||
|
|
||||||
if (moved) {
|
if (moved) {
|
||||||
|
|
@ -596,11 +596,11 @@ async function updateTaskPosition(e) {
|
||||||
oldBucket !== undefined && // This shouldn't actually be `undefined`, but let's play it safe.
|
oldBucket !== undefined && // This shouldn't actually be `undefined`, but let's play it safe.
|
||||||
newBucket.id !== oldBucket.id
|
newBucket.id !== oldBucket.id
|
||||||
) {
|
) {
|
||||||
kanbanStore.setBucketById({
|
bucketStore.setBucketById({
|
||||||
...oldBucket,
|
...oldBucket,
|
||||||
count: oldBucket.count - 1,
|
count: oldBucket.count - 1,
|
||||||
})
|
})
|
||||||
kanbanStore.setBucketById({
|
bucketStore.setBucketById({
|
||||||
...newBucket,
|
...newBucket,
|
||||||
count: newBucket.count + 1,
|
count: newBucket.count + 1,
|
||||||
})
|
})
|
||||||
|
|
@ -626,13 +626,13 @@ async function updateTaskPosition(e) {
|
||||||
Object.assign(newTask, updatedTaskBucket.task)
|
Object.assign(newTask, updatedTaskBucket.task)
|
||||||
newTask.bucketId = updatedTaskBucket.bucketId
|
newTask.bucketId = updatedTaskBucket.bucketId
|
||||||
if (updatedTaskBucket.bucketId !== newTask.bucketId) {
|
if (updatedTaskBucket.bucketId !== newTask.bucketId) {
|
||||||
kanbanStore.moveTaskToBucket(newTask, updatedTaskBucket.bucketId)
|
bucketStore.moveTaskToBucket(newTask, updatedTaskBucket.bucketId)
|
||||||
}
|
}
|
||||||
if (updatedTaskBucket.bucket) {
|
if (updatedTaskBucket.bucket) {
|
||||||
kanbanStore.setBucketById(updatedTaskBucket.bucket, false)
|
bucketStore.setBucketById(updatedTaskBucket.bucket, false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
kanbanStore.setTaskInBucket(newTask)
|
bucketStore.setTaskInBucket(newTask)
|
||||||
|
|
||||||
// Make sure the first and second task don't both get position 0 assigned
|
// Make sure the first and second task don't both get position 0 assigned
|
||||||
if (newTaskIndex === 0 && taskAfter !== null && taskAfter.position === 0) {
|
if (newTaskIndex === 0 && taskAfter !== null && taskAfter.position === 0) {
|
||||||
|
|
@ -675,10 +675,10 @@ async function addTaskToBucket(bucketId: IBucket['id']) {
|
||||||
projectId: projectIdWithFallback.value,
|
projectId: projectIdWithFallback.value,
|
||||||
})
|
})
|
||||||
newTaskText.value = ''
|
newTaskText.value = ''
|
||||||
kanbanStore.addTaskToBucket(task)
|
bucketStore.addTaskToBucket(task)
|
||||||
scrollTaskContainerToTop(bucketId)
|
scrollTaskContainerToTop(bucketId)
|
||||||
|
|
||||||
const bucket = kanbanStore.getBucketById(bucketId)
|
const bucket = bucketStore.getBucketById(bucketId)
|
||||||
if (bucket && bucket.limit && bucket.count >= bucket.limit) {
|
if (bucket && bucket.limit && bucket.count >= bucket.limit) {
|
||||||
toggleShowNewTaskInput(bucketId)
|
toggleShowNewTaskInput(bucketId)
|
||||||
}
|
}
|
||||||
|
|
@ -697,7 +697,7 @@ async function createNewBucket() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await kanbanStore.createBucket(new BucketModel({
|
await bucketStore.createBucket(new BucketModel({
|
||||||
title: newBucketTitle.value,
|
title: newBucketTitle.value,
|
||||||
projectId: projectIdWithFallback.value,
|
projectId: projectIdWithFallback.value,
|
||||||
projectViewId: props.viewId,
|
projectViewId: props.viewId,
|
||||||
|
|
@ -716,7 +716,7 @@ function deleteBucketModal(bucketId: IBucket['id']) {
|
||||||
|
|
||||||
async function deleteBucket() {
|
async function deleteBucket() {
|
||||||
try {
|
try {
|
||||||
await kanbanStore.deleteBucket({
|
await bucketStore.deleteBucket({
|
||||||
bucket: new BucketModel({
|
bucket: new BucketModel({
|
||||||
id: bucketToDelete.value,
|
id: bucketToDelete.value,
|
||||||
projectId: projectIdWithFallback.value,
|
projectId: projectIdWithFallback.value,
|
||||||
|
|
@ -740,13 +740,13 @@ async function focusBucketTitle(e: Event) {
|
||||||
|
|
||||||
async function saveBucketTitle(bucketId: IBucket['id'], bucketTitle: string) {
|
async function saveBucketTitle(bucketId: IBucket['id'], bucketTitle: string) {
|
||||||
|
|
||||||
const bucket = kanbanStore.getBucketById(bucketId)
|
const bucket = bucketStore.getBucketById(bucketId)
|
||||||
if (bucket?.title === bucketTitle) {
|
if (bucket?.title === bucketTitle) {
|
||||||
bucketTitleEditable.value = false
|
bucketTitleEditable.value = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await kanbanStore.updateBucket({
|
await bucketStore.updateBucket({
|
||||||
id: bucketId,
|
id: bucketId,
|
||||||
title: bucketTitle,
|
title: bucketTitle,
|
||||||
projectId: projectId.value,
|
projectId: projectId.value,
|
||||||
|
|
@ -757,7 +757,7 @@ async function saveBucketTitle(bucketId: IBucket['id'], bucketTitle: string) {
|
||||||
|
|
||||||
function updateBuckets(value: IBucket[]) {
|
function updateBuckets(value: IBucket[]) {
|
||||||
// (1) buckets get updated in store and tasks positions get invalidated
|
// (1) buckets get updated in store and tasks positions get invalidated
|
||||||
kanbanStore.setBuckets(value)
|
bucketStore.setBuckets(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleRecurringTaskCompletion() {
|
function handleRecurringTaskCompletion() {
|
||||||
|
|
@ -772,7 +772,7 @@ function handleRecurringTaskCompletion() {
|
||||||
|
|
||||||
if (filterContainsDateFields) {
|
if (filterContainsDateFields) {
|
||||||
// Reload the kanban board to refresh tasks that now match/don't match the filter
|
// Reload the kanban board to refresh tasks that now match/don't match the filter
|
||||||
kanbanStore.loadBucketsForProject(projectId.value, props.viewId, params.value)
|
bucketStore.loadBucketsForProject(projectId.value, props.viewId, params.value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -785,7 +785,7 @@ function updateBucketPosition(e: { newIndex: number }) {
|
||||||
const bucketBefore = buckets.value[e.newIndex - 1] ?? null
|
const bucketBefore = buckets.value[e.newIndex - 1] ?? null
|
||||||
const bucketAfter = buckets.value[e.newIndex + 1] ?? null
|
const bucketAfter = buckets.value[e.newIndex + 1] ?? null
|
||||||
|
|
||||||
kanbanStore.updateBucket({
|
bucketStore.updateBucket({
|
||||||
id: bucket.id,
|
id: bucket.id,
|
||||||
projectId: projectId.value,
|
projectId: projectId.value,
|
||||||
position: calculateItemPosition(
|
position: calculateItemPosition(
|
||||||
|
|
@ -800,8 +800,8 @@ async function saveBucketLimit(bucketId: IBucket['id'], limit: number) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await kanbanStore.updateBucket({
|
await bucketStore.updateBucket({
|
||||||
...kanbanStore.getBucketById(bucketId),
|
...bucketStore.getBucketById(bucketId),
|
||||||
projectId: projectId.value,
|
projectId: projectId.value,
|
||||||
limit,
|
limit,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@
|
||||||
|
|
||||||
<template #default>
|
<template #default>
|
||||||
<div
|
<div
|
||||||
:class="{ 'is-loading': loading }"
|
:class="{ 'is-loading': hasBuckets ? bucketStore.isLoading : loading }"
|
||||||
class="loader-container is-max-width-desktop list-view"
|
class="loader-container is-max-width-desktop list-view"
|
||||||
>
|
>
|
||||||
<Card
|
<Card
|
||||||
|
|
@ -30,6 +30,188 @@
|
||||||
:has-content="false"
|
:has-content="false"
|
||||||
class="has-overflow"
|
class="has-overflow"
|
||||||
>
|
>
|
||||||
|
<!-- Sectioned mode (list view with manual bucket configuration) -->
|
||||||
|
<template v-if="hasBuckets">
|
||||||
|
<AddTask
|
||||||
|
v-if="!project?.isArchived && canWrite"
|
||||||
|
ref="addTaskRef"
|
||||||
|
class="list-view__add-task d-print-none"
|
||||||
|
:default-position="firstNewPosition"
|
||||||
|
@taskAdded="updateTaskList"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="bucket in buckets"
|
||||||
|
:key="bucket.id"
|
||||||
|
class="bucket-section"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="bucket-section__header"
|
||||||
|
@click="toggleBucketCollapse(bucket.id)"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="icon bucket-section__collapse-icon"
|
||||||
|
:class="{'is-collapsed': collapsedBuckets[bucket.id]}"
|
||||||
|
>
|
||||||
|
<Icon icon="chevron-down" />
|
||||||
|
</span>
|
||||||
|
<h2
|
||||||
|
class="bucket-section__title"
|
||||||
|
:contenteditable="(canWrite && !collapsedBuckets[bucket.id]) ? true : undefined"
|
||||||
|
:spellcheck="false"
|
||||||
|
@keydown.enter.prevent.stop="!$event.isComposing && ($event.target as HTMLElement).blur()"
|
||||||
|
@keydown.esc.prevent.stop="!$event.isComposing && ($event.target as HTMLElement).blur()"
|
||||||
|
@blur="saveBucketTitle(bucket.id, ($event.target as HTMLElement).textContent as string)"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
{{ bucket.title }}
|
||||||
|
</h2>
|
||||||
|
<span
|
||||||
|
v-if="bucket.limit > 0 || bucket.count > 0"
|
||||||
|
:class="{'is-max': bucket.limit > 0 && bucket.count >= bucket.limit}"
|
||||||
|
class="bucket-section__count"
|
||||||
|
>
|
||||||
|
{{ bucket.limit > 0 ? `${bucket.count}/${bucket.limit}` : bucket.count }}
|
||||||
|
</span>
|
||||||
|
<Dropdown
|
||||||
|
v-if="canWrite && !collapsedBuckets[bucket.id]"
|
||||||
|
class="is-right bucket-section__options"
|
||||||
|
trigger-icon="ellipsis-v"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<DropdownItem
|
||||||
|
v-tooltip="$t('project.kanban.defaultBucketHint')"
|
||||||
|
:icon-class="{'has-text-primary': bucket.id === currentView?.defaultBucketId}"
|
||||||
|
icon="th"
|
||||||
|
@click.stop="toggleDefaultBucket(bucket)"
|
||||||
|
>
|
||||||
|
{{ $t('project.kanban.defaultBucket') }}
|
||||||
|
</DropdownItem>
|
||||||
|
<DropdownItem
|
||||||
|
icon="angles-up"
|
||||||
|
@click.stop="toggleBucketCollapse(bucket.id)"
|
||||||
|
>
|
||||||
|
{{ $t('project.list.collapseSection') }}
|
||||||
|
</DropdownItem>
|
||||||
|
<DropdownItem
|
||||||
|
v-tooltip="buckets.length <= 1 ? $t('project.kanban.deleteLast') : ''"
|
||||||
|
class="has-text-danger"
|
||||||
|
:class="{'is-disabled': buckets.length <= 1}"
|
||||||
|
icon-class="has-text-danger"
|
||||||
|
icon="trash-alt"
|
||||||
|
@click.stop="() => deleteBucketModal(bucket.id)"
|
||||||
|
>
|
||||||
|
{{ $t('misc.delete') }}
|
||||||
|
</DropdownItem>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<draggable
|
||||||
|
v-if="!collapsedBuckets[bucket.id]"
|
||||||
|
:model-value="bucket.tasks"
|
||||||
|
group="tasks"
|
||||||
|
item-key="id"
|
||||||
|
tag="ul"
|
||||||
|
:component-data="{
|
||||||
|
class: {
|
||||||
|
tasks: true,
|
||||||
|
'dragging-disabled': !canDragTasks || !isPositionSorting,
|
||||||
|
},
|
||||||
|
type: 'transition-group',
|
||||||
|
}"
|
||||||
|
:animation="100"
|
||||||
|
:handle="dragHandle"
|
||||||
|
:delay-on-touch-only="!isTouchDevice"
|
||||||
|
:delay="isTouchDevice ? 0 : 1000"
|
||||||
|
ghost-class="task-ghost"
|
||||||
|
@start="handleDragStart"
|
||||||
|
@end="(e) => saveTaskPositionInBucket(e, bucket)"
|
||||||
|
>
|
||||||
|
<template #item="{element: t}">
|
||||||
|
<SingleTaskInProject
|
||||||
|
:show-list-color="false"
|
||||||
|
:can-mark-as-done="canWrite || isPseudoProject"
|
||||||
|
:the-task="t"
|
||||||
|
:all-tasks="getAllTasksFromBuckets()"
|
||||||
|
@taskUpdated="updateTaskInBuckets"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-if="canDragTasks && isPositionSorting"
|
||||||
|
class="icon handle"
|
||||||
|
>
|
||||||
|
<Icon icon="grip-lines" />
|
||||||
|
</span>
|
||||||
|
</SingleTaskInProject>
|
||||||
|
</template>
|
||||||
|
</draggable>
|
||||||
|
|
||||||
|
<!-- Load more -->
|
||||||
|
<div
|
||||||
|
v-if="!collapsedBuckets[bucket.id] && bucket.tasks.length < bucket.count"
|
||||||
|
class="bucket-section__load-more"
|
||||||
|
>
|
||||||
|
<ButtonLink @click="loadMoreForBucket(bucket.id)">
|
||||||
|
{{ $t('project.list.loadMore', {remaining: bucket.count - bucket.tasks.length}) }}
|
||||||
|
</ButtonLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add section button -->
|
||||||
|
<div
|
||||||
|
v-if="canWrite"
|
||||||
|
class="bucket-section__add"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="showNewSectionInput"
|
||||||
|
class="field has-addons bucket-section__add-input"
|
||||||
|
>
|
||||||
|
<div class="control is-expanded">
|
||||||
|
<input
|
||||||
|
v-model="newSectionTitle"
|
||||||
|
v-focus.always
|
||||||
|
class="input"
|
||||||
|
:placeholder="$t('project.list.addSectionPlaceholder')"
|
||||||
|
type="text"
|
||||||
|
@keyup.enter="createNewSection"
|
||||||
|
@keyup.esc="showNewSectionInput = false"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="control">
|
||||||
|
<XButton
|
||||||
|
:shadow="false"
|
||||||
|
@click="createNewSection"
|
||||||
|
>
|
||||||
|
{{ $t('project.list.add') }}
|
||||||
|
</XButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ButtonLink
|
||||||
|
v-else
|
||||||
|
@click="showNewSectionInput = true"
|
||||||
|
>
|
||||||
|
<Icon icon="plus" />
|
||||||
|
{{ $t('project.list.addSection') }}
|
||||||
|
</ButtonLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete section confirmation modal -->
|
||||||
|
<Modal
|
||||||
|
v-if="showSectionDeleteModal"
|
||||||
|
@close="showSectionDeleteModal = false"
|
||||||
|
@submit="deleteSection"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
{{ $t('project.list.deleteSection') }}
|
||||||
|
</template>
|
||||||
|
<template #text>
|
||||||
|
<p>{{ $t('project.list.deleteSectionText1') }}</p>
|
||||||
|
<p>{{ $t('project.list.deleteSectionText2') }}</p>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Flat mode (existing) -->
|
||||||
|
<template v-else>
|
||||||
<AddTask
|
<AddTask
|
||||||
v-if="!project?.isArchived && canWrite"
|
v-if="!project?.isArchived && canWrite"
|
||||||
ref="addTaskRef"
|
ref="addTaskRef"
|
||||||
|
|
@ -93,6 +275,7 @@
|
||||||
:total-pages="totalPages"
|
:total-pages="totalPages"
|
||||||
:current-page="currentPage"
|
:current-page="currentPage"
|
||||||
/>
|
/>
|
||||||
|
</template>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -102,6 +285,7 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {ref, computed, nextTick, onMounted, onBeforeUnmount, watch, toRef} from 'vue'
|
import {ref, computed, nextTick, onMounted, onBeforeUnmount, watch, toRef} from 'vue'
|
||||||
|
import {useI18n} from 'vue-i18n'
|
||||||
import draggable from 'zhyswan-vuedraggable'
|
import draggable from 'zhyswan-vuedraggable'
|
||||||
|
|
||||||
import ProjectWrapper from '@/components/project/ProjectWrapper.vue'
|
import ProjectWrapper from '@/components/project/ProjectWrapper.vue'
|
||||||
|
|
@ -112,22 +296,39 @@ import FilterPopup from '@/components/project/partials/FilterPopup.vue'
|
||||||
import Nothing from '@/components/misc/Nothing.vue'
|
import Nothing from '@/components/misc/Nothing.vue'
|
||||||
import Pagination from '@/components/misc/Pagination.vue'
|
import Pagination from '@/components/misc/Pagination.vue'
|
||||||
import SortPopup from '@/components/project/partials/SortPopup.vue'
|
import SortPopup from '@/components/project/partials/SortPopup.vue'
|
||||||
|
import Dropdown from '@/components/misc/Dropdown.vue'
|
||||||
|
import DropdownItem from '@/components/misc/DropdownItem.vue'
|
||||||
|
import Modal from '@/components/misc/Modal.vue'
|
||||||
|
|
||||||
import {useTaskList} from '@/composables/useTaskList'
|
import {useTaskList} from '@/composables/useTaskList'
|
||||||
import {useTaskDragToProject} from '@/composables/useTaskDragToProject'
|
import {useTaskDragToProject} from '@/composables/useTaskDragToProject'
|
||||||
import {shouldShowTaskInListView} from '@/composables/useTaskListFiltering'
|
import {shouldShowTaskInListView} from '@/composables/useTaskListFiltering'
|
||||||
import {PERMISSIONS as Permissions} from '@/constants/permissions'
|
import {PERMISSIONS as Permissions} from '@/constants/permissions'
|
||||||
import {calculateItemPosition} from '@/helpers/calculateItemPosition'
|
import {calculateItemPosition} from '@/helpers/calculateItemPosition'
|
||||||
|
import {
|
||||||
|
type CollapsedBuckets,
|
||||||
|
getCollapsedBucketState,
|
||||||
|
saveCollapsedBucketState,
|
||||||
|
} from '@/helpers/saveCollapsedBucketState'
|
||||||
import type {ITask} from '@/modelTypes/ITask'
|
import type {ITask} from '@/modelTypes/ITask'
|
||||||
|
import type {IBucket} from '@/modelTypes/IBucket'
|
||||||
import {isSavedFilter, useSavedFilter} from '@/services/savedFilter'
|
import {isSavedFilter, useSavedFilter} from '@/services/savedFilter'
|
||||||
|
import {success} from '@/message'
|
||||||
|
|
||||||
import {useBaseStore} from '@/stores/base'
|
import {useBaseStore} from '@/stores/base'
|
||||||
import {useTaskStore} from '@/stores/tasks'
|
import {useTaskStore} from '@/stores/tasks'
|
||||||
|
import {useBucketStore} from '@/stores/buckets'
|
||||||
|
import {useProjectStore} from '@/stores/projects'
|
||||||
|
|
||||||
import type {IProject} from '@/modelTypes/IProject'
|
import type {IProject} from '@/modelTypes/IProject'
|
||||||
import type {IProjectView} from '@/modelTypes/IProjectView'
|
import type {IProjectView} from '@/modelTypes/IProjectView'
|
||||||
import TaskPositionService from '@/services/taskPosition'
|
import TaskPositionService from '@/services/taskPosition'
|
||||||
import TaskPositionModel from '@/models/taskPosition'
|
import TaskPositionModel from '@/models/taskPosition'
|
||||||
|
import TaskBucketService from '@/services/taskBucket'
|
||||||
|
import TaskBucketModel from '@/models/taskBucket'
|
||||||
|
import BucketModel from '@/models/bucket'
|
||||||
|
import ProjectViewService from '@/services/projectViews'
|
||||||
|
import ProjectViewModel from '@/models/projectView'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
isLoadingProject: boolean,
|
isLoadingProject: boolean,
|
||||||
|
|
@ -139,6 +340,8 @@ const projectId = toRef(props, 'projectId')
|
||||||
|
|
||||||
defineOptions({name: 'List'})
|
defineOptions({name: 'List'})
|
||||||
|
|
||||||
|
const {t} = useI18n({useScope: 'global'})
|
||||||
|
|
||||||
const ctaVisible = ref(false)
|
const ctaVisible = ref(false)
|
||||||
|
|
||||||
const drag = ref(false)
|
const drag = ref(false)
|
||||||
|
|
@ -177,6 +380,14 @@ watch(
|
||||||
const isPositionSorting = computed(() => 'position' in sortByParam.value)
|
const isPositionSorting = computed(() => 'position' in sortByParam.value)
|
||||||
|
|
||||||
const firstNewPosition = computed(() => {
|
const firstNewPosition = computed(() => {
|
||||||
|
if (hasBuckets.value) {
|
||||||
|
const defaultBucket = buckets.value.find(b => b.id === currentView.value?.defaultBucketId) || buckets.value[0]
|
||||||
|
if (defaultBucket?.tasks?.length > 0) {
|
||||||
|
return calculateItemPosition(null, defaultBucket.tasks[0].position)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
if (tasks.value.length === 0) {
|
if (tasks.value.length === 0) {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
@ -186,6 +397,8 @@ const firstNewPosition = computed(() => {
|
||||||
|
|
||||||
const baseStore = useBaseStore()
|
const baseStore = useBaseStore()
|
||||||
const taskStore = useTaskStore()
|
const taskStore = useTaskStore()
|
||||||
|
const bucketStore = useBucketStore()
|
||||||
|
const projectStore = useProjectStore()
|
||||||
const {handleTaskDropToProject} = useTaskDragToProject()
|
const {handleTaskDropToProject} = useTaskDragToProject()
|
||||||
const project = computed(() => baseStore.currentProject)
|
const project = computed(() => baseStore.currentProject)
|
||||||
|
|
||||||
|
|
@ -214,7 +427,184 @@ function focusNewTaskInput() {
|
||||||
addTaskRef.value?.focusTaskInput()
|
addTaskRef.value?.focusTaskInput()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Bucket/Section mode
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
const currentView = computed(() => {
|
||||||
|
return project.value?.views?.find(v => v.id === props.viewId) as IProjectView || null
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasBuckets = computed(() => {
|
||||||
|
return currentView.value?.bucketConfigurationMode !== 'none'
|
||||||
|
&& currentView.value?.bucketConfigurationMode !== undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
const buckets = computed(() => bucketStore.buckets)
|
||||||
|
|
||||||
|
const collapsedBuckets = ref<CollapsedBuckets>({})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[() => props.projectId, () => props.viewId, hasBuckets],
|
||||||
|
async ([pId, vId, bucketed]) => {
|
||||||
|
if (!bucketed) return
|
||||||
|
await bucketStore.loadBucketsForProject(pId, vId, params.value)
|
||||||
|
collapsedBuckets.value = getCollapsedBucketState(pId)
|
||||||
|
},
|
||||||
|
{immediate: true},
|
||||||
|
)
|
||||||
|
|
||||||
|
function toggleBucketCollapse(bucketId: IBucket['id']) {
|
||||||
|
collapsedBuckets.value = {
|
||||||
|
...collapsedBuckets.value,
|
||||||
|
[bucketId]: !collapsedBuckets.value[bucketId],
|
||||||
|
}
|
||||||
|
saveCollapsedBucketState(props.projectId, collapsedBuckets.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAllTasksFromBuckets(): ITask[] {
|
||||||
|
return buckets.value.flatMap(b => b.tasks)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTaskInBuckets(updatedTask: ITask) {
|
||||||
|
bucketStore.setTaskInBucket(updatedTask)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveBucketTitle(bucketId: IBucket['id'], bucketTitle: string) {
|
||||||
|
const bucket = bucketStore.getBucketById(bucketId)
|
||||||
|
if (bucket?.title === bucketTitle) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await bucketStore.updateBucket({
|
||||||
|
id: bucketId,
|
||||||
|
title: bucketTitle,
|
||||||
|
projectId: projectId.value,
|
||||||
|
})
|
||||||
|
success({message: t('project.list.sectionTitleSavedSuccess')})
|
||||||
|
}
|
||||||
|
|
||||||
|
const sectionToDelete = ref<IBucket['id']>(0)
|
||||||
|
const showSectionDeleteModal = ref(false)
|
||||||
|
|
||||||
|
function deleteBucketModal(bucketId: IBucket['id']) {
|
||||||
|
if (buckets.value.length <= 1) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sectionToDelete.value = bucketId
|
||||||
|
showSectionDeleteModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteSection() {
|
||||||
|
try {
|
||||||
|
await bucketStore.deleteBucket({
|
||||||
|
bucket: new BucketModel({
|
||||||
|
id: sectionToDelete.value,
|
||||||
|
projectId: projectId.value,
|
||||||
|
projectViewId: props.viewId,
|
||||||
|
}),
|
||||||
|
params: params.value,
|
||||||
|
})
|
||||||
|
success({message: t('project.list.deleteSectionSuccess')})
|
||||||
|
} finally {
|
||||||
|
showSectionDeleteModal.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const newSectionTitle = ref('')
|
||||||
|
const showNewSectionInput = ref(false)
|
||||||
|
|
||||||
|
async function createNewSection() {
|
||||||
|
if (newSectionTitle.value === '') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await bucketStore.createBucket(new BucketModel({
|
||||||
|
title: newSectionTitle.value,
|
||||||
|
projectId: projectId.value,
|
||||||
|
projectViewId: props.viewId,
|
||||||
|
}))
|
||||||
|
newSectionTitle.value = ''
|
||||||
|
showNewSectionInput.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleDefaultBucket(bucket: IBucket) {
|
||||||
|
const defaultBucketId = currentView.value?.defaultBucketId === bucket.id
|
||||||
|
? 0
|
||||||
|
: bucket.id
|
||||||
|
|
||||||
|
const projectViewService = new ProjectViewService()
|
||||||
|
const updatedView = await projectViewService.update(new ProjectViewModel({
|
||||||
|
...currentView.value,
|
||||||
|
defaultBucketId,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const views = project.value.views.map(v => v.id === currentView.value?.id ? updatedView : v)
|
||||||
|
const updatedProject = {
|
||||||
|
...project.value,
|
||||||
|
views,
|
||||||
|
}
|
||||||
|
|
||||||
|
projectStore.setProject(updatedProject)
|
||||||
|
|
||||||
|
success({message: t('project.kanban.defaultBucketSavedSuccess')})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMoreForBucket(bucketId: IBucket['id']) {
|
||||||
|
await bucketStore.loadNextTasksForBucket(
|
||||||
|
props.projectId,
|
||||||
|
props.viewId,
|
||||||
|
params.value,
|
||||||
|
bucketId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveTaskPositionInBucket(
|
||||||
|
e: {originalEvent?: MouseEvent, to: HTMLElement, from: HTMLElement, newIndex: number, oldIndex: number},
|
||||||
|
targetBucket: IBucket,
|
||||||
|
) {
|
||||||
|
drag.value = false
|
||||||
|
|
||||||
|
const task = targetBucket.tasks[e.newIndex]
|
||||||
|
if (!task) return
|
||||||
|
|
||||||
|
const taskBefore = targetBucket.tasks[e.newIndex - 1] ?? null
|
||||||
|
const taskAfter = targetBucket.tasks[e.newIndex + 1] ?? null
|
||||||
|
|
||||||
|
const position = calculateItemPosition(
|
||||||
|
taskBefore?.position ?? null,
|
||||||
|
taskAfter?.position ?? null,
|
||||||
|
)
|
||||||
|
|
||||||
|
await taskPositionService.value.update(new TaskPositionModel({
|
||||||
|
position,
|
||||||
|
projectViewId: props.viewId,
|
||||||
|
taskId: task.id,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// If bucket changed, update bucket assignment
|
||||||
|
if (e.to !== e.from) {
|
||||||
|
const taskBucketService = new TaskBucketService()
|
||||||
|
await taskBucketService.update(new TaskBucketModel({
|
||||||
|
taskId: task.id,
|
||||||
|
bucketId: targetBucket.id,
|
||||||
|
projectViewId: props.viewId,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Flat mode (existing logic)
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
function updateTaskList(task: ITask) {
|
function updateTaskList(task: ITask) {
|
||||||
|
if (hasBuckets.value) {
|
||||||
|
bucketStore.addTaskToBucket(task)
|
||||||
|
baseStore.setHasTasks(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (!isPositionSorting.value) {
|
if (!isPositionSorting.value) {
|
||||||
// reload tasks with current filter and sorting
|
// reload tasks with current filter and sorting
|
||||||
loadTasks()
|
loadTasks()
|
||||||
|
|
@ -246,7 +636,8 @@ function updateTasks(updatedTask: ITask) {
|
||||||
function handleDragStart(e: { item: HTMLElement }) {
|
function handleDragStart(e: { item: HTMLElement }) {
|
||||||
drag.value = true
|
drag.value = true
|
||||||
const taskId = parseInt(e.item.dataset.taskId ?? '', 10)
|
const taskId = parseInt(e.item.dataset.taskId ?? '', 10)
|
||||||
const task = tasks.value.find(t => t.id === taskId)
|
const allAvailableTasks = hasBuckets.value ? getAllTasksFromBuckets() : tasks.value
|
||||||
|
const task = allAvailableTasks.find(t => t.id === taskId)
|
||||||
|
|
||||||
if (task) {
|
if (task) {
|
||||||
taskStore.setDraggedTask(task)
|
taskStore.setDraggedTask(task)
|
||||||
|
|
@ -421,4 +812,84 @@ onBeforeUnmount(() => {
|
||||||
margin-block-end: 0;
|
margin-block-end: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bucket section styles
|
||||||
|
.bucket-section {
|
||||||
|
&:not(:first-child) {
|
||||||
|
border-block-start: 1px solid var(--grey-200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bucket-section__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .5rem;
|
||||||
|
padding: .75rem 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
background: var(--grey-50);
|
||||||
|
border-block-end: 1px solid var(--grey-100);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--grey-100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bucket-section__collapse-icon {
|
||||||
|
transition: transform 150ms ease;
|
||||||
|
color: var(--grey-500);
|
||||||
|
|
||||||
|
&.is-collapsed {
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bucket-section__title {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
padding: .125rem .25rem;
|
||||||
|
border-radius: $radius;
|
||||||
|
min-inline-size: 0;
|
||||||
|
|
||||||
|
&[contenteditable='true'] {
|
||||||
|
cursor: text;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: 2px solid var(--primary);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bucket-section__count {
|
||||||
|
font-size: .85rem;
|
||||||
|
color: var(--grey-500);
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
&.is-max {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bucket-section__options {
|
||||||
|
margin-inline-start: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bucket-section__load-more {
|
||||||
|
padding: .5rem 1rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--grey-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bucket-section__add {
|
||||||
|
padding: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bucket-section__add-input {
|
||||||
|
max-inline-size: 400px;
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -177,7 +177,7 @@ function handleBubbleSave() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="view.viewKind === 'kanban'"
|
v-if="view.viewKind === 'kanban' || view.viewKind === 'list'"
|
||||||
class="field"
|
class="field"
|
||||||
>
|
>
|
||||||
<label
|
<label
|
||||||
|
|
@ -212,7 +212,7 @@ function handleBubbleSave() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="view.viewKind === 'kanban' && view.bucketConfigurationMode === 'filter'"
|
v-if="(view.viewKind === 'kanban' || view.viewKind === 'list') && view.bucketConfigurationMode === 'filter'"
|
||||||
class="field"
|
class="field"
|
||||||
>
|
>
|
||||||
<label class="label">
|
<label class="label">
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ import type {IBucket} from '@/modelTypes/IBucket'
|
||||||
import {PROJECT_VIEW_KINDS} from '@/modelTypes/IProjectView'
|
import {PROJECT_VIEW_KINDS} from '@/modelTypes/IProjectView'
|
||||||
|
|
||||||
import {useProjectStore} from '@/stores/projects'
|
import {useProjectStore} from '@/stores/projects'
|
||||||
import {useKanbanStore} from '@/stores/kanban'
|
import {useBucketStore} from '@/stores/buckets'
|
||||||
import {useBaseStore} from '@/stores/base'
|
import {useBaseStore} from '@/stores/base'
|
||||||
|
|
||||||
import BaseButton from '@/components/base/BaseButton.vue'
|
import BaseButton from '@/components/base/BaseButton.vue'
|
||||||
|
|
@ -69,30 +69,30 @@ const emit = defineEmits<{
|
||||||
const {t} = useI18n({useScope: 'global'})
|
const {t} = useI18n({useScope: 'global'})
|
||||||
|
|
||||||
const projectStore = useProjectStore()
|
const projectStore = useProjectStore()
|
||||||
const kanbanStore = useKanbanStore()
|
const bucketStore = useBucketStore()
|
||||||
const baseStore = useBaseStore()
|
const baseStore = useBaseStore()
|
||||||
|
|
||||||
const project = computed(() => projectStore.projects[props.task.projectId])
|
const project = computed(() => projectStore.projects[props.task.projectId])
|
||||||
|
|
||||||
// If the project has exactly one manual kanban view, always use it.
|
// If the project has exactly one manual bucket view (kanban or list), always use it.
|
||||||
// If there are multiple, only show the selector when the active view is one of them.
|
// If there are multiple, only show the selector when the active view is one of them.
|
||||||
const kanbanView = computed(() => {
|
const kanbanView = computed(() => {
|
||||||
if (!project.value?.views) {
|
if (!project.value?.views) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const manualKanbanViews = project.value.views.filter(
|
const manualBucketViews = project.value.views.filter(
|
||||||
v => v.viewKind === PROJECT_VIEW_KINDS.KANBAN
|
v => (v.viewKind === PROJECT_VIEW_KINDS.KANBAN || v.viewKind === PROJECT_VIEW_KINDS.LIST)
|
||||||
&& v.bucketConfigurationMode === 'manual',
|
&& v.bucketConfigurationMode === 'manual',
|
||||||
)
|
)
|
||||||
|
|
||||||
if (manualKanbanViews.length === 1) {
|
if (manualBucketViews.length === 1) {
|
||||||
return manualKanbanViews[0]
|
return manualBucketViews[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
if (manualKanbanViews.length > 1) {
|
if (manualBucketViews.length > 1) {
|
||||||
const activeViewId = baseStore.currentProjectViewId
|
const activeViewId = baseStore.currentProjectViewId
|
||||||
return manualKanbanViews.find(v => v.id === activeViewId) || null
|
return manualBucketViews.find(v => v.id === activeViewId) || null
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
|
|
@ -156,7 +156,7 @@ async function changeBucket(bucket: IBucket) {
|
||||||
updatedBuckets.push({...bucket})
|
updatedBuckets.push({...bucket})
|
||||||
}
|
}
|
||||||
|
|
||||||
kanbanStore.moveTaskToBucket(props.task, bucket.id)
|
bucketStore.moveTaskToBucket(props.task, bucket.id)
|
||||||
|
|
||||||
// Only pick up done state from the response since moving to/from the
|
// Only pick up done state from the response since moving to/from the
|
||||||
// done bucket can toggle it. Spreading the full response task would
|
// done bucket can toggle it. Spreading the full response task would
|
||||||
|
|
|
||||||
|
|
@ -441,7 +441,17 @@
|
||||||
"empty": "This project is currently empty.",
|
"empty": "This project is currently empty.",
|
||||||
"newTaskCta": "Create a task.",
|
"newTaskCta": "Create a task.",
|
||||||
"editTask": "Edit Task",
|
"editTask": "Edit Task",
|
||||||
"sort": "Sort"
|
"sort": "Sort",
|
||||||
|
"loadMore": "Show {remaining} more tasks",
|
||||||
|
"addSection": "Add section",
|
||||||
|
"addSectionPlaceholder": "Enter the new section title…",
|
||||||
|
"deleteSection": "Delete this section",
|
||||||
|
"deleteSectionText1": "Are you sure you want to delete this section?",
|
||||||
|
"deleteSectionText2": "This will not delete any tasks but move them into the default section.",
|
||||||
|
"deleteSectionSuccess": "The section has been deleted successfully.",
|
||||||
|
"sectionTitleSavedSuccess": "The section title has been saved successfully.",
|
||||||
|
"sectionLimitSavedSuccess": "The section limit has been saved successfully.",
|
||||||
|
"collapseSection": "Collapse this section"
|
||||||
},
|
},
|
||||||
"gantt": {
|
"gantt": {
|
||||||
"title": "Gantt",
|
"title": "Gantt",
|
||||||
|
|
|
||||||
|
|
@ -32,10 +32,10 @@ function getTaskIndicesById(buckets: IBucket[], taskId: ITask['id']) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This store is intended to hold the currently active kanban view.
|
* This store is intended to hold the buckets for the currently active bucketed view (kanban or list).
|
||||||
* It should hold only the current buckets.
|
* It should hold only the current buckets.
|
||||||
*/
|
*/
|
||||||
export const useKanbanStore = defineStore('kanban', () => {
|
export const useBucketStore = defineStore('buckets', () => {
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const baseStore = useBaseStore()
|
const baseStore = useBaseStore()
|
||||||
|
|
||||||
|
|
@ -399,5 +399,5 @@ export const useKanbanStore = defineStore('kanban', () => {
|
||||||
|
|
||||||
// support hot reloading
|
// support hot reloading
|
||||||
if (import.meta.hot) {
|
if (import.meta.hot) {
|
||||||
import.meta.hot.accept(acceptHMRUpdate(useKanbanStore, import.meta.hot))
|
import.meta.hot.accept(acceptHMRUpdate(useBucketStore, import.meta.hot))
|
||||||
}
|
}
|
||||||
|
|
@ -29,7 +29,7 @@ import {REMINDER_PERIOD_RELATIVE_TO_TYPES} from '@/types/IReminderPeriodRelative
|
||||||
import {setModuleLoading} from '@/stores/helper'
|
import {setModuleLoading} from '@/stores/helper'
|
||||||
import {useLabelStore} from '@/stores/labels'
|
import {useLabelStore} from '@/stores/labels'
|
||||||
import {useProjectStore} from '@/stores/projects'
|
import {useProjectStore} from '@/stores/projects'
|
||||||
import {useKanbanStore} from '@/stores/kanban'
|
import {useBucketStore} from '@/stores/buckets'
|
||||||
import {useBaseStore} from '@/stores/base'
|
import {useBaseStore} from '@/stores/base'
|
||||||
import ProjectUserService from '@/services/projectUsers'
|
import ProjectUserService from '@/services/projectUsers'
|
||||||
import {useAuthStore} from '@/stores/auth'
|
import {useAuthStore} from '@/stores/auth'
|
||||||
|
|
@ -127,7 +127,7 @@ async function findAssignees(parsedTaskAssignees: string[], projectId: number):
|
||||||
|
|
||||||
export const useTaskStore = defineStore('task', () => {
|
export const useTaskStore = defineStore('task', () => {
|
||||||
const baseStore = useBaseStore()
|
const baseStore = useBaseStore()
|
||||||
const kanbanStore = useKanbanStore()
|
const bucketStore = useBucketStore()
|
||||||
const labelStore = useLabelStore()
|
const labelStore = useLabelStore()
|
||||||
const projectStore = useProjectStore()
|
const projectStore = useProjectStore()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|
@ -184,7 +184,7 @@ export const useTaskStore = defineStore('task', () => {
|
||||||
const taskService = new TaskService()
|
const taskService = new TaskService()
|
||||||
try {
|
try {
|
||||||
const updatedTask = await taskService.update(task)
|
const updatedTask = await taskService.update(task)
|
||||||
kanbanStore.ensureTaskIsInCorrectBucket(updatedTask)
|
bucketStore.ensureTaskIsInCorrectBucket(updatedTask)
|
||||||
lastUpdatedTask.value = updatedTask
|
lastUpdatedTask.value = updatedTask
|
||||||
return updatedTask
|
return updatedTask
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -195,7 +195,7 @@ export const useTaskStore = defineStore('task', () => {
|
||||||
async function deleteTask(task: ITask) {
|
async function deleteTask(task: ITask) {
|
||||||
const taskService = new TaskService()
|
const taskService = new TaskService()
|
||||||
const response = await taskService.delete(task)
|
const response = await taskService.delete(task)
|
||||||
kanbanStore.removeTaskInBucket(task)
|
bucketStore.removeTaskInBucket(task)
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -208,7 +208,7 @@ export const useTaskStore = defineStore('task', () => {
|
||||||
taskId: ITask['id']
|
taskId: ITask['id']
|
||||||
attachment: IAttachment
|
attachment: IAttachment
|
||||||
}) {
|
}) {
|
||||||
const t = kanbanStore.getTaskById(taskId)
|
const t = bucketStore.getTaskById(taskId)
|
||||||
if (t.task !== null) {
|
if (t.task !== null) {
|
||||||
const attachments = [
|
const attachments = [
|
||||||
...t.task.attachments,
|
...t.task.attachments,
|
||||||
|
|
@ -222,7 +222,7 @@ export const useTaskStore = defineStore('task', () => {
|
||||||
attachments,
|
attachments,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
kanbanStore.setTaskInBucketByIndex(newTask)
|
bucketStore.setTaskInBucketByIndex(newTask)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -241,7 +241,7 @@ export const useTaskStore = defineStore('task', () => {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
taskId: taskId,
|
taskId: taskId,
|
||||||
}))
|
}))
|
||||||
const t = kanbanStore.getTaskById(taskId)
|
const t = bucketStore.getTaskById(taskId)
|
||||||
if (t.task === null) {
|
if (t.task === null) {
|
||||||
// Don't try further adding a label if the task is not in kanban
|
// Don't try further adding a label if the task is not in kanban
|
||||||
// Usually this means the kanban board hasn't been accessed until now.
|
// Usually this means the kanban board hasn't been accessed until now.
|
||||||
|
|
@ -250,7 +250,7 @@ export const useTaskStore = defineStore('task', () => {
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
kanbanStore.setTaskInBucketByIndex({
|
bucketStore.setTaskInBucketByIndex({
|
||||||
...t,
|
...t,
|
||||||
task: {
|
task: {
|
||||||
...t.task,
|
...t.task,
|
||||||
|
|
@ -279,7 +279,7 @@ export const useTaskStore = defineStore('task', () => {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
taskId: taskId,
|
taskId: taskId,
|
||||||
}))
|
}))
|
||||||
const t = kanbanStore.getTaskById(taskId)
|
const t = bucketStore.getTaskById(taskId)
|
||||||
if (t.task === null) {
|
if (t.task === null) {
|
||||||
// Don't try further adding a label if the task is not in kanban
|
// Don't try further adding a label if the task is not in kanban
|
||||||
// Usually this means the kanban board hasn't been accessed until now.
|
// Usually this means the kanban board hasn't been accessed until now.
|
||||||
|
|
@ -290,7 +290,7 @@ export const useTaskStore = defineStore('task', () => {
|
||||||
|
|
||||||
const assignees = t.task.assignees.filter(({ id }) => id !== user.id)
|
const assignees = t.task.assignees.filter(({ id }) => id !== user.id)
|
||||||
|
|
||||||
kanbanStore.setTaskInBucketByIndex({
|
bucketStore.setTaskInBucketByIndex({
|
||||||
...t,
|
...t,
|
||||||
task: {
|
task: {
|
||||||
...t.task,
|
...t.task,
|
||||||
|
|
@ -313,7 +313,7 @@ export const useTaskStore = defineStore('task', () => {
|
||||||
taskId,
|
taskId,
|
||||||
labelId: label.id,
|
labelId: label.id,
|
||||||
}))
|
}))
|
||||||
const t = kanbanStore.getTaskById(taskId)
|
const t = bucketStore.getTaskById(taskId)
|
||||||
if (t.task === null) {
|
if (t.task === null) {
|
||||||
// Don't try further adding a label if the task is not in kanban
|
// Don't try further adding a label if the task is not in kanban
|
||||||
// Usually this means the kanban board hasn't been accessed until now.
|
// Usually this means the kanban board hasn't been accessed until now.
|
||||||
|
|
@ -322,7 +322,7 @@ export const useTaskStore = defineStore('task', () => {
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
kanbanStore.setTaskInBucketByIndex({
|
bucketStore.setTaskInBucketByIndex({
|
||||||
...t,
|
...t,
|
||||||
task: {
|
task: {
|
||||||
...t.task,
|
...t.task,
|
||||||
|
|
@ -345,7 +345,7 @@ export const useTaskStore = defineStore('task', () => {
|
||||||
taskId, labelId:
|
taskId, labelId:
|
||||||
label.id,
|
label.id,
|
||||||
}))
|
}))
|
||||||
const t = kanbanStore.getTaskById(taskId)
|
const t = bucketStore.getTaskById(taskId)
|
||||||
if (t.task === null) {
|
if (t.task === null) {
|
||||||
// Don't try further adding a label if the task is not in kanban
|
// Don't try further adding a label if the task is not in kanban
|
||||||
// Usually this means the kanban board hasn't been accessed until now.
|
// Usually this means the kanban board hasn't been accessed until now.
|
||||||
|
|
@ -357,7 +357,7 @@ export const useTaskStore = defineStore('task', () => {
|
||||||
// Remove the label from the project
|
// Remove the label from the project
|
||||||
const labels = t.task.labels.filter(({ id }) => id !== label.id)
|
const labels = t.task.labels.filter(({ id }) => id !== label.id)
|
||||||
|
|
||||||
kanbanStore.setTaskInBucketByIndex({
|
bucketStore.setTaskInBucketByIndex({
|
||||||
...t,
|
...t,
|
||||||
task: {
|
task: {
|
||||||
...t.task,
|
...t.task,
|
||||||
|
|
@ -558,9 +558,9 @@ export const useTaskStore = defineStore('task', () => {
|
||||||
const taskService = new TaskService()
|
const taskService = new TaskService()
|
||||||
await taskService.markTaskAsRead(taskId)
|
await taskService.markTaskAsRead(taskId)
|
||||||
|
|
||||||
const t = kanbanStore.getTaskById(taskId)
|
const t = bucketStore.getTaskById(taskId)
|
||||||
if (t.task !== null) {
|
if (t.task !== null) {
|
||||||
kanbanStore.setTaskInBucket({
|
bucketStore.setTaskInBucket({
|
||||||
...t.task,
|
...t.task,
|
||||||
isUnread: false,
|
isUnread: false,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,7 @@ async function createView() {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
newView.value.bucketConfigurationMode = newView.value.viewKind === 'kanban'
|
newView.value.bucketConfigurationMode = (newView.value.viewKind === 'kanban' || newView.value.viewKind === 'list')
|
||||||
? newView.value.bucketConfigurationMode
|
? newView.value.bucketConfigurationMode
|
||||||
: 'none'
|
: 'none'
|
||||||
newView.value.projectId = props.projectId
|
newView.value.projectId = props.projectId
|
||||||
|
|
@ -96,7 +96,7 @@ async function deleteView(viewId: number) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveView(view: IProjectView) {
|
async function saveView(view: IProjectView) {
|
||||||
if (view?.viewKind !== 'kanban') {
|
if (view?.viewKind !== 'kanban' && view?.viewKind !== 'list') {
|
||||||
view.bucketConfigurationMode = 'none'
|
view.bucketConfigurationMode = 'none'
|
||||||
}
|
}
|
||||||
const result = await projectViewService.update(view)
|
const result = await projectViewService.update(view)
|
||||||
|
|
|
||||||
|
|
@ -677,7 +677,7 @@ import {REMINDER_PERIOD_RELATIVE_TO_TYPES} from '@/types/IReminderPeriodRelative
|
||||||
import {playPopSound} from '@/helpers/playPop'
|
import {playPopSound} from '@/helpers/playPop'
|
||||||
|
|
||||||
import {useTaskStore} from '@/stores/tasks'
|
import {useTaskStore} from '@/stores/tasks'
|
||||||
import {useKanbanStore} from '@/stores/kanban'
|
import {useBucketStore} from '@/stores/buckets'
|
||||||
import {useProjectStore} from '@/stores/projects'
|
import {useProjectStore} from '@/stores/projects'
|
||||||
import {useAuthStore} from '@/stores/auth'
|
import {useAuthStore} from '@/stores/auth'
|
||||||
import {useBaseStore} from '@/stores/base'
|
import {useBaseStore} from '@/stores/base'
|
||||||
|
|
@ -703,7 +703,7 @@ const {t} = useI18n({useScope: 'global'})
|
||||||
|
|
||||||
const projectStore = useProjectStore()
|
const projectStore = useProjectStore()
|
||||||
const taskStore = useTaskStore()
|
const taskStore = useTaskStore()
|
||||||
const kanbanStore = useKanbanStore()
|
const bucketStore = useBucketStore()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const baseStore = useBaseStore()
|
const baseStore = useBaseStore()
|
||||||
|
|
||||||
|
|
@ -821,7 +821,7 @@ async function attachmentUpload(file: File, onSuccess?: (url: string) => void) {
|
||||||
|
|
||||||
function onAttachmentsUpdated(attachments: IAttachment[]) {
|
function onAttachmentsUpdated(attachments: IAttachment[]) {
|
||||||
task.value.attachments = attachments
|
task.value.attachments = attachments
|
||||||
kanbanStore.setTaskInBucket({
|
bucketStore.setTaskInBucket({
|
||||||
...task.value,
|
...task.value,
|
||||||
attachments,
|
attachments,
|
||||||
})
|
})
|
||||||
|
|
@ -1121,7 +1121,7 @@ async function changeProject(project: IProject | null) {
|
||||||
if (project === null) {
|
if (project === null) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
kanbanStore.removeTaskInBucket(task.value)
|
bucketStore.removeTaskInBucket(task.value)
|
||||||
await saveTask({
|
await saveTask({
|
||||||
...task.value,
|
...task.value,
|
||||||
projectId: project.id,
|
projectId: project.id,
|
||||||
|
|
|
||||||
|
|
@ -250,3 +250,25 @@
|
||||||
position: 10
|
position: 10
|
||||||
created: 2020-04-18 21:13:52
|
created: 2020-04-18 21:13:52
|
||||||
updated: 2020-04-18 21:13:52
|
updated: 2020-04-18 21:13:52
|
||||||
|
# Buckets for list view with manual bucket configuration (project_view 161)
|
||||||
|
- id: 41
|
||||||
|
title: Backlog
|
||||||
|
project_view_id: 161
|
||||||
|
created_by_id: 1
|
||||||
|
position: 1
|
||||||
|
created: 2020-04-18 21:13:52
|
||||||
|
updated: 2020-04-18 21:13:52
|
||||||
|
- id: 42
|
||||||
|
title: In Progress
|
||||||
|
project_view_id: 161
|
||||||
|
created_by_id: 1
|
||||||
|
position: 2
|
||||||
|
created: 2020-04-18 21:13:52
|
||||||
|
updated: 2020-04-18 21:13:52
|
||||||
|
- id: 43
|
||||||
|
title: Review
|
||||||
|
project_view_id: 161
|
||||||
|
created_by_id: 1
|
||||||
|
position: 3
|
||||||
|
created: 2020-04-18 21:13:52
|
||||||
|
updated: 2020-04-18 21:13:52
|
||||||
|
|
|
||||||
|
|
@ -1007,3 +1007,13 @@
|
||||||
updated: '2024-03-18 15:14:13'
|
updated: '2024-03-18 15:14:13'
|
||||||
created: '2018-03-18 15:14:13'
|
created: '2018-03-18 15:14:13'
|
||||||
bucket_configuration_mode: 1
|
bucket_configuration_mode: 1
|
||||||
|
# List view with manual bucket configuration (for testing list view sections)
|
||||||
|
- id: 161
|
||||||
|
title: Sectioned List
|
||||||
|
project_id: 1
|
||||||
|
view_kind: 0
|
||||||
|
bucket_configuration_mode: 1
|
||||||
|
default_bucket_id: 41
|
||||||
|
position: 5
|
||||||
|
updated: '2024-03-18 15:14:13'
|
||||||
|
created: '2018-03-18 15:14:13'
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,16 @@
|
||||||
#- task_id: 39
|
#- task_id: 39
|
||||||
# project_view_id: 100
|
# project_view_id: 100
|
||||||
# bucket_id: null
|
# bucket_id: null
|
||||||
|
# Task-bucket mappings for list view with manual buckets (project_view 161)
|
||||||
|
- task_id: 1
|
||||||
|
project_view_id: 161
|
||||||
|
bucket_id: 41
|
||||||
|
- task_id: 2
|
||||||
|
project_view_id: 161
|
||||||
|
bucket_id: 42
|
||||||
|
- task_id: 3
|
||||||
|
project_view_id: 161
|
||||||
|
bucket_id: 43
|
||||||
- task_id: 40
|
- task_id: 40
|
||||||
project_view_id: 144
|
project_view_id: 144
|
||||||
bucket_id: 38
|
bucket_id: 38
|
||||||
|
|
|
||||||
|
|
@ -312,7 +312,7 @@ func createProjectView(s *xorm.Session, p *ProjectView, a web.Auth, createBacklo
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if p.ViewKind == ProjectViewKindKanban && createBacklogBucket && p.BucketConfigurationMode == BucketConfigurationModeManual {
|
if createBacklogBucket && p.BucketConfigurationMode == BucketConfigurationModeManual {
|
||||||
// Create default buckets for kanban view
|
// Create default buckets for kanban view
|
||||||
backlog := &Bucket{
|
backlog := &Bucket{
|
||||||
ProjectViewID: p.ID,
|
ProjectViewID: p.ID,
|
||||||
|
|
@ -344,9 +344,11 @@ func createProjectView(s *xorm.Session, p *ProjectView, a web.Auth, createBacklo
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set Backlog as default bucket and Done as done bucket
|
// Set Backlog as default bucket and Done as done bucket (only kanban views use done buckets)
|
||||||
p.DefaultBucketID = backlog.ID
|
p.DefaultBucketID = backlog.ID
|
||||||
|
if p.ViewKind == ProjectViewKindKanban {
|
||||||
p.DoneBucketID = done.ID
|
p.DoneBucketID = done.ID
|
||||||
|
}
|
||||||
_, err = s.ID(p.ID).Cols("default_bucket_id", "done_bucket_id").Update(p)
|
_, err = s.ID(p.ID).Cols("default_bucket_id", "done_bucket_id").Update(p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -1034,7 +1034,6 @@ func setTaskInBucketInViews(s *xorm.Session, t *Task, a web.Auth, setBucket bool
|
||||||
|
|
||||||
for _, view := range views {
|
for _, view := range views {
|
||||||
if setBucket && !moveToDone &&
|
if setBucket && !moveToDone &&
|
||||||
view.ViewKind == ProjectViewKindKanban &&
|
|
||||||
view.BucketConfigurationMode == BucketConfigurationModeManual {
|
view.BucketConfigurationMode == BucketConfigurationModeManual {
|
||||||
|
|
||||||
bucketID := view.DoneBucketID
|
bucketID := view.DoneBucketID
|
||||||
|
|
|
||||||
|
|
@ -158,6 +158,31 @@ func TestTask_Create(t *testing.T) {
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
assert.True(t, user.IsErrUserDoesNotExist(err))
|
assert.True(t, user.IsErrUserDoesNotExist(err))
|
||||||
})
|
})
|
||||||
|
t.Run("task assigned to list view with manual buckets", func(t *testing.T) {
|
||||||
|
db.LoadAndAssertFixtures(t)
|
||||||
|
s := db.NewSession()
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
task := &Task{
|
||||||
|
Title: "Test list view bucket assignment",
|
||||||
|
ProjectID: 1,
|
||||||
|
}
|
||||||
|
err := task.Create(s, usr)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, s.Commit())
|
||||||
|
|
||||||
|
// Task should be assigned to default bucket of the list view with manual buckets (view 161, bucket 41)
|
||||||
|
db.AssertExists(t, "task_buckets", map[string]interface{}{
|
||||||
|
"task_id": task.ID,
|
||||||
|
"bucket_id": 41,
|
||||||
|
"project_view_id": 161,
|
||||||
|
}, false)
|
||||||
|
// Task should also still be assigned to the kanban view's default bucket
|
||||||
|
db.AssertExists(t, "task_buckets", map[string]interface{}{
|
||||||
|
"task_id": task.ID,
|
||||||
|
"bucket_id": 1,
|
||||||
|
}, false)
|
||||||
|
})
|
||||||
t.Run("default bucket different", func(t *testing.T) {
|
t.Run("default bucket different", func(t *testing.T) {
|
||||||
db.LoadAndAssertFixtures(t)
|
db.LoadAndAssertFixtures(t)
|
||||||
s := db.NewSession()
|
s := db.NewSession()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue