add tag deletion modal

This commit is contained in:
swithek 2026-09-06 12:45:09 +02:00
parent b126237a98
commit 70728413a7
8 changed files with 366 additions and 29 deletions

View File

@ -400,6 +400,25 @@ describe("<AppSidebar>", { concurrent: false }, () => {
expect(sidebar(wrapper).emitted("initial-load-complete")).toHaveLength(1)
})
it("announces that every section has loaded with every tag hidden", async ({
expect,
}) => {
// a reload hands the cache its answers before the sidebar is built,
// so the tags section is there from the first render — and with the
// last visible tag hidden it has no rows to report
stubQueries()
seedQueryData(["documents", "tree"], [treeElement()])
seedQueryData(
["tags", "tree"],
[{ ...tagElement(TAG_A, "Production", "#22c55e"), hidden: true }],
)
const wrapper = await mountSidebar()
await settleMutations()
expect(sidebar(wrapper).emitted("initial-load-complete")).toHaveLength(1)
})
describe("next steps", { concurrent: false }, () => {
it("invites the user to add team members while alone", async ({
expect,
@ -714,7 +733,9 @@ describe("<AppSidebar>", { concurrent: false }, () => {
expect(calls[0]?.body).toEqual({ hidden: true })
})
it("deletes a tag from its options menu", async ({ expect }) => {
it("asks the page to delete a tag from its options menu", async ({
expect,
}) => {
stubQueries({ tags: seededTags() })
const calls = mockEndpoint("DELETE", `/api/tags/${TAG_A}`, () => ({}))
const wrapper = await mountSidebar()
@ -724,7 +745,12 @@ describe("<AppSidebar>", { concurrent: false }, () => {
menuItem(t("sidebar.item-dropdown-menu-buttons.delete-tag")).click()
await settleMutations()
expect(calls).toHaveLength(1)
// the modal outlives the sidebar's own drawer on a narrow screen,
// so the page owns it and the deletion with it
expect(sidebar(wrapper).emitted("delete-tag")).toEqual([
[{ id: TAG_A, name: "Production" }],
])
expect(calls).toHaveLength(0)
})
// the tree lists a document under a tag by its default branch, so

View File

@ -21,18 +21,14 @@ const emit = defineEmits<{
(e: "initial-load-complete" | "toggle-notifications"): void
(e: "open-settings", target: "org-members" | null): void
(e: "delete-document" | "duplicate-document", id: string): void
(e: "delete-tag", tag: { id: string; name: string }): void
(e: "create-document", parentId: string | null): void
}>()
const { t } = useI18n({ useScope: "global" })
const { fetchDocumentTree, updateDocumentTree } = useDocumentAPI()
const {
fetchTagTree,
updateTagTree,
updateTagVisibility,
deleteTag,
unassignBranchTag,
} = useTagAPI()
const { fetchTagTree, updateTagTree, updateTagVisibility, unassignBranchTag } =
useTagAPI()
const { fetchOrganization, safeSignOut } = useAuthSession()
const { fetchGitHubConnectionStatus, gitHubConfigured, fetchGitHubInstallURL } =
useGitHubAPI()
@ -131,6 +127,9 @@ const sections = computed<Section[]>(() => {
rootItems.push({
id: "tags",
heading: t("sidebar.sections.tags.heading"),
// the tree has answered by the time this section exists, so no
// rows means every tag is hidden rather than still on its way
isEmptyAfterLoad: true,
emptyMessage: t("sidebar.sections.tags.all-hidden"),
items: tagItems,
onUpdateLocation: handleTagLocationUpdate,
@ -351,8 +350,8 @@ function tagActions(tag: TagTreeElement): SidebarItemAction[] {
id: "delete-tag",
name: t("sidebar.item-dropdown-menu-buttons.delete-tag"),
icon: "lucide:trash-2",
fn: async () => {
await handleTagDelete(tag.id)
fn: () => {
emit("delete-tag", { id: tag.id, name: tag.tagName })
},
},
]
@ -383,14 +382,6 @@ async function handleTagVisibilityUpdate(id: string, req: { hidden: boolean }) {
}
}
async function handleTagDelete(id: string) {
try {
await deleteTag.mutateAsync(id)
} catch {
showToastMessage("error", t("sidebar.errors.delete-tag-failed"))
}
}
// the tree lists a document under a tag by its default branch, so that is
// the branch the row's action detaches
async function handleDocumentTagRemoval(
@ -506,7 +497,7 @@ async function installSlack() {
<ShadcnUiSidebarGroupContent>
<div
v-if="section.emptyMessage && !section.items.length"
class="px-2 py-1.25 text-2sm text-sidebar-foreground/60"
class="flex h-7 items-center px-2 text-2sm text-sidebar-foreground/60"
>
{{ section.emptyMessage }}
</div>

View File

@ -0,0 +1,114 @@
import { mountSuspended } from "@nuxt/test-utils/runtime"
import { beforeEach, describe, it, vi } from "vitest"
import TagDeletionModal from "./TagDeletionModal.vue"
import { clearTeleportedOverlays, t, teleportedButton } from "./test-helpers"
function mountModal(
target: { deleteTag: () => Promise<void>; name: string } | null,
) {
return mountSuspended(TagDeletionModal, {
props: { modelValue: target },
})
}
// the dialog body is teleported into <body>, so it is the only place the
// modal's own markup can be read from
function dialog() {
return document.body.querySelector("[data-slot='dialog-content']")
}
// the dialog body is teleported into the shared <body> and the delete flow
// is driven by the global fake timers, so these tests cannot interleave
// t() reaches for the nuxt app context, so the label can only be
// resolved inside a test — not once at module scope
function confirmButton() {
return teleportedButton(t("sidebar.tag-deletion-modal.confirm-button"))
}
describe("<TagDeletionModal>", { concurrent: false }, () => {
beforeEach(clearTeleportedOverlays)
it("stays closed while there is no deletion target", async ({ expect }) => {
await mountModal(null)
expect(dialog()).toBeNull()
})
it("names the tag it is about to delete", async ({ expect }) => {
await mountModal({ name: "Production", deleteTag: vi.fn() })
expect(dialog()?.textContent).toContain("Production")
})
it("deletes the tag when the confirm button is pressed", async ({
expect,
}) => {
vi.useFakeTimers()
const deleteTag = vi.fn().mockResolvedValue(undefined)
const wrapper = await mountModal({ name: "Production", deleteTag })
confirmButton().click()
await vi.advanceTimersByTimeAsync(300)
expect(deleteTag).toHaveBeenCalledTimes(1)
expect(wrapper.emitted("update:modelValue")?.at(-1)).toEqual([null])
})
it("shows a spinner and disables both buttons while deleting", async ({
expect,
}) => {
vi.useFakeTimers()
const deleteTag = vi.fn().mockResolvedValue(undefined)
await mountModal({ name: "Production", deleteTag })
confirmButton().click()
await nextTick()
expect(
dialog()?.querySelector(".i-svg-spinners\\:blocks-shuffle-3"),
).not.toBeNull()
expect(confirmButton().disabled).toBe(true)
expect(
teleportedButton(t("sidebar.tag-deletion-modal.cancel-button")).disabled,
).toBe(true)
})
it("closes without deleting when the cancel button is pressed", async ({
expect,
}) => {
const deleteTag = vi.fn()
const wrapper = await mountModal({ name: "Production", deleteTag })
teleportedButton(t("sidebar.tag-deletion-modal.cancel-button")).click()
await nextTick()
expect(deleteTag).toHaveBeenCalledTimes(0)
expect(wrapper.emitted("update:modelValue")?.at(-1)).toEqual([null])
})
it("closes without deleting when the close button is pressed", async ({
expect,
}) => {
const deleteTag = vi.fn()
const wrapper = await mountModal({ name: "Production", deleteTag })
teleportedButton(t("general.modal-close-screen-reader-hint")).click()
await nextTick()
expect(deleteTag).toHaveBeenCalledTimes(0)
expect(wrapper.emitted("update:modelValue")?.at(-1)).toEqual([null])
})
it("keeps showing the tag name while the dialog animates shut", async ({
expect,
}) => {
const wrapper = await mountModal({
name: "Production",
deleteTag: vi.fn(),
})
await wrapper.setProps({ modelValue: null })
expect(document.body.textContent).toContain("Production")
})
})

View File

@ -0,0 +1,104 @@
<script lang="ts" setup>
const open = defineModel<{
deleteTag: () => Promise<void>
name: string
} | null>({
default: null,
})
const lastValidTarget = useLastValidRef(open)
const loading = ref(false)
async function deleteTag() {
if (!lastValidTarget.value || !open.value) {
return
}
loading.value = true
await delay(300) // show loading spinner for at least a moment
await lastValidTarget.value.deleteTag()
open.value = null
setTimeout(() => {
// just to ensure that the spinner is visible until the modal closes
loading.value = false
}, 500)
}
</script>
<template>
<ShadcnUiDialog
:open="!!open"
@update:open="
(v) => {
if (!v) {
open = null
}
}
"
>
<ShadcnUiDialogContent
class="max-h-[90dvh] w-[85dvw] overflow-y-auto p-0 text-foreground sm:w-110 md:max-h-[80dvh]"
>
<div class="flex flex-col gap-8 p-6">
<div class="flex min-h-0 flex-col gap-3">
<ShadcnUiDialogHeader>
<ShadcnUiDialogTitle class="text-base">
{{ $t("sidebar.tag-deletion-modal.title") }}
</ShadcnUiDialogTitle>
<ShadcnUiButton
variant="ghost-plain"
class="absolute top-1/2 right-0 shrink-0 -translate-y-1/2 p-0"
@click="open = null"
>
<Icon name="lucide:x" size="1rem" />
<span class="sr-only">
{{ $t("general.modal-close-screen-reader-hint") }}
</span>
</ShadcnUiButton>
</ShadcnUiDialogHeader>
<div class="flex flex-col gap-2 self-stretch">
<i18n-t
scope="global"
keypath="sidebar.tag-deletion-modal.description"
tag="p"
class="text-2sm text-muted-foreground"
>
<template #name>
<span class="font-medium break-all">
{{ lastValidTarget?.name }}
</span>
</template>
</i18n-t>
</div>
<div class="flex gap-2 self-stretch">
<ShadcnUiButton
type="button"
variant="destructive"
size="sm"
:disabled="loading"
class="text-2sm"
@click="deleteTag"
>
<Icon
v-show="loading"
name="svg-spinners:blocks-shuffle-3"
class="size-3"
/>
{{ $t("sidebar.tag-deletion-modal.confirm-button") }}
</ShadcnUiButton>
<ShadcnUiButton
type="button"
size="sm"
variant="secondary"
:disabled="loading"
class="text-2sm"
@click="open = null"
>
{{ $t("sidebar.tag-deletion-modal.cancel-button") }}
</ShadcnUiButton>
</div>
</div>
</div>
</ShadcnUiDialogContent>
</ShadcnUiDialog>
</template>

View File

@ -4,6 +4,7 @@ import {
clearQueryCache,
disposeMockEndpoints,
makeXid,
mockDeferredEndpoint,
mockEndpoint,
runInApp,
} from "~/composables/api/test-helpers"
@ -447,6 +448,22 @@ describe("<DocumentTagList>", { concurrent: false }, () => {
expect(pickerRows().filter((row) => row.includes("Production"))).toEqual([])
})
it("reopens with the search box empty", async ({ expect }) => {
stubTags([
makeTag(TAG_A, "Production", "#1a9e4a"),
makeTag(TAG_B, "Staging", "#e8760c"),
])
const wrapper = await mountTags()
await openPicker(wrapper)
await search("stag")
await openPicker(wrapper)
await openPicker(wrapper)
expect(pickerInput().value).toBe("")
expect(pickerRows()).toEqual(["Production", "Staging"])
})
it("hints at creation while nothing is typed", async ({ expect }) => {
stubTags([makeTag(TAG_A, "Production", "#1a9e4a")])
const wrapper = await mountTags()
@ -512,6 +529,43 @@ describe("<DocumentTagList>", { concurrent: false }, () => {
expect(assigned[0]?.body).toEqual({ tagId: TAG_B })
})
it("keeps the other tags listed while the new one is still in flight", async ({
expect,
}) => {
// the create settles only once the refetch behind it has, so a slow
// request leaves the list on screen for as long as it takes
stubTags([makeTag(TAG_A, "Production", "#1a9e4a")])
const created = mockDeferredEndpoint("POST", "/api/tags")
const wrapper = await mountTags()
await openPicker(wrapper)
await search("Rollout")
pressEnter()
await created.reached
await nextTick()
expect(pickerRows()).toEqual(["Production", "Rollout"])
})
it("scrolls the list down to the tag it just appended", async ({
expect,
}) => {
// happy-dom lays nothing out, so the list reports a height for the
// scroll to aim at
vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockReturnValue(500)
stubTags([makeTag(TAG_A, "Production", "#1a9e4a")])
mockEndpoint("POST", "/api/tags", () => ({ id: TAG_B }))
mockEndpoint("POST", BRANCH_TAGS_URL, () => ({}))
const wrapper = await mountTags()
await openPicker(wrapper)
await search("Rollout")
pressEnter()
await nextTick()
expect(pickerItems()[0]?.parentElement?.scrollTop).toBe(500)
})
it("creates nothing on enter for a name that already exists", async ({
expect,
}) => {

View File

@ -49,6 +49,7 @@ const documentTags = computed(() => {
const rowElem = useTemplateRef<HTMLElement>("tag-row")
const triggerElem = useTemplateRef<HTMLElement>("tag-trigger")
const listElem = useTemplateRef<HTMLElement>("tag-list")
const rowParentElem = computed(() => rowElem.value?.parentElement ?? null)
// the count settles by trying one more pill until the row overflows, then
// stepping back. tooMany remembers the count that overflowed, so the two
@ -115,14 +116,16 @@ useResizeObserver([rowElem, rowParentElem], () => {
void measureOnResize()
})
// the search is emptied on the way in rather than on the way out: the
// list is still on screen while the menu fades, and clearing it then
// shows the rows springing back to their full length mid-close
watch(pickerOpen, (isOpen) => {
if (isOpen) {
newColor.value = suggestedColor()
if (!isOpen) {
return
}
query.value = ""
newColor.value = suggestedColor()
})
// the swatches come from theme variables, which resolve to oklch, while a
@ -205,21 +208,31 @@ async function toggleTag(tag: TagTreeElement) {
async function createAndAssign() {
const documentId = editorStore.activeDocumentId
const branchId = editorStore.activeBranchId
if (!documentId || !branchId || !trimmedQuery.value) {
const tagName = trimmedQuery.value
if (!documentId || !branchId || !tagName) {
return
}
// the search box is emptied before the request rather than after it.
// mutateAsync settles only once the refetch its success triggers has,
// and until then the typed name narrows the list to the one tag that
// matches it — the one just created
query.value = ""
try {
const created = await createTag.mutateAsync({
tagName: trimmedQuery.value,
const creating = createTag.mutateAsync({
tagName: tagName,
// the swatches resolve from theme variables, which are oklch; the
// stored colour is hex so it survives a theme change
color: colorToHex(
newColor.value ?? chartStyles().selectableColors.default,
),
})
// the tag is appended, so the row it adds is below the fold on a
// list long enough to scroll. onMutate has already put it there
void nextTick(scrollListToEnd)
query.value = ""
const created = await creating
// the tag just created holds a colour now, so the next one drawn has
// to weigh it in — the menu stays open across several creations
newColor.value = suggestedColor()
@ -234,6 +247,15 @@ async function createAndAssign() {
}
}
function scrollListToEnd() {
const list = listElem.value
if (!list) {
return
}
list.scrollTop = list.scrollHeight
}
// the create row is the only thing enter can act on: an existing tag is
// toggled from its own row, and a name already taken leaves nothing to
// create
@ -307,7 +329,7 @@ function handleSearchEnter() {
/>
<ShadcnUiDropdownMenuSeparator />
<template v-if="matchingTags.length">
<div class="max-h-47.5 overflow-y-auto">
<div ref="tag-list" class="max-h-47.5 overflow-y-auto">
<ShadcnUiDropdownMenuItem
v-for="tag in matchingTags"
:key="tag.id"

View File

@ -117,6 +117,7 @@ const {
duplicateDocument,
useFetchDocumentBranchesByDocId,
} = useDocumentAPI()
const { deleteTag } = useTagAPI()
const { fetchOrganization } = useAuthSession()
const { t } = useI18n({ useScope: "global" })
const { setEditable } = useEditorMeta()
@ -150,6 +151,10 @@ const pendingDocDeletion = ref<{
deleteDocument: () => Promise<void>
name: string
} | null>(null)
const pendingTagDeletion = ref<{
deleteTag: () => Promise<void>
name: string
} | null>(null)
const notificationSidebarOpen = ref(false)
const contentEditorRef = shallowRef<Editor | null>(null)
const nameEditorRef = shallowRef<Editor | null>(null)
@ -509,6 +514,19 @@ function handleDocumentDeletion(id: string | null | undefined) {
}
}
function handleTagDeletion(tag: { id: string; name: string }) {
pendingTagDeletion.value = {
name: tag.name,
deleteTag: async () => {
try {
await deleteTag.mutateAsync(tag.id)
} catch {
showToastMessage("error", t("sidebar.errors.delete-tag-failed"))
}
},
}
}
async function handleDocumentDuplication(id?: string) {
if (!id) {
id = activeDocMetadata.value?.id
@ -586,6 +604,7 @@ function clearLinkHighlightNodeOnce() {
@create-document="handleDocumentCreation"
@duplicate-document="handleDocumentDuplication"
@delete-document="handleDocumentDeletion"
@delete-tag="handleTagDeletion"
@open-settings="
(target) =>
target === 'org-members'
@ -610,6 +629,7 @@ function clearLinkHighlightNodeOnce() {
v-if="activeDocMetadata && allSectionsLoaded"
v-model="pendingDocDeletion"
/>
<TagDeletionModal v-model="pendingTagDeletion" />
<main class="w-full min-w-0 bg-background">
<EditorIconPickerProvider>
<DocumentHeader

View File

@ -24,7 +24,7 @@
"tags": {
"heading": "Tags",
"visibility-action-title": "Choose Visible Tags",
"all-hidden": "Every tag is hidden"
"all-hidden": "All tags are hidden"
},
"next-steps": {
"heading": "Next Steps",
@ -52,6 +52,12 @@
},
"item-placeholder-name": "Add Page",
"toggle-sidebar": "Toggle Sidebar",
"tag-deletion-modal": {
"title": "Delete Tag",
"description": "Are you sure you want to delete {name}? It will be removed from every page using it. This action cannot be undone.",
"confirm-button": "Delete tag",
"cancel-button": "Cancel"
},
"errors": {
"create-document-failed": "Failed to create a page. Please try again",
"update-tree-failed": "Failed to update the page location. Please try again",