Compare commits

...

10 Commits

15 changed files with 674 additions and 125 deletions

View File

@ -304,7 +304,7 @@ import type {ITask} from '@/modelTypes/ITask'
import {useBaseStore} from '@/stores/base'
import {useTaskStore} from '@/stores/tasks'
import {useKanbanStore} from '@/stores/kanban'
import {useBucketStore} from '@/stores/buckets'
import {useAuthStore} from '@/stores/auth'
import ProjectWrapper from '@/components/project/ProjectWrapper.vue'
@ -356,7 +356,7 @@ const MIN_SCROLL_HEIGHT_PERCENT = 0.25
const {t} = useI18n({useScope: 'global'})
const baseStore = useBaseStore()
const kanbanStore = useKanbanStore()
const bucketStore = useBucketStore()
const taskStore = useTaskStore()
const projectStore = useProjectStore()
const authStore = useAuthStore()
@ -480,8 +480,8 @@ function onHandleTouchMove(e: TouchEvent) {
}
}
const buckets = computed(() => kanbanStore.buckets)
const loading = computed(() => kanbanStore.isLoading)
const buckets = computed(() => bucketStore.buckets)
const loading = computed(() => bucketStore.isLoading)
const projectIdWithFallback = computed<number>(() => project.value?.id || projectId.value)
const taskLoading = computed(() => taskStore.isLoading || taskPositionService.value.loading)
@ -497,7 +497,7 @@ watch(
return
}
collapsedBuckets.value = getCollapsedBucketState(projectId)
kanbanStore.loadBucketsForProject(projectId, viewId, params)
bucketStore.loadBucketsForProject(projectId, viewId, params)
},
{
immediate: true,
@ -520,7 +520,7 @@ function handleTaskContainerScroll(id: IBucket['id'], el: HTMLElement) {
return
}
kanbanStore.loadNextTasksForBucket(
bucketStore.loadNextTasksForBucket(
projectId.value,
props.viewId,
params.value,
@ -529,13 +529,13 @@ function handleTaskContainerScroll(id: IBucket['id'], el: HTMLElement) {
}
function updateTasks(bucketId: IBucket['id'], tasks: IBucket['tasks']) {
const bucket = kanbanStore.getBucketById(bucketId)
const bucket = bucketStore.getBucketById(bucketId)
if (bucket === undefined) {
return
}
kanbanStore.setBucketById({
bucketStore.setBucketById({
...bucket,
tasks,
})
@ -546,7 +546,7 @@ async function updateTaskPosition(e) {
// Check if dropped on a sidebar project
const {moved} = await handleTaskDropToProject(e, (task) => {
kanbanStore.removeTaskInBucket(task)
bucketStore.removeTaskInBucket(task)
})
if (moved) {
@ -596,11 +596,11 @@ async function updateTaskPosition(e) {
oldBucket !== undefined && // This shouldn't actually be `undefined`, but let's play it safe.
newBucket.id !== oldBucket.id
) {
kanbanStore.setBucketById({
bucketStore.setBucketById({
...oldBucket,
count: oldBucket.count - 1,
})
kanbanStore.setBucketById({
bucketStore.setBucketById({
...newBucket,
count: newBucket.count + 1,
})
@ -626,13 +626,13 @@ async function updateTaskPosition(e) {
Object.assign(newTask, updatedTaskBucket.task)
newTask.bucketId = updatedTaskBucket.bucketId
if (updatedTaskBucket.bucketId !== newTask.bucketId) {
kanbanStore.moveTaskToBucket(newTask, updatedTaskBucket.bucketId)
bucketStore.moveTaskToBucket(newTask, updatedTaskBucket.bucketId)
}
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
if (newTaskIndex === 0 && taskAfter !== null && taskAfter.position === 0) {
@ -675,10 +675,10 @@ async function addTaskToBucket(bucketId: IBucket['id']) {
projectId: projectIdWithFallback.value,
})
newTaskText.value = ''
kanbanStore.addTaskToBucket(task)
bucketStore.addTaskToBucket(task)
scrollTaskContainerToTop(bucketId)
const bucket = kanbanStore.getBucketById(bucketId)
const bucket = bucketStore.getBucketById(bucketId)
if (bucket && bucket.limit && bucket.count >= bucket.limit) {
toggleShowNewTaskInput(bucketId)
}
@ -697,7 +697,7 @@ async function createNewBucket() {
return
}
await kanbanStore.createBucket(new BucketModel({
await bucketStore.createBucket(new BucketModel({
title: newBucketTitle.value,
projectId: projectIdWithFallback.value,
projectViewId: props.viewId,
@ -716,7 +716,7 @@ function deleteBucketModal(bucketId: IBucket['id']) {
async function deleteBucket() {
try {
await kanbanStore.deleteBucket({
await bucketStore.deleteBucket({
bucket: new BucketModel({
id: bucketToDelete.value,
projectId: projectIdWithFallback.value,
@ -740,13 +740,13 @@ async function focusBucketTitle(e: Event) {
async function saveBucketTitle(bucketId: IBucket['id'], bucketTitle: string) {
const bucket = kanbanStore.getBucketById(bucketId)
const bucket = bucketStore.getBucketById(bucketId)
if (bucket?.title === bucketTitle) {
bucketTitleEditable.value = false
return
}
await kanbanStore.updateBucket({
await bucketStore.updateBucket({
id: bucketId,
title: bucketTitle,
projectId: projectId.value,
@ -757,7 +757,7 @@ async function saveBucketTitle(bucketId: IBucket['id'], bucketTitle: string) {
function updateBuckets(value: IBucket[]) {
// (1) buckets get updated in store and tasks positions get invalidated
kanbanStore.setBuckets(value)
bucketStore.setBuckets(value)
}
function handleRecurringTaskCompletion() {
@ -772,7 +772,7 @@ function handleRecurringTaskCompletion() {
if (filterContainsDateFields) {
// 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 bucketAfter = buckets.value[e.newIndex + 1] ?? null
kanbanStore.updateBucket({
bucketStore.updateBucket({
id: bucket.id,
projectId: projectId.value,
position: calculateItemPosition(
@ -800,8 +800,8 @@ async function saveBucketLimit(bucketId: IBucket['id'], limit: number) {
return
}
await kanbanStore.updateBucket({
...kanbanStore.getBucketById(bucketId),
await bucketStore.updateBucket({
...bucketStore.getBucketById(bucketId),
projectId: projectId.value,
limit,
})

View File

@ -22,7 +22,7 @@
<template #default>
<div
:class="{ 'is-loading': loading }"
:class="{ 'is-loading': hasBuckets ? bucketStore.isLoading : loading }"
class="loader-container is-max-width-desktop list-view"
>
<Card
@ -30,6 +30,188 @@
:has-content="false"
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
v-if="!project?.isArchived && canWrite"
ref="addTaskRef"
@ -93,6 +275,7 @@
:total-pages="totalPages"
:current-page="currentPage"
/>
</template>
</Card>
</div>
</template>
@ -102,6 +285,7 @@
<script setup lang="ts">
import {ref, computed, nextTick, onMounted, onBeforeUnmount, watch, toRef} from 'vue'
import {useI18n} from 'vue-i18n'
import draggable from 'zhyswan-vuedraggable'
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 Pagination from '@/components/misc/Pagination.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 {useTaskDragToProject} from '@/composables/useTaskDragToProject'
import {shouldShowTaskInListView} from '@/composables/useTaskListFiltering'
import {PERMISSIONS as Permissions} from '@/constants/permissions'
import {calculateItemPosition} from '@/helpers/calculateItemPosition'
import {
type CollapsedBuckets,
getCollapsedBucketState,
saveCollapsedBucketState,
} from '@/helpers/saveCollapsedBucketState'
import type {ITask} from '@/modelTypes/ITask'
import type {IBucket} from '@/modelTypes/IBucket'
import {isSavedFilter, useSavedFilter} from '@/services/savedFilter'
import {success} from '@/message'
import {useBaseStore} from '@/stores/base'
import {useTaskStore} from '@/stores/tasks'
import {useBucketStore} from '@/stores/buckets'
import {useProjectStore} from '@/stores/projects'
import type {IProject} from '@/modelTypes/IProject'
import type {IProjectView} from '@/modelTypes/IProjectView'
import TaskPositionService from '@/services/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<{
isLoadingProject: boolean,
@ -139,6 +340,8 @@ const projectId = toRef(props, 'projectId')
defineOptions({name: 'List'})
const {t} = useI18n({useScope: 'global'})
const ctaVisible = ref(false)
const drag = ref(false)
@ -177,6 +380,14 @@ watch(
const isPositionSorting = computed(() => 'position' in sortByParam.value)
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) {
return 0
}
@ -186,6 +397,8 @@ const firstNewPosition = computed(() => {
const baseStore = useBaseStore()
const taskStore = useTaskStore()
const bucketStore = useBucketStore()
const projectStore = useProjectStore()
const {handleTaskDropToProject} = useTaskDragToProject()
const project = computed(() => baseStore.currentProject)
@ -214,7 +427,184 @@ function focusNewTaskInput() {
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) {
if (hasBuckets.value) {
bucketStore.addTaskToBucket(task)
baseStore.setHasTasks(true)
return
}
if (!isPositionSorting.value) {
// reload tasks with current filter and sorting
loadTasks()
@ -246,7 +636,8 @@ function updateTasks(updatedTask: ITask) {
function handleDragStart(e: { item: HTMLElement }) {
drag.value = true
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) {
taskStore.setDraggedTask(task)
@ -421,4 +812,84 @@ onBeforeUnmount(() => {
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>

View File

@ -177,7 +177,7 @@ function handleBubbleSave() {
</div>
<div
v-if="view.viewKind === 'kanban'"
v-if="view.viewKind === 'kanban' || view.viewKind === 'list'"
class="field"
>
<label
@ -212,7 +212,7 @@ function handleBubbleSave() {
</div>
<div
v-if="view.viewKind === 'kanban' && view.bucketConfigurationMode === 'filter'"
v-if="(view.viewKind === 'kanban' || view.viewKind === 'list') && view.bucketConfigurationMode === 'filter'"
class="field"
>
<label class="label">

View File

@ -44,7 +44,7 @@ import type {IBucket} from '@/modelTypes/IBucket'
import {PROJECT_VIEW_KINDS} from '@/modelTypes/IProjectView'
import {useProjectStore} from '@/stores/projects'
import {useKanbanStore} from '@/stores/kanban'
import {useBucketStore} from '@/stores/buckets'
import {useBaseStore} from '@/stores/base'
import BaseButton from '@/components/base/BaseButton.vue'
@ -69,30 +69,30 @@ const emit = defineEmits<{
const {t} = useI18n({useScope: 'global'})
const projectStore = useProjectStore()
const kanbanStore = useKanbanStore()
const bucketStore = useBucketStore()
const baseStore = useBaseStore()
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.
const kanbanView = computed(() => {
if (!project.value?.views) {
return null
}
const manualKanbanViews = project.value.views.filter(
v => v.viewKind === PROJECT_VIEW_KINDS.KANBAN
const manualBucketViews = project.value.views.filter(
v => (v.viewKind === PROJECT_VIEW_KINDS.KANBAN || v.viewKind === PROJECT_VIEW_KINDS.LIST)
&& v.bucketConfigurationMode === 'manual',
)
if (manualKanbanViews.length === 1) {
return manualKanbanViews[0]
if (manualBucketViews.length === 1) {
return manualBucketViews[0]
}
if (manualKanbanViews.length > 1) {
if (manualBucketViews.length > 1) {
const activeViewId = baseStore.currentProjectViewId
return manualKanbanViews.find(v => v.id === activeViewId) || null
return manualBucketViews.find(v => v.id === activeViewId) || null
}
return null
@ -156,7 +156,7 @@ async function changeBucket(bucket: IBucket) {
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
// done bucket can toggle it. Spreading the full response task would

View File

@ -441,7 +441,17 @@
"empty": "This project is currently empty.",
"newTaskCta": "Create a 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": {
"title": "Gantt",

View File

@ -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.
*/
export const useKanbanStore = defineStore('kanban', () => {
export const useBucketStore = defineStore('buckets', () => {
const authStore = useAuthStore()
const baseStore = useBaseStore()
@ -399,5 +399,5 @@ export const useKanbanStore = defineStore('kanban', () => {
// support hot reloading
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useKanbanStore, import.meta.hot))
import.meta.hot.accept(acceptHMRUpdate(useBucketStore, import.meta.hot))
}

View File

@ -29,7 +29,7 @@ import {REMINDER_PERIOD_RELATIVE_TO_TYPES} from '@/types/IReminderPeriodRelative
import {setModuleLoading} from '@/stores/helper'
import {useLabelStore} from '@/stores/labels'
import {useProjectStore} from '@/stores/projects'
import {useKanbanStore} from '@/stores/kanban'
import {useBucketStore} from '@/stores/buckets'
import {useBaseStore} from '@/stores/base'
import ProjectUserService from '@/services/projectUsers'
import {useAuthStore} from '@/stores/auth'
@ -127,7 +127,7 @@ async function findAssignees(parsedTaskAssignees: string[], projectId: number):
export const useTaskStore = defineStore('task', () => {
const baseStore = useBaseStore()
const kanbanStore = useKanbanStore()
const bucketStore = useBucketStore()
const labelStore = useLabelStore()
const projectStore = useProjectStore()
const authStore = useAuthStore()
@ -184,7 +184,7 @@ export const useTaskStore = defineStore('task', () => {
const taskService = new TaskService()
try {
const updatedTask = await taskService.update(task)
kanbanStore.ensureTaskIsInCorrectBucket(updatedTask)
bucketStore.ensureTaskIsInCorrectBucket(updatedTask)
lastUpdatedTask.value = updatedTask
return updatedTask
} finally {
@ -195,7 +195,7 @@ export const useTaskStore = defineStore('task', () => {
async function deleteTask(task: ITask) {
const taskService = new TaskService()
const response = await taskService.delete(task)
kanbanStore.removeTaskInBucket(task)
bucketStore.removeTaskInBucket(task)
return response
}
@ -208,7 +208,7 @@ export const useTaskStore = defineStore('task', () => {
taskId: ITask['id']
attachment: IAttachment
}) {
const t = kanbanStore.getTaskById(taskId)
const t = bucketStore.getTaskById(taskId)
if (t.task !== null) {
const attachments = [
...t.task.attachments,
@ -222,7 +222,7 @@ export const useTaskStore = defineStore('task', () => {
attachments,
},
}
kanbanStore.setTaskInBucketByIndex(newTask)
bucketStore.setTaskInBucketByIndex(newTask)
}
}
@ -241,7 +241,7 @@ export const useTaskStore = defineStore('task', () => {
userId: user.id,
taskId: taskId,
}))
const t = kanbanStore.getTaskById(taskId)
const t = bucketStore.getTaskById(taskId)
if (t.task === null) {
// 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.
@ -250,7 +250,7 @@ export const useTaskStore = defineStore('task', () => {
return r
}
kanbanStore.setTaskInBucketByIndex({
bucketStore.setTaskInBucketByIndex({
...t,
task: {
...t.task,
@ -279,7 +279,7 @@ export const useTaskStore = defineStore('task', () => {
userId: user.id,
taskId: taskId,
}))
const t = kanbanStore.getTaskById(taskId)
const t = bucketStore.getTaskById(taskId)
if (t.task === null) {
// 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.
@ -290,7 +290,7 @@ export const useTaskStore = defineStore('task', () => {
const assignees = t.task.assignees.filter(({ id }) => id !== user.id)
kanbanStore.setTaskInBucketByIndex({
bucketStore.setTaskInBucketByIndex({
...t,
task: {
...t.task,
@ -313,7 +313,7 @@ export const useTaskStore = defineStore('task', () => {
taskId,
labelId: label.id,
}))
const t = kanbanStore.getTaskById(taskId)
const t = bucketStore.getTaskById(taskId)
if (t.task === null) {
// 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.
@ -322,7 +322,7 @@ export const useTaskStore = defineStore('task', () => {
return r
}
kanbanStore.setTaskInBucketByIndex({
bucketStore.setTaskInBucketByIndex({
...t,
task: {
...t.task,
@ -345,7 +345,7 @@ export const useTaskStore = defineStore('task', () => {
taskId, labelId:
label.id,
}))
const t = kanbanStore.getTaskById(taskId)
const t = bucketStore.getTaskById(taskId)
if (t.task === null) {
// 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.
@ -357,7 +357,7 @@ export const useTaskStore = defineStore('task', () => {
// Remove the label from the project
const labels = t.task.labels.filter(({ id }) => id !== label.id)
kanbanStore.setTaskInBucketByIndex({
bucketStore.setTaskInBucketByIndex({
...t,
task: {
...t.task,
@ -558,9 +558,9 @@ export const useTaskStore = defineStore('task', () => {
const taskService = new TaskService()
await taskService.markTaskAsRead(taskId)
const t = kanbanStore.getTaskById(taskId)
const t = bucketStore.getTaskById(taskId)
if (t.task !== null) {
kanbanStore.setTaskInBucket({
bucketStore.setTaskInBucket({
...t.task,
isUnread: false,
})

View File

@ -65,7 +65,7 @@ async function createView() {
}
try {
newView.value.bucketConfigurationMode = newView.value.viewKind === 'kanban'
newView.value.bucketConfigurationMode = (newView.value.viewKind === 'kanban' || newView.value.viewKind === 'list')
? newView.value.bucketConfigurationMode
: 'none'
newView.value.projectId = props.projectId
@ -96,7 +96,7 @@ async function deleteView(viewId: number) {
}
async function saveView(view: IProjectView) {
if (view?.viewKind !== 'kanban') {
if (view?.viewKind !== 'kanban' && view?.viewKind !== 'list') {
view.bucketConfigurationMode = 'none'
}
const result = await projectViewService.update(view)

View File

@ -677,7 +677,7 @@ import {REMINDER_PERIOD_RELATIVE_TO_TYPES} from '@/types/IReminderPeriodRelative
import {playPopSound} from '@/helpers/playPop'
import {useTaskStore} from '@/stores/tasks'
import {useKanbanStore} from '@/stores/kanban'
import {useBucketStore} from '@/stores/buckets'
import {useProjectStore} from '@/stores/projects'
import {useAuthStore} from '@/stores/auth'
import {useBaseStore} from '@/stores/base'
@ -703,7 +703,7 @@ const {t} = useI18n({useScope: 'global'})
const projectStore = useProjectStore()
const taskStore = useTaskStore()
const kanbanStore = useKanbanStore()
const bucketStore = useBucketStore()
const authStore = useAuthStore()
const baseStore = useBaseStore()
@ -821,7 +821,7 @@ async function attachmentUpload(file: File, onSuccess?: (url: string) => void) {
function onAttachmentsUpdated(attachments: IAttachment[]) {
task.value.attachments = attachments
kanbanStore.setTaskInBucket({
bucketStore.setTaskInBucket({
...task.value,
attachments,
})
@ -1121,7 +1121,7 @@ async function changeProject(project: IProject | null) {
if (project === null) {
return
}
kanbanStore.removeTaskInBucket(task.value)
bucketStore.removeTaskInBucket(task.value)
await saveTask({
...task.value,
projectId: project.id,

View File

@ -250,3 +250,25 @@
position: 10
created: 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

View File

@ -1007,3 +1007,13 @@
updated: '2024-03-18 15:14:13'
created: '2018-03-18 15:14:13'
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'

View File

@ -115,6 +115,16 @@
#- task_id: 39
# project_view_id: 100
# 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
project_view_id: 144
bucket_id: 38

View File

@ -312,7 +312,7 @@ func createProjectView(s *xorm.Session, p *ProjectView, a web.Auth, createBacklo
return
}
if p.ViewKind == ProjectViewKindKanban && createBacklogBucket && p.BucketConfigurationMode == BucketConfigurationModeManual {
if createBacklogBucket && p.BucketConfigurationMode == BucketConfigurationModeManual {
// Create default buckets for kanban view
backlog := &Bucket{
ProjectViewID: p.ID,
@ -344,9 +344,11 @@ func createProjectView(s *xorm.Session, p *ProjectView, a web.Auth, createBacklo
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
if p.ViewKind == ProjectViewKindKanban {
p.DoneBucketID = done.ID
}
_, err = s.ID(p.ID).Cols("default_bucket_id", "done_bucket_id").Update(p)
if err != nil {
return

View File

@ -1034,7 +1034,6 @@ func setTaskInBucketInViews(s *xorm.Session, t *Task, a web.Auth, setBucket bool
for _, view := range views {
if setBucket && !moveToDone &&
view.ViewKind == ProjectViewKindKanban &&
view.BucketConfigurationMode == BucketConfigurationModeManual {
bucketID := view.DoneBucketID

View File

@ -158,6 +158,31 @@ func TestTask_Create(t *testing.T) {
require.Error(t, 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) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()