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:
@@ -1,124 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useDialog } from '../composables/useDialog'
|
||||
import { ApiError, apiRequest } from '../lib/api'
|
||||
import { useRouter } from 'vue-router'
|
||||
import ManageMenu from './ManageMenu.vue'
|
||||
import { apiRequest } from '../lib/api'
|
||||
import { useCardsStore } from '../stores/cards'
|
||||
import { useInboxStore } from '../stores/inbox'
|
||||
|
||||
// The "Manage" dropdown + its delete-card confirmation modal, shared by the
|
||||
// card view and its configuration view -- each links to the other, and
|
||||
// either can delete the card. Mirrors ProjectManageMenu.
|
||||
// The card-specific bits ManageMenu needs: where its Configure link goes,
|
||||
// what deleting a card actually does, and the confirmation wording.
|
||||
const props = defineProps<{
|
||||
cardId: number
|
||||
cardText: string
|
||||
/** 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
|
||||
* the dashboard instead. */
|
||||
* goes to the dashboard instead. */
|
||||
projectId: number | null
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const cards = useCardsStore()
|
||||
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() {
|
||||
deleting.value = true
|
||||
deleteError.value = null
|
||||
try {
|
||||
await apiRequest(`/cards/${props.cardId}`, { method: 'DELETE', auth: true })
|
||||
// Refresh whichever store holds this card -- a project's board, or the
|
||||
// sidebar inbox -- so it no longer shows the card we just deleted.
|
||||
await (props.projectId !== null ? cards.load(props.projectId) : inbox.load())
|
||||
await router.push(
|
||||
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
|
||||
}
|
||||
await apiRequest(`/cards/${props.cardId}`, { method: 'DELETE', auth: true })
|
||||
// Refresh whichever store holds this card -- a project's board, or the
|
||||
// sidebar inbox -- so it no longer shows the card we just deleted.
|
||||
await (props.projectId !== null ? cards.load(props.projectId) : inbox.load())
|
||||
await router.push(
|
||||
props.projectId !== null ? { name: 'project', params: { id: props.projectId } } : { name: 'dashboard' },
|
||||
)
|
||||
}
|
||||
|
||||
// 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 ▾
|
||||
</button>
|
||||
|
||||
<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"
|
||||
<ManageMenu
|
||||
:configure-to="{ name: 'card-configure', params: { id: cardId } }"
|
||||
configure-route-name="card-configure"
|
||||
delete-label="Delete card"
|
||||
confirm-title="Delete this card?"
|
||||
delete-error-fallback="Could not delete the card."
|
||||
:confirm-delete="confirmDelete"
|
||||
>
|
||||
<div class="modal__backdrop" @click="confirmingDelete = false" />
|
||||
<div class="modal__dialog">
|
||||
<h2 id="confirm-delete-title">Delete this card?</h2>
|
||||
<p class="muted">“{{ cardText }}” 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>
|
||||
“{{ cardText }}” will be permanently deleted.
|
||||
</ManageMenu>
|
||||
</template>
|
||||
|
||||
@@ -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 ▾
|
||||
</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>
|
||||
@@ -1,121 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useDialog } from '../composables/useDialog'
|
||||
import { ApiError, apiRequest } from '../lib/api'
|
||||
import { useRouter } from 'vue-router'
|
||||
import ManageMenu from './ManageMenu.vue'
|
||||
import { apiRequest } from '../lib/api'
|
||||
import { useCardsStore } from '../stores/cards'
|
||||
import { useProjectsStore } from '../stores/projects'
|
||||
|
||||
// The "Manage" dropdown + its delete-project confirmation modal, shared by
|
||||
// the project view and its configuration view -- each links to the other,
|
||||
// and either can delete the project.
|
||||
// The project-specific bits ManageMenu needs: where its Configure link goes,
|
||||
// what deleting a project actually does, and the confirmation wording.
|
||||
const props = defineProps<{
|
||||
projectId: number
|
||||
projectTitle: string
|
||||
cardCount: number
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const projects = useProjectsStore()
|
||||
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() {
|
||||
deleting.value = true
|
||||
deleteError.value = null
|
||||
try {
|
||||
await apiRequest(`/projects/${props.projectId}`, { method: 'DELETE', auth: true })
|
||||
projects.reset()
|
||||
cards.reset()
|
||||
await router.push('/')
|
||||
} catch (e) {
|
||||
deleteError.value = e instanceof ApiError ? e.message : 'Could not delete the project.'
|
||||
deleting.value = false
|
||||
}
|
||||
await apiRequest(`/projects/${props.projectId}`, { method: 'DELETE', auth: true })
|
||||
projects.reset()
|
||||
cards.reset()
|
||||
await router.push('/')
|
||||
}
|
||||
|
||||
// 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 ▾
|
||||
</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"
|
||||
<ManageMenu
|
||||
:configure-to="{ name: 'project-configure', params: { id: projectId } }"
|
||||
configure-route-name="project-configure"
|
||||
delete-label="Delete project"
|
||||
confirm-title="Delete this project?"
|
||||
delete-error-fallback="Could not delete the project."
|
||||
:confirm-delete="confirmDelete"
|
||||
>
|
||||
<div class="modal__backdrop" @click="confirmingDelete = false" />
|
||||
<div class="modal__dialog">
|
||||
<h2 id="confirm-delete-title">Delete this project?</h2>
|
||||
<p class="muted">
|
||||
“{{ projectTitle }}” and its {{ cardCount }}
|
||||
card{{ cardCount === 1 ? '' : 's' }} 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 project' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
“{{ projectTitle }}” and its {{ cardCount }}
|
||||
card{{ cardCount === 1 ? '' : 's' }} will be permanently deleted.
|
||||
</ManageMenu>
|
||||
</template>
|
||||
|
||||
@@ -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 {
|
||||
method?: string
|
||||
body?: unknown
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
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 { useInboxStore } from '../stores/inbox'
|
||||
import type { Card } from '../types'
|
||||
@@ -24,11 +24,7 @@ async function load() {
|
||||
card.value = fetched
|
||||
textDraft.value = fetched.text
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 404) {
|
||||
loadError.value = 'That card does not exist.'
|
||||
} else {
|
||||
loadError.value = e instanceof ApiError ? e.message : 'Could not load the card.'
|
||||
}
|
||||
loadError.value = notFoundOr(e, 'That card does not exist.', 'Could not load the card.')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, type RouteLocationRaw } from 'vue-router'
|
||||
import CardManageMenu from '../components/CardManageMenu.vue'
|
||||
import { ApiError, apiRequest } from '../lib/api'
|
||||
import { apiRequest, notFoundOr } from '../lib/api'
|
||||
import type { Card } from '../types'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -19,11 +19,7 @@ async function load() {
|
||||
const { card: fetched } = await apiRequest<{ card: Card }>(`/cards/${cardId}`, { auth: true })
|
||||
card.value = fetched
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 404) {
|
||||
loadError.value = 'That card does not exist.'
|
||||
} else {
|
||||
loadError.value = e instanceof ApiError ? e.message : 'Could not load the card.'
|
||||
}
|
||||
loadError.value = notFoundOr(e, 'That card does not exist.', 'Could not load the card.')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import ProjectManageMenu from '../components/ProjectManageMenu.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 type { Project } from '../types'
|
||||
|
||||
@@ -23,11 +23,7 @@ async function load() {
|
||||
project.value = fetched
|
||||
nameDraft.value = fetched.title
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 404) {
|
||||
loadError.value = 'That project does not exist.'
|
||||
} else {
|
||||
loadError.value = e instanceof ApiError ? e.message : 'Could not load the project.'
|
||||
}
|
||||
loadError.value = notFoundOr(e, 'That project does not exist.', 'Could not load the project.')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import ProjectManageMenu from '../components/ProjectManageMenu.vue'
|
||||
import { ApiError, apiRequest } from '../lib/api'
|
||||
import { apiRequest, notFoundOr } from '../lib/api'
|
||||
import { useCardsStore } from '../stores/cards'
|
||||
import type { Project } from '../types'
|
||||
|
||||
@@ -30,11 +30,7 @@ async function load() {
|
||||
cards.load(projectId),
|
||||
])
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 404) {
|
||||
loadError.value = 'That project does not exist.'
|
||||
} else {
|
||||
loadError.value = e instanceof ApiError ? e.message : 'Could not load the project.'
|
||||
}
|
||||
loadError.value = notFoundOr(e, 'That project does not exist.', 'Could not load the project.')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user