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:
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)"
|
||||
>
|
||||
✕
|
||||
</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">
|
||||
“{{ pendingDelete.name }}” 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>
|
||||
Reference in New Issue
Block a user