Extract StatusManager, dedupe ColumnChange type, add useDialog composable

- Pull the status-management feature (drag reorder, add, delete with
  reassignment) out of ProjectConfigureView into its own StatusManager
  component. It fetches its own status list independently, so the view
  is left with just page chrome and the rename form (316 -> 106 lines).
- Define ColumnChange once in lib/cardOrder.ts instead of duplicating
  the same type in AppSidebar.vue and ProjectView.vue.
- Add composables/useDialog.ts for the Escape-to-close + focus-on-open
  behaviour shared by every confirm/reassign modal (and, without a
  focus target, plain dropdown menus). Used by ProjectManageMenu's
  delete-confirmation modal + its own menu, and StatusManager's
  reassignment modal.
This commit is contained in:
2026-09-04 23:35:33 +01:00
parent a3462af005
commit 83471e55d4
7 changed files with 280 additions and 242 deletions
+1 -6
View File
@@ -3,7 +3,7 @@ import { computed, onMounted, ref } from 'vue'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import draggable from 'vuedraggable'
import { ApiError } from '../lib/api'
import { reorderColumn } from '../lib/cardOrder'
import { reorderColumn, type ColumnChange } from '../lib/cardOrder'
import { useCardsStore } from '../stores/cards'
import { useInboxStore } from '../stores/inbox'
import { useProjectsStore } from '../stores/projects'
@@ -52,11 +52,6 @@ const newInboxText = ref('')
const addingToInbox = ref(false)
const inboxError = ref<string | null>(null)
type ColumnChange = {
added?: { element: Card; newIndex: number }
moved?: { element: Card; oldIndex: number; newIndex: number }
}
async function loadInbox() {
inboxError.value = null
try {
+7 -15
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useDialog } from '../composables/useDialog'
import { ApiError, apiRequest } from '../lib/api'
import { useCardsStore } from '../stores/cards'
import { useProjectsStore } from '../stores/projects'
@@ -45,20 +46,11 @@ async function confirmDelete() {
}
}
watch(confirmingDelete, async (open) => {
if (open) {
await nextTick()
cancelButton.value?.focus()
}
})
function onKeydown(event: KeyboardEvent) {
if (event.key !== 'Escape') return
if (confirmingDelete.value) confirmingDelete.value = false
else if (menuOpen.value) menuOpen.value = false
}
onMounted(() => window.addEventListener('keydown', onKeydown))
onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
// The modal takes priority: while it's open, Escape closes it, not the menu
// underneath (askDelete() already closes the menu when the modal opens, so
// the two are never both open at once).
useDialog(confirmingDelete, () => (confirmingDelete.value = false), cancelButton)
useDialog(menuOpen, () => (menuOpen.value = false))
</script>
<template>
+230
View File
@@ -0,0 +1,230 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import draggable from 'vuedraggable'
import { useDialog } from '../composables/useDialog'
import { ApiError, apiRequest } from '../lib/api'
import type { CardStatus } from '../types'
// A project's statuses: a sorted, drag-reorderable list with add/delete.
// Deleting a status that still has cards asks which other status to move
// them to first. Self-contained -- fetches its own list independently of
// the rest of the configuration view.
const props = defineProps<{ projectId: number }>()
const statuses = ref<CardStatus[]>([])
const loadError = ref<string | null>(null)
const actionError = ref<string | null>(null)
void load()
async function load() {
loadError.value = null
try {
const { statuses: fetched } = await apiRequest<{ statuses: CardStatus[] }>(
`/projects/${props.projectId}/statuses`,
{ auth: true },
)
statuses.value = fetched
} catch (e) {
loadError.value = e instanceof ApiError ? e.message : 'Could not load the statuses.'
}
}
// --- Reorder (drag-and-drop) ----------------------------------------------
// vuedraggable mutates `statuses` in place as the user drags; @change fires
// once the drop lands, and we persist the whole new order.
async function onStatusesReordered() {
actionError.value = null
try {
await apiRequest(`/projects/${props.projectId}/statuses/order`, {
method: 'PUT',
auth: true,
body: { status_ids: statuses.value.map((s) => s.id) },
})
} catch (e) {
actionError.value = e instanceof ApiError ? e.message : 'Could not save the new order.'
await load() // our optimistic local order may not match the server's
}
}
// --- Add ---------------------------------------------------------------
const newStatusName = ref('')
const addingStatus = ref(false)
const addStatusError = ref<ApiError | null>(null)
async function onAddStatus() {
addingStatus.value = true
addStatusError.value = null
try {
const { status } = await apiRequest<{ status: CardStatus }>(`/projects/${props.projectId}/statuses`, {
method: 'POST',
auth: true,
body: { name: newStatusName.value },
})
statuses.value.push(status)
newStatusName.value = ''
} catch (e) {
addStatusError.value = e instanceof ApiError ? e : new ApiError('Could not add the status.', 0)
} finally {
addingStatus.value = false
}
}
// --- Delete, with reassignment when cards are still using the status -----
const deletingStatusId = ref<number | null>(null)
// Set once a plain delete comes back 409 -- that status has cards, so we ask
// which other status to move them to before trying again.
const pendingDelete = ref<CardStatus | null>(null)
const pendingDeleteCardCount = ref(0)
const reassignTo = ref<number | ''>('')
const reassigning = ref(false)
const reassignCancelButton = ref<HTMLButtonElement>()
const reassignOptions = computed(() => statuses.value.filter((s) => s.id !== pendingDelete.value?.id))
async function onDeleteStatus(status: CardStatus) {
actionError.value = null
deletingStatusId.value = status.id
try {
await apiRequest(`/projects/${props.projectId}/statuses/${status.id}`, { method: 'DELETE', auth: true })
statuses.value = statuses.value.filter((s) => s.id !== status.id)
} catch (e) {
if (e instanceof ApiError && e.status === 409 && e.cardCount !== undefined) {
pendingDelete.value = status
pendingDeleteCardCount.value = e.cardCount
reassignTo.value = ''
} else {
actionError.value = e instanceof ApiError ? e.message : 'Could not delete the status.'
}
} finally {
deletingStatusId.value = null
}
}
function cancelReassign() {
pendingDelete.value = null
}
async function confirmReassignAndDelete() {
if (!pendingDelete.value || reassignTo.value === '') return
reassigning.value = true
actionError.value = null
try {
await apiRequest(`/projects/${props.projectId}/statuses/${pendingDelete.value.id}`, {
method: 'DELETE',
auth: true,
body: { reassign_to: reassignTo.value },
})
statuses.value = statuses.value.filter((s) => s.id !== pendingDelete.value?.id)
pendingDelete.value = null
} catch (e) {
actionError.value = e instanceof ApiError ? e.message : 'Could not reassign and delete the status.'
} finally {
reassigning.value = false
}
}
useDialog(computed(() => pendingDelete.value !== null), cancelReassign, reassignCancelButton)
</script>
<template>
<section class="config-section">
<h2>Statuses</h2>
<p class="muted">
Drag to reorder. Deleting a status that still has cards asks where to move them first.
</p>
<p v-if="loadError" class="form-error">{{ loadError }}</p>
<p v-if="actionError" class="form-error">{{ actionError }}</p>
<draggable
:list="statuses"
item-key="id"
tag="ul"
class="status-list"
ghost-class="status-row--ghost"
:animation="150"
@change="onStatusesReordered"
>
<template #item="{ element: status }: { element: CardStatus }">
<li class="status-row">
<span class="status-row__name">{{ status.name }}</span>
<button
type="button"
class="status-row__delete"
:disabled="statuses.length <= 1 || deletingStatusId === status.id"
:title="statuses.length <= 1 ? 'A project must have at least one status.' : 'Delete status'"
:aria-label="`Delete status ${status.name}`"
@click="onDeleteStatus(status)"
>
&#10005;
</button>
</li>
</template>
</draggable>
<form class="field-row" @submit.prevent="onAddStatus">
<input
v-model="newStatusName"
class="field field--compact"
type="text"
maxlength="100"
required
placeholder="Status name"
aria-label="Status name"
/>
<button type="submit" :disabled="addingStatus">{{ addingStatus ? 'Adding…' : 'Add' }}</button>
</form>
<p v-if="addStatusError" class="form-error">{{ addStatusError.message }}</p>
</section>
<div
v-if="pendingDelete"
class="modal"
role="dialog"
aria-modal="true"
aria-labelledby="reassign-title"
>
<div class="modal__backdrop" @click="cancelReassign" />
<div class="modal__dialog">
<h2 id="reassign-title">Move its cards first</h2>
<p class="muted">
&ldquo;{{ pendingDelete.name }}&rdquo; still has {{ pendingDeleteCardCount }}
card{{ pendingDeleteCardCount === 1 ? '' : 's' }}. Choose another status to move
{{ pendingDeleteCardCount === 1 ? 'it' : 'them' }} to before deleting it.
</p>
<label class="modal__field">
<span>Move cards to</span>
<select v-model="reassignTo" class="field">
<option value="" disabled>Choose a status</option>
<option v-for="s in reassignOptions" :key="s.id" :value="s.id">{{ s.name }}</option>
</select>
</label>
<p v-if="actionError" class="form-error">{{ actionError }}</p>
<div class="modal__actions">
<button
ref="reassignCancelButton"
type="button"
class="btn-secondary"
:disabled="reassigning"
@click="cancelReassign"
>
Cancel
</button>
<button
type="button"
class="btn-danger"
:disabled="reassigning || reassignTo === ''"
@click="confirmReassignAndDelete"
>
{{ reassigning ? 'Moving…' : 'Move cards & delete' }}
</button>
</div>
</div>
</div>
</template>
+28
View File
@@ -0,0 +1,28 @@
import { nextTick, onBeforeUnmount, onMounted, watch, type Ref } from 'vue'
/**
* Shared "dialog" behaviour: Escape closes it, and -- when a focus target is
* given -- opening it moves focus there (matches our confirm/reassign
* modals, which follow the WAI-ARIA dialog pattern). Omit focusTarget to use
* just the Escape-to-close half, e.g. for a plain dropdown menu.
*/
export function useDialog<T extends HTMLElement>(
isOpen: Ref<boolean>,
close: () => void,
focusTarget?: Ref<T | undefined>,
): void {
if (focusTarget) {
watch(isOpen, async (open) => {
if (open) {
await nextTick()
focusTarget.value?.focus()
}
})
}
function onKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && isOpen.value) close()
}
onMounted(() => window.addEventListener('keydown', onKeydown))
onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
}
+8
View File
@@ -1,6 +1,14 @@
import { apiRequest } from './api'
import type { Card } from '../types'
/** A vuedraggable @change payload -- shared by every column that can be a
* drag source/target (a project's kanban columns, the sidebar's inbox). */
export type ColumnChange = {
added?: { element: Card; newIndex: number }
removed?: { element: Card; oldIndex: number }
moved?: { element: Card; oldIndex: number; newIndex: number }
}
/**
* Set the contents and order of one column -- the inbox (both null) or a
* project's status (both set). Shared by the sidebar's inbox list and a
+5 -215
View File
@@ -1,32 +1,26 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import draggable from 'vuedraggable'
import ProjectManageMenu from '../components/ProjectManageMenu.vue'
import StatusManager from '../components/StatusManager.vue'
import { ApiError, apiRequest } from '../lib/api'
import { useProjectsStore } from '../stores/projects'
import type { CardStatus, Project } from '../types'
import type { Project } from '../types'
const route = useRoute()
const projectId = Number(route.params.id)
const projects = useProjectsStore()
const project = ref<Project | null>(null)
const statuses = ref<CardStatus[]>([])
const loadError = ref<string | null>(null)
const statusActionError = ref<string | null>(null)
onMounted(() => void load())
async function load() {
loadError.value = null
try {
const [{ project: fetched }, { statuses: fetchedStatuses }] = await Promise.all([
apiRequest<{ project: Project }>(`/projects/${projectId}`, { auth: true }),
apiRequest<{ statuses: CardStatus[] }>(`/projects/${projectId}/statuses`, { auth: true }),
])
const { project: fetched } = await apiRequest<{ project: Project }>(`/projects/${projectId}`, { auth: true })
project.value = fetched
statuses.value = fetchedStatuses
nameDraft.value = fetched.title
} catch (e) {
if (e instanceof ApiError && e.status === 404) {
@@ -67,115 +61,6 @@ async function onRenameProject() {
renaming.value = false
}
}
// --- Reorder (drag-and-drop) ----------------------------------------------
// vuedraggable mutates `statuses` in place as the user drags; @change fires
// once the drop lands, and we persist the whole new order.
async function onStatusesReordered() {
statusActionError.value = null
try {
await apiRequest(`/projects/${projectId}/statuses/order`, {
method: 'PUT',
auth: true,
body: { status_ids: statuses.value.map((s) => s.id) },
})
} catch (e) {
statusActionError.value = e instanceof ApiError ? e.message : 'Could not save the new order.'
await load() // our optimistic local order may not match the server's
}
}
// --- Add ---------------------------------------------------------------
const newStatusName = ref('')
const addingStatus = ref(false)
const addStatusError = ref<ApiError | null>(null)
async function onAddStatus() {
addingStatus.value = true
addStatusError.value = null
try {
const { status } = await apiRequest<{ status: CardStatus }>(`/projects/${projectId}/statuses`, {
method: 'POST',
auth: true,
body: { name: newStatusName.value },
})
statuses.value.push(status)
newStatusName.value = ''
} catch (e) {
addStatusError.value = e instanceof ApiError ? e : new ApiError('Could not add the status.', 0)
} finally {
addingStatus.value = false
}
}
// --- Delete, with reassignment when cards are still using the status -----
const deletingStatusId = ref<number | null>(null)
// Set once a plain delete comes back 409 -- that status has cards, so we ask
// which other status to move them to before trying again.
const pendingDelete = ref<CardStatus | null>(null)
const pendingDeleteCardCount = ref(0)
const reassignTo = ref<number | ''>('')
const reassigning = ref(false)
const reassignCancelButton = ref<HTMLButtonElement>()
const reassignOptions = computed(() => statuses.value.filter((s) => s.id !== pendingDelete.value?.id))
async function onDeleteStatus(status: CardStatus) {
statusActionError.value = null
deletingStatusId.value = status.id
try {
await apiRequest(`/projects/${projectId}/statuses/${status.id}`, { method: 'DELETE', auth: true })
statuses.value = statuses.value.filter((s) => s.id !== status.id)
} catch (e) {
if (e instanceof ApiError && e.status === 409 && e.cardCount !== undefined) {
pendingDelete.value = status
pendingDeleteCardCount.value = e.cardCount
reassignTo.value = ''
} else {
statusActionError.value = e instanceof ApiError ? e.message : 'Could not delete the status.'
}
} finally {
deletingStatusId.value = null
}
}
function cancelReassign() {
pendingDelete.value = null
}
async function confirmReassignAndDelete() {
if (!pendingDelete.value || reassignTo.value === '') return
reassigning.value = true
statusActionError.value = null
try {
await apiRequest(`/projects/${projectId}/statuses/${pendingDelete.value.id}`, {
method: 'DELETE',
auth: true,
body: { reassign_to: reassignTo.value },
})
statuses.value = statuses.value.filter((s) => s.id !== pendingDelete.value?.id)
pendingDelete.value = null
} catch (e) {
statusActionError.value = e instanceof ApiError ? e.message : 'Could not reassign and delete the status.'
} finally {
reassigning.value = false
}
}
watch(pendingDelete, async (status) => {
if (status) {
await nextTick()
reassignCancelButton.value?.focus()
}
})
function onKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && pendingDelete.value) cancelReassign()
}
onMounted(() => window.addEventListener('keydown', onKeydown))
onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
</script>
<template>
@@ -215,102 +100,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
<p v-if="renameError" class="form-error">{{ renameError.message }}</p>
</section>
<section class="config-section">
<h2>Statuses</h2>
<p class="muted">
Drag to reorder. Deleting a status that still has cards asks where to move them first.
</p>
<p v-if="statusActionError" class="form-error">{{ statusActionError }}</p>
<draggable
:list="statuses"
item-key="id"
tag="ul"
class="status-list"
ghost-class="status-row--ghost"
:animation="150"
@change="onStatusesReordered"
>
<template #item="{ element: status }: { element: CardStatus }">
<li class="status-row">
<span class="status-row__name">{{ status.name }}</span>
<button
type="button"
class="status-row__delete"
:disabled="statuses.length <= 1 || deletingStatusId === status.id"
:title="statuses.length <= 1 ? 'A project must have at least one status.' : 'Delete status'"
:aria-label="`Delete status ${status.name}`"
@click="onDeleteStatus(status)"
>
&#10005;
</button>
</li>
</template>
</draggable>
<form class="field-row" @submit.prevent="onAddStatus">
<input
v-model="newStatusName"
class="field field--compact"
type="text"
maxlength="100"
required
placeholder="Status name"
aria-label="Status name"
/>
<button type="submit" :disabled="addingStatus">{{ addingStatus ? 'Adding…' : 'Add' }}</button>
</form>
<p v-if="addStatusError" class="form-error">{{ addStatusError.message }}</p>
</section>
<StatusManager :project-id="projectId" />
</template>
</section>
<div
v-if="pendingDelete"
class="modal"
role="dialog"
aria-modal="true"
aria-labelledby="reassign-title"
>
<div class="modal__backdrop" @click="cancelReassign" />
<div class="modal__dialog">
<h2 id="reassign-title">Move its cards first</h2>
<p class="muted">
&ldquo;{{ pendingDelete.name }}&rdquo; still has {{ pendingDeleteCardCount }}
card{{ pendingDeleteCardCount === 1 ? '' : 's' }}. Choose another status to move
{{ pendingDeleteCardCount === 1 ? 'it' : 'them' }} to before deleting it.
</p>
<label class="modal__field">
<span>Move cards to</span>
<select v-model="reassignTo" class="field">
<option value="" disabled>Choose a status</option>
<option v-for="s in reassignOptions" :key="s.id" :value="s.id">{{ s.name }}</option>
</select>
</label>
<p v-if="statusActionError" class="form-error">{{ statusActionError }}</p>
<div class="modal__actions">
<button
ref="reassignCancelButton"
type="button"
class="btn-secondary"
:disabled="reassigning"
@click="cancelReassign"
>
Cancel
</button>
<button
type="button"
class="btn-danger"
:disabled="reassigning || reassignTo === ''"
@click="confirmReassignAndDelete"
>
{{ reassigning ? 'Moving…' : 'Move cards & delete' }}
</button>
</div>
</div>
</div>
</template>
+1 -6
View File
@@ -6,7 +6,7 @@ import CardRow from '../components/CardRow.vue'
import KanbanCard from '../components/KanbanCard.vue'
import ProjectManageMenu from '../components/ProjectManageMenu.vue'
import { ApiError, apiRequest } from '../lib/api'
import { reorderColumn } from '../lib/cardOrder'
import { reorderColumn, type ColumnChange } from '../lib/cardOrder'
import { useCardsStore } from '../stores/cards'
import { useInboxStore } from '../stores/inbox'
import type { Card, CardStatus, Project } from '../types'
@@ -51,11 +51,6 @@ interface Column {
statusId: number
cards: Card[]
}
type ColumnChange = {
added?: { element: Card; newIndex: number }
removed?: { element: Card; oldIndex: number }
moved?: { element: Card; oldIndex: number; newIndex: number }
}
const board = ref<Column[]>([])