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 { RouterLink, useRoute, useRouter } from 'vue-router'
|
||||||
import draggable from 'vuedraggable'
|
import draggable from 'vuedraggable'
|
||||||
import { ApiError } from '../lib/api'
|
import { ApiError } from '../lib/api'
|
||||||
import { reorderColumn } from '../lib/cardOrder'
|
import { reorderColumn, type ColumnChange } from '../lib/cardOrder'
|
||||||
import { useCardsStore } from '../stores/cards'
|
import { useCardsStore } from '../stores/cards'
|
||||||
import { useInboxStore } from '../stores/inbox'
|
import { useInboxStore } from '../stores/inbox'
|
||||||
import { useProjectsStore } from '../stores/projects'
|
import { useProjectsStore } from '../stores/projects'
|
||||||
@@ -52,11 +52,6 @@ const newInboxText = ref('')
|
|||||||
const addingToInbox = ref(false)
|
const addingToInbox = ref(false)
|
||||||
const inboxError = ref<string | null>(null)
|
const inboxError = ref<string | null>(null)
|
||||||
|
|
||||||
type ColumnChange = {
|
|
||||||
added?: { element: Card; newIndex: number }
|
|
||||||
moved?: { element: Card; oldIndex: number; newIndex: number }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadInbox() {
|
async function loadInbox() {
|
||||||
inboxError.value = null
|
inboxError.value = null
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { useDialog } from '../composables/useDialog'
|
||||||
import { ApiError, apiRequest } from '../lib/api'
|
import { ApiError, apiRequest } from '../lib/api'
|
||||||
import { useCardsStore } from '../stores/cards'
|
import { useCardsStore } from '../stores/cards'
|
||||||
import { useProjectsStore } from '../stores/projects'
|
import { useProjectsStore } from '../stores/projects'
|
||||||
@@ -45,20 +46,11 @@ async function confirmDelete() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(confirmingDelete, async (open) => {
|
// The modal takes priority: while it's open, Escape closes it, not the menu
|
||||||
if (open) {
|
// underneath (askDelete() already closes the menu when the modal opens, so
|
||||||
await nextTick()
|
// the two are never both open at once).
|
||||||
cancelButton.value?.focus()
|
useDialog(confirmingDelete, () => (confirmingDelete.value = false), cancelButton)
|
||||||
}
|
useDialog(menuOpen, () => (menuOpen.value = false))
|
||||||
})
|
|
||||||
|
|
||||||
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))
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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>
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -1,6 +1,14 @@
|
|||||||
import { apiRequest } from './api'
|
import { apiRequest } from './api'
|
||||||
import type { Card } from '../types'
|
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
|
* 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
|
* project's status (both set). Shared by the sidebar's inbox list and a
|
||||||
|
|||||||
@@ -1,32 +1,26 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import draggable from 'vuedraggable'
|
|
||||||
import ProjectManageMenu from '../components/ProjectManageMenu.vue'
|
import ProjectManageMenu from '../components/ProjectManageMenu.vue'
|
||||||
|
import StatusManager from '../components/StatusManager.vue'
|
||||||
import { ApiError, apiRequest } from '../lib/api'
|
import { ApiError, apiRequest } from '../lib/api'
|
||||||
import { useProjectsStore } from '../stores/projects'
|
import { useProjectsStore } from '../stores/projects'
|
||||||
import type { CardStatus, Project } from '../types'
|
import type { Project } from '../types'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const projectId = Number(route.params.id)
|
const projectId = Number(route.params.id)
|
||||||
const projects = useProjectsStore()
|
const projects = useProjectsStore()
|
||||||
|
|
||||||
const project = ref<Project | null>(null)
|
const project = ref<Project | null>(null)
|
||||||
const statuses = ref<CardStatus[]>([])
|
|
||||||
const loadError = ref<string | null>(null)
|
const loadError = ref<string | null>(null)
|
||||||
const statusActionError = ref<string | null>(null)
|
|
||||||
|
|
||||||
onMounted(() => void load())
|
onMounted(() => void load())
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loadError.value = null
|
loadError.value = null
|
||||||
try {
|
try {
|
||||||
const [{ project: fetched }, { statuses: fetchedStatuses }] = await Promise.all([
|
const { project: fetched } = await apiRequest<{ project: Project }>(`/projects/${projectId}`, { auth: true })
|
||||||
apiRequest<{ project: Project }>(`/projects/${projectId}`, { auth: true }),
|
|
||||||
apiRequest<{ statuses: CardStatus[] }>(`/projects/${projectId}/statuses`, { auth: true }),
|
|
||||||
])
|
|
||||||
project.value = fetched
|
project.value = fetched
|
||||||
statuses.value = fetchedStatuses
|
|
||||||
nameDraft.value = fetched.title
|
nameDraft.value = fetched.title
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof ApiError && e.status === 404) {
|
if (e instanceof ApiError && e.status === 404) {
|
||||||
@@ -67,115 +61,6 @@ async function onRenameProject() {
|
|||||||
renaming.value = false
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -215,102 +100,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
|
|||||||
<p v-if="renameError" class="form-error">{{ renameError.message }}</p>
|
<p v-if="renameError" class="form-error">{{ renameError.message }}</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="config-section">
|
<StatusManager :project-id="projectId" />
|
||||||
<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)"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</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>
|
|
||||||
</template>
|
</template>
|
||||||
</section>
|
</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="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>
|
</template>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import CardRow from '../components/CardRow.vue'
|
|||||||
import KanbanCard from '../components/KanbanCard.vue'
|
import KanbanCard from '../components/KanbanCard.vue'
|
||||||
import ProjectManageMenu from '../components/ProjectManageMenu.vue'
|
import ProjectManageMenu from '../components/ProjectManageMenu.vue'
|
||||||
import { ApiError, apiRequest } from '../lib/api'
|
import { ApiError, apiRequest } from '../lib/api'
|
||||||
import { reorderColumn } from '../lib/cardOrder'
|
import { reorderColumn, type ColumnChange } from '../lib/cardOrder'
|
||||||
import { useCardsStore } from '../stores/cards'
|
import { useCardsStore } from '../stores/cards'
|
||||||
import { useInboxStore } from '../stores/inbox'
|
import { useInboxStore } from '../stores/inbox'
|
||||||
import type { Card, CardStatus, Project } from '../types'
|
import type { Card, CardStatus, Project } from '../types'
|
||||||
@@ -51,11 +51,6 @@ interface Column {
|
|||||||
statusId: number
|
statusId: number
|
||||||
cards: Card[]
|
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[]>([])
|
const board = ref<Column[]>([])
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user