Extract a shared ManageMenu; add notFoundOr for the repeated 404 pattern

ManageMenu.vue holds what ProjectManageMenu and CardManageMenu had
copy-pasted between them almost verbatim: the dropdown, the confirm
modal, both useDialog wirings, menuOpen/confirmingDelete/deleting
state. Each is now a thin wrapper supplying only what's genuinely
entity-specific -- the Configure route, the delete labels, and (as
confirmDelete) what deleting actually does, since that differs more
than the UI around it (which stores to refresh, where to navigate).
The confirmation body text comes through the default slot, since a
project's names its card count and a card's doesn't.

notFoundOr(e, notFoundMessage, fallbackMessage) in lib/api.ts replaces
the identical "404 -> fixed message, else -> the error's own message,
else -> a fallback" block that ProjectView, ProjectConfigureView,
CardView, and CardConfigureView had each written out by hand.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-05 01:25:37 +01:00
co-authored by Claude Sonnet 5
parent 6c7b78ad4b
commit 367a98308a
8 changed files with 174 additions and 226 deletions
+15 -95
View File
@@ -1,44 +1,25 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router' import ManageMenu from './ManageMenu.vue'
import { useDialog } from '../composables/useDialog' import { apiRequest } from '../lib/api'
import { ApiError, apiRequest } from '../lib/api'
import { useCardsStore } from '../stores/cards' import { useCardsStore } from '../stores/cards'
import { useInboxStore } from '../stores/inbox' import { useInboxStore } from '../stores/inbox'
// The "Manage" dropdown + its delete-card confirmation modal, shared by the // The card-specific bits ManageMenu needs: where its Configure link goes,
// card view and its configuration view -- each links to the other, and // what deleting a card actually does, and the confirmation wording.
// either can delete the card. Mirrors ProjectManageMenu.
const props = defineProps<{ const props = defineProps<{
cardId: number cardId: number
cardText: string cardText: string
/** null for an inbox card -- there's no project to return to, so a delete /** null for an inbox card -- there's no project to return to, so a delete
* (or the "Back to project" link on the pages using this menu) goes to * goes to the dashboard instead. */
* the dashboard instead. */
projectId: number | null projectId: number | null
}>() }>()
const route = useRoute()
const router = useRouter() const router = useRouter()
const cards = useCardsStore() const cards = useCardsStore()
const inbox = useInboxStore() const inbox = useInboxStore()
const menuOpen = ref(false)
const confirmingDelete = ref(false)
const deleting = ref(false)
const deleteError = ref<string | null>(null)
const cancelButton = ref<HTMLButtonElement>()
function askDelete() {
menuOpen.value = false
deleteError.value = null
confirmingDelete.value = true
}
async function confirmDelete() { async function confirmDelete() {
deleting.value = true
deleteError.value = null
try {
await apiRequest(`/cards/${props.cardId}`, { method: 'DELETE', auth: true }) await apiRequest(`/cards/${props.cardId}`, { method: 'DELETE', auth: true })
// Refresh whichever store holds this card -- a project's board, or the // Refresh whichever store holds this card -- a project's board, or the
// sidebar inbox -- so it no longer shows the card we just deleted. // sidebar inbox -- so it no longer shows the card we just deleted.
@@ -46,79 +27,18 @@ async function confirmDelete() {
await router.push( await router.push(
props.projectId !== null ? { name: 'project', params: { id: props.projectId } } : { name: 'dashboard' }, props.projectId !== null ? { name: 'project', params: { id: props.projectId } } : { name: 'dashboard' },
) )
} catch (e) {
deleteError.value = e instanceof ApiError ? e.message : 'Could not delete the card.'
deleting.value = false
} }
}
// 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> </script>
<template> <template>
<div class="menu"> <ManageMenu
<button :configure-to="{ name: 'card-configure', params: { id: cardId } }"
type="button" configure-route-name="card-configure"
class="menu__toggle" delete-label="Delete card"
aria-haspopup="true" confirm-title="Delete this card?"
:aria-expanded="menuOpen" delete-error-fallback="Could not delete the card."
@click="menuOpen = !menuOpen" :confirm-delete="confirmDelete"
> >
Manage &#9662; &ldquo;{{ cardText }}&rdquo; will be permanently deleted.
</button> </ManageMenu>
<template v-if="menuOpen">
<div class="menu__backdrop" @click="menuOpen = false" />
<ul class="menu__list" role="menu">
<li v-if="route.name !== 'card-configure'" role="none">
<RouterLink
:to="{ name: 'card-configure', params: { id: cardId } }"
role="menuitem"
class="menu__item"
@click="menuOpen = false"
>
Configure
</RouterLink>
</li>
<li role="none">
<button type="button" role="menuitem" class="menu__item menu__item--danger" @click="askDelete">
Delete card
</button>
</li>
</ul>
</template>
</div>
<div
v-if="confirmingDelete"
class="modal"
role="dialog"
aria-modal="true"
aria-labelledby="confirm-delete-title"
>
<div class="modal__backdrop" @click="confirmingDelete = false" />
<div class="modal__dialog">
<h2 id="confirm-delete-title">Delete this card?</h2>
<p class="muted">&ldquo;{{ cardText }}&rdquo; will be permanently deleted.</p>
<p v-if="deleteError" class="form-error">{{ deleteError }}</p>
<div class="modal__actions">
<button
ref="cancelButton"
type="button"
class="btn-secondary"
:disabled="deleting"
@click="confirmingDelete = false"
>
Cancel
</button>
<button type="button" class="btn-danger" :disabled="deleting" @click="confirmDelete">
{{ deleting ? 'Deleting' : 'Delete card' }}
</button>
</div>
</div>
</div>
</template> </template>
+114
View File
@@ -0,0 +1,114 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRoute } from 'vue-router'
import type { RouteLocationRaw } from 'vue-router'
import { useDialog } from '../composables/useDialog'
import { ApiError } from '../lib/api'
// Generic "Manage" dropdown + delete-confirmation modal -- shared by every
// entity's view and its own configuration view (currently a project and a
// card): a Configure link (hidden while already on that page) and a Delete
// action. What deleting actually does varies a lot (which stores need
// refreshing, where to navigate afterwards), so the caller supplies that as
// confirmDelete; the modal's own confirmation wording comes from the
// default slot, since it varies too (a project's names its card count, a
// card's doesn't).
const props = defineProps<{
configureTo: RouteLocationRaw
configureRouteName: string
deleteLabel: string
confirmTitle: string
deleteErrorFallback: string
confirmDelete: () => Promise<void>
}>()
const route = useRoute()
const menuOpen = ref(false)
const confirmingDelete = ref(false)
const deleting = ref(false)
const deleteError = ref<string | null>(null)
const cancelButton = ref<HTMLButtonElement>()
function askDelete() {
menuOpen.value = false
deleteError.value = null
confirmingDelete.value = true
}
async function onConfirmDelete() {
deleting.value = true
deleteError.value = null
try {
await props.confirmDelete()
} catch (e) {
deleteError.value = e instanceof ApiError ? e.message : props.deleteErrorFallback
deleting.value = false
}
}
// 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>
<div class="menu">
<button
type="button"
class="menu__toggle"
aria-haspopup="true"
:aria-expanded="menuOpen"
@click="menuOpen = !menuOpen"
>
Manage &#9662;
</button>
<template v-if="menuOpen">
<div class="menu__backdrop" @click="menuOpen = false" />
<ul class="menu__list" role="menu">
<li v-if="route.name !== configureRouteName" role="none">
<RouterLink :to="configureTo" role="menuitem" class="menu__item" @click="menuOpen = false">
Configure
</RouterLink>
</li>
<li role="none">
<button type="button" role="menuitem" class="menu__item menu__item--danger" @click="askDelete">
{{ deleteLabel }}
</button>
</li>
</ul>
</template>
</div>
<div
v-if="confirmingDelete"
class="modal"
role="dialog"
aria-modal="true"
aria-labelledby="confirm-delete-title"
>
<div class="modal__backdrop" @click="confirmingDelete = false" />
<div class="modal__dialog">
<h2 id="confirm-delete-title">{{ confirmTitle }}</h2>
<p class="muted"><slot /></p>
<p v-if="deleteError" class="form-error">{{ deleteError }}</p>
<div class="modal__actions">
<button
ref="cancelButton"
type="button"
class="btn-secondary"
:disabled="deleting"
@click="confirmingDelete = false"
>
Cancel
</button>
<button type="button" class="btn-danger" :disabled="deleting" @click="onConfirmDelete">
{{ deleting ? 'Deleting' : deleteLabel }}
</button>
</div>
</div>
</div>
</template>
+13 -94
View File
@@ -1,121 +1,40 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router' import ManageMenu from './ManageMenu.vue'
import { useDialog } from '../composables/useDialog' import { 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'
// The "Manage" dropdown + its delete-project confirmation modal, shared by // The project-specific bits ManageMenu needs: where its Configure link goes,
// the project view and its configuration view -- each links to the other, // what deleting a project actually does, and the confirmation wording.
// and either can delete the project.
const props = defineProps<{ const props = defineProps<{
projectId: number projectId: number
projectTitle: string projectTitle: string
cardCount: number cardCount: number
}>() }>()
const route = useRoute()
const router = useRouter() const router = useRouter()
const projects = useProjectsStore() const projects = useProjectsStore()
const cards = useCardsStore() const cards = useCardsStore()
const menuOpen = ref(false)
const confirmingDelete = ref(false)
const deleting = ref(false)
const deleteError = ref<string | null>(null)
const cancelButton = ref<HTMLButtonElement>()
function askDelete() {
menuOpen.value = false
deleteError.value = null
confirmingDelete.value = true
}
async function confirmDelete() { async function confirmDelete() {
deleting.value = true
deleteError.value = null
try {
await apiRequest(`/projects/${props.projectId}`, { method: 'DELETE', auth: true }) await apiRequest(`/projects/${props.projectId}`, { method: 'DELETE', auth: true })
projects.reset() projects.reset()
cards.reset() cards.reset()
await router.push('/') await router.push('/')
} catch (e) {
deleteError.value = e instanceof ApiError ? e.message : 'Could not delete the project.'
deleting.value = false
} }
}
// 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> </script>
<template> <template>
<div class="menu"> <ManageMenu
<button :configure-to="{ name: 'project-configure', params: { id: projectId } }"
type="button" configure-route-name="project-configure"
class="menu__toggle" delete-label="Delete project"
aria-haspopup="true" confirm-title="Delete this project?"
:aria-expanded="menuOpen" delete-error-fallback="Could not delete the project."
@click="menuOpen = !menuOpen" :confirm-delete="confirmDelete"
> >
Manage &#9662;
</button>
<template v-if="menuOpen">
<div class="menu__backdrop" @click="menuOpen = false" />
<ul class="menu__list" role="menu">
<li v-if="route.name !== 'project-configure'" role="none">
<RouterLink
:to="{ name: 'project-configure', params: { id: projectId } }"
role="menuitem"
class="menu__item"
@click="menuOpen = false"
>
Configure
</RouterLink>
</li>
<li role="none">
<button type="button" role="menuitem" class="menu__item menu__item--danger" @click="askDelete">
Delete project
</button>
</li>
</ul>
</template>
</div>
<div
v-if="confirmingDelete"
class="modal"
role="dialog"
aria-modal="true"
aria-labelledby="confirm-delete-title"
>
<div class="modal__backdrop" @click="confirmingDelete = false" />
<div class="modal__dialog">
<h2 id="confirm-delete-title">Delete this project?</h2>
<p class="muted">
&ldquo;{{ projectTitle }}&rdquo; and its {{ cardCount }} &ldquo;{{ projectTitle }}&rdquo; and its {{ cardCount }}
card{{ cardCount === 1 ? '' : 's' }} will be permanently deleted. card{{ cardCount === 1 ? '' : 's' }} will be permanently deleted.
</p> </ManageMenu>
<p v-if="deleteError" class="form-error">{{ deleteError }}</p>
<div class="modal__actions">
<button
ref="cancelButton"
type="button"
class="btn-secondary"
:disabled="deleting"
@click="confirmingDelete = false"
>
Cancel
</button>
<button type="button" class="btn-danger" :disabled="deleting" @click="confirmDelete">
{{ deleting ? 'Deleting' : 'Delete project' }}
</button>
</div>
</div>
</div>
</template> </template>
+11
View File
@@ -38,6 +38,17 @@ export class ApiError extends Error {
} }
} }
/**
* A message for a caught error: a fixed one for "not found" (404), the
* server's own message for any other API error, and a fallback for
* anything else (e.g. a network failure) -- the shape every view's initial
* load uses to turn a failed fetch into what it shows the user.
*/
export function notFoundOr(e: unknown, notFoundMessage: string, fallbackMessage: string): string {
if (e instanceof ApiError && e.status === 404) return notFoundMessage
return e instanceof ApiError ? e.message : fallbackMessage
}
interface RequestOptions { interface RequestOptions {
method?: string method?: string
body?: unknown body?: unknown
+2 -6
View File
@@ -2,7 +2,7 @@
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import CardManageMenu from '../components/CardManageMenu.vue' import CardManageMenu from '../components/CardManageMenu.vue'
import { ApiError, apiRequest } from '../lib/api' import { ApiError, apiRequest, notFoundOr } from '../lib/api'
import { useCardsStore } from '../stores/cards' import { useCardsStore } from '../stores/cards'
import { useInboxStore } from '../stores/inbox' import { useInboxStore } from '../stores/inbox'
import type { Card } from '../types' import type { Card } from '../types'
@@ -24,11 +24,7 @@ async function load() {
card.value = fetched card.value = fetched
textDraft.value = fetched.text textDraft.value = fetched.text
} catch (e) { } catch (e) {
if (e instanceof ApiError && e.status === 404) { loadError.value = notFoundOr(e, 'That card does not exist.', 'Could not load the card.')
loadError.value = 'That card does not exist.'
} else {
loadError.value = e instanceof ApiError ? e.message : 'Could not load the card.'
}
} }
} }
+2 -6
View File
@@ -2,7 +2,7 @@
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useRoute, type RouteLocationRaw } from 'vue-router' import { useRoute, type RouteLocationRaw } from 'vue-router'
import CardManageMenu from '../components/CardManageMenu.vue' import CardManageMenu from '../components/CardManageMenu.vue'
import { ApiError, apiRequest } from '../lib/api' import { apiRequest, notFoundOr } from '../lib/api'
import type { Card } from '../types' import type { Card } from '../types'
const route = useRoute() const route = useRoute()
@@ -19,11 +19,7 @@ async function load() {
const { card: fetched } = await apiRequest<{ card: Card }>(`/cards/${cardId}`, { auth: true }) const { card: fetched } = await apiRequest<{ card: Card }>(`/cards/${cardId}`, { auth: true })
card.value = fetched card.value = fetched
} catch (e) { } catch (e) {
if (e instanceof ApiError && e.status === 404) { loadError.value = notFoundOr(e, 'That card does not exist.', 'Could not load the card.')
loadError.value = 'That card does not exist.'
} else {
loadError.value = e instanceof ApiError ? e.message : 'Could not load the card.'
}
} }
} }
+2 -6
View File
@@ -3,7 +3,7 @@ import { onMounted, ref } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import ProjectManageMenu from '../components/ProjectManageMenu.vue' import ProjectManageMenu from '../components/ProjectManageMenu.vue'
import StatusManager from '../components/StatusManager.vue' import StatusManager from '../components/StatusManager.vue'
import { ApiError, apiRequest } from '../lib/api' import { ApiError, apiRequest, notFoundOr } from '../lib/api'
import { useProjectsStore } from '../stores/projects' import { useProjectsStore } from '../stores/projects'
import type { Project } from '../types' import type { Project } from '../types'
@@ -23,11 +23,7 @@ async function load() {
project.value = fetched project.value = fetched
nameDraft.value = fetched.title nameDraft.value = fetched.title
} catch (e) { } catch (e) {
if (e instanceof ApiError && e.status === 404) { loadError.value = notFoundOr(e, 'That project does not exist.', 'Could not load the project.')
loadError.value = 'That project does not exist.'
} else {
loadError.value = e instanceof ApiError ? e.message : 'Could not load the project.'
}
} }
} }
+2 -6
View File
@@ -2,7 +2,7 @@
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import ProjectManageMenu from '../components/ProjectManageMenu.vue' import ProjectManageMenu from '../components/ProjectManageMenu.vue'
import { ApiError, apiRequest } from '../lib/api' import { apiRequest, notFoundOr } from '../lib/api'
import { useCardsStore } from '../stores/cards' import { useCardsStore } from '../stores/cards'
import type { Project } from '../types' import type { Project } from '../types'
@@ -30,11 +30,7 @@ async function load() {
cards.load(projectId), cards.load(projectId),
]) ])
} catch (e) { } catch (e) {
if (e instanceof ApiError && e.status === 404) { loadError.value = notFoundOr(e, 'That project does not exist.', 'Could not load the project.')
loadError.value = 'That project does not exist.'
} else {
loadError.value = e instanceof ApiError ? e.message : 'Could not load the project.'
}
} }
} }
</script> </script>