fix: deleted image wrongfully caching

This commit is contained in:
fsylvain committed 2026-10-01 05:27:25 +02:00
1 parent b3793d5dbb
commit ff0854f331
19 files changed
+201 -87

No files matched your search

@@ -12,7 +12,7 @@ import (
"github.com/go-chi/chi/v5"
)
const cacheControl = "public, max-age=31536000, immutable"
const cacheControl = "public, no-cache"
const svgContentSecurityPolicy = "default-src 'none'; style-src 'unsafe-inline'; sandbox"
const fallbackContentType = "application/octet-stream"
+4 -4
View File
@@ -88,7 +88,7 @@ Rows update when the server responds, not optimistically. Search, tab and sort a
- Breadcrumbs: Articles (mobile title "Articles")
- Modal: **Delete this article?**
- text ""Winter checklist for timber windows" and its 3 images will be deleted. This can't be undone."
- text ""Winter checklist for timber windows" will be deleted. Its images stay in your library. This can't be undone."
- cancel "Cancel"
- confirm "Delete article" (danger)
@@ -98,7 +98,7 @@ Rows update when the server responds, not optimistically. Search, tab and sort a
- Breadcrumbs: Articles (mobile title "Articles")
- Modal: **Delete this article?**
- text ""Winter checklist for timber windows" and its 3 images will be deleted. This can't be undone."
- text ""Winter checklist for timber windows" will be deleted. Its images stay in your library. This can't be undone."
- cancel "Cancel"
- confirm "Deleting…" (danger), loading, not dismissible
@@ -108,7 +108,7 @@ Rows update when the server responds, not optimistically. Search, tab and sort a
- Breadcrumbs: Articles (mobile title "Articles")
- Modal: **Delete this article?**
- text ""Winter checklist for timber windows" and its 3 images will be deleted. This can't be undone."
- text ""Winter checklist for timber windows" will be deleted. Its images stay in your library. This can't be undone."
- Alert: "Couldn't delete the article, so it's still there. Try again in a moment."
- cancel "Cancel"
- confirm "Delete article" (danger)
@@ -118,5 +118,5 @@ Rows update when the server responds, not optimistically. Search, tab and sort a
**Trigger:** 204: the row is removed and focus moves to the next row. This is also where a delete from the editor ends up.
- Breadcrumbs: Articles (mobile title "Articles")
- Toast (success, circle-check): **Article deleted** ""Winter checklist for timber windows" and its images are gone."
- Toast (success, circle-check): **Article deleted** ""Winter checklist for timber windows" is gone."
- Tab counts (All, Online, Drafts): 5, 4, 1
+1 -1
View File
@@ -183,7 +183,7 @@ On mobile, Save and Publish sit in a sticky bottom bar and the details panel ope
- Breadcrumbs: Articles › How we price a bathroom renovation (mobile title "Edit article")
- Modal: **Delete this article?**
- text ""How we price a bathroom renovation" and its 3 images will be deleted. This can't be undone."
- text ""How we price a bathroom renovation" will be deleted. Its images stay in your library. This can't be undone."
- cancel "Cancel"
- confirm "Delete article" (danger)
- Editor: status "draft", save "saved"
+6 -6
View File
@@ -59,9 +59,9 @@ Feature: Articles list screen
remaining articles, and raises an info toast (icon info, id "article-not-found-{id}"): title
"That article no longer exists" text "It was deleted somewhere else, so we removed it from
the list."
And "Delete…" opens ConfirmDialog: title "Delete this article?" text "\"{title}\" and its
{n} images will be deleted. This can't be undone." (a components.md convention: the row's
title plus its image count, "and its image" for exactly 1 and no image clause at all for 0),
And "Delete…" opens ConfirmDialog: title "Delete this article?" text "\"{title}\" will be
deleted. Its images stay in your library. This can't be undone." (the second sentence is
present only when the article body holds at least one image),
"Cancel" and "Delete article" (danger); while the DELETE request is in flight the dialog
cannot be dismissed by Cancel, Esc or a backdrop click, both buttons are disabled, the
confirm button reads "Deleting…" with a loading indicator, and aria-busy is set
@@ -72,7 +72,7 @@ Feature: Articles list screen
And a successful delete (204) closes the dialog, removes the row, moves focus to the next row
(or the page heading if the deleted row was last), recomputes the tab counts, and raises a
success toast (icon circle-check, id "article-deleted-{id}"): title "Article deleted" text
"\"{title}\" and its images are gone."; this is also where a delete initiated from the editor
"\"{title}\" is gone."; this is also where a delete initiated from the editor
ends up
And the list, sorted by last updated with the most recently updated article first, holds
these 6 articles by default: id 1 "How we price a bathroom renovation" (online, updated "2
@@ -231,7 +231,7 @@ Feature: Articles list screen
Given the Default state has loaded, and id 4 "Winter checklist for timber windows" has 3 images
When the user chooses "Delete…" from id 4's row menu
Then ConfirmDialog opens: title "Delete this article?" text "\"Winter checklist for timber
windows\" and its 3 images will be deleted. This can't be undone.", "Cancel" and "Delete
windows\" will be deleted. Its images stay in your library. This can't be undone.", "Cancel" and "Delete
article" (danger)
Scenario: Delete in progress — the dialog cannot be dismissed while the request is in flight
@@ -256,4 +256,4 @@ Feature: Articles list screen
Then the dialog closes, id 4's row is removed from the list, and focus moves to the next row
And the tabs read "All 5" "Online 4" "Drafts 1"
And a success toast appears: icon circle-check, id "article-deleted-4", title "Article
deleted", text "\"Winter checklist for timber windows\" and its images are gone."
deleted", text "\"Winter checklist for timber windows\" is gone."
+1 -1
View File
@@ -114,7 +114,7 @@ as listed), unless a scenario's Examples row substitutes a smaller fixture.
18. **Deleted** — continuing from 16 (a fresh attempt), resolve the DELETE 204; assert the dialog
closes, id 4's row is removed, focus moves to the next row; assert tabs read "All 5" "Online
4" "Drafts 1"; assert a toast with id "article-deleted-4", icon circle-check, title "Article
deleted", text "\"Winter checklist for timber windows\" and its images are gone."
deleted", text "\"Winter checklist for timber windows\" is gone."
## Visual (manual verification — desktop and mobile, light and dark)
+8 -6
View File
@@ -23,9 +23,11 @@ Feature: Serving uploaded images as static files
"application/octet-stream"
And every response from this handler, success or 404, sets
"X-Content-Type-Options: nosniff"
And every successful response sets a long-lived, public Cache-Control
header, e.g. "public, max-age=31536000, immutable", because a stored
filename is a UUID and its bytes never change
And every successful response sets the Cache-Control header
"public, no-cache", so a browser may store the file but must revalidate
it before reusing it; a stored filename is a UUID and its bytes never
change, but the file can be deleted from the media library, and a
long-lived cache would keep showing it
And an "image/svg+xml" response additionally sets
"Content-Security-Policy: default-src 'none'; style-src
'unsafe-inline'; sandbox", so a script embedded in an uploaded SVG
@@ -42,7 +44,7 @@ Feature: Serving uploaded images as static files
cookie
Then the response status is 200
And the response Content-Type is <content_type>
And the response Cache-Control is "public, max-age=31536000, immutable"
And the response Cache-Control is "public, no-cache"
And the response X-Content-Type-Options is "nosniff"
And the response has no Content-Security-Policy header
And the response body is that file's exact content
@@ -61,7 +63,7 @@ Feature: Serving uploaded images as static files
with no jwt cookie
Then the response status is 200
And the response Content-Type is "image/svg+xml"
And the response Cache-Control is "public, max-age=31536000, immutable"
And the response Cache-Control is "public, no-cache"
And the response X-Content-Type-Options is "nosniff"
And the response Content-Security-Policy is
"default-src 'none'; style-src 'unsafe-inline'; sandbox"
@@ -128,7 +130,7 @@ Feature: Serving uploaded images as static files
with no jwt cookie
Then the response status is 200
And the response Content-Type is "image/webp"
And the response Cache-Control is "public, max-age=31536000, immutable"
And the response Cache-Control is "public, no-cache"
And the response X-Content-Type-Options is "nosniff"
And the response body is empty
+1 -1
View File
@@ -29,7 +29,7 @@ Package: `test` (black-box, new file `test/serve_uploaded_images_test.go`).
non-empty byte content. For each, `httptest.NewRequest("GET",
"/static/uploadedImages/"+name, nil)`, no cookie. Assert status `200`;
`Content-Type` equals the expected value; `Cache-Control` equals
`"public, max-age=31536000, immutable"`; `X-Content-Type-Options` equals
`"public, no-cache"`; `X-Content-Type-Options` equals
`"nosniff"`; `Content-Security-Policy` header is absent; body bytes equal
what was written.
7. Write a `.svg` file whose content contains an inline `onload` handler
+3 -3
View File
@@ -64,7 +64,7 @@ func TestServeUploadedImagesHttp(t *testing.T) {
if got := rec.Header().Get("Content-Type"); got != example.contentType {
t.Fatalf("expected Content-Type %q, got %q", example.contentType, got)
}
if got := rec.Header().Get("Cache-Control"); got != "public, max-age=31536000, immutable" {
if got := rec.Header().Get("Cache-Control"); got != "public, no-cache" {
t.Fatalf("unexpected Cache-Control: %q", got)
}
if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" {
@@ -96,7 +96,7 @@ func TestServeUploadedImagesHttp(t *testing.T) {
if got := rec.Header().Get("Content-Type"); got != "image/svg+xml" {
t.Fatalf("expected Content-Type image/svg+xml, got %q", got)
}
if got := rec.Header().Get("Cache-Control"); got != "public, max-age=31536000, immutable" {
if got := rec.Header().Get("Cache-Control"); got != "public, no-cache" {
t.Fatalf("unexpected Cache-Control: %q", got)
}
if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" {
@@ -237,7 +237,7 @@ func TestServeUploadedImagesHttp(t *testing.T) {
if got := rec.Header().Get("Content-Type"); got != "image/webp" {
t.Fatalf("expected Content-Type image/webp, got %q", got)
}
if got := rec.Header().Get("Cache-Control"); got != "public, max-age=31536000, immutable" {
if got := rec.Header().Get("Cache-Control"); got != "public, no-cache" {
t.Fatalf("unexpected Cache-Control: %q", got)
}
if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" {
+34 -5
View File
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref, watch } from 'vue'
import { NodeViewWrapper, nodeViewProps } from '@tiptap/vue-3'
import { articleImageId } from '@/lib/articleImage'
import { mediaRevision, withMediaRevision } from '@/lib/mediaRevision'
const props = defineProps(nodeViewProps)
@@ -20,6 +21,19 @@ const imageAttrs = computed(() => {
return { src, alt, title, width, height, 'data-image-id': props.node.attrs['data-image-id'] }
})
const failed = ref(false)
const displayAttrs = computed(() => ({
...imageAttrs.value,
src: withMediaRevision(imageAttrs.value.src, mediaRevision.value),
}))
watch(
() => displayAttrs.value.src,
() => {
failed.value = false
},
)
function onDeleteClick() {
if (imageId.value === null) {
return
@@ -31,14 +45,29 @@ function onDeleteClick() {
<template>
<NodeViewWrapper
as="div"
class="relative block w-fit"
:class="{ 'outline-2 outline-primary rounded-md': selected }"
class="relative block"
:class="[failed ? 'w-full' : 'w-fit', { 'outline-2 outline-primary rounded-md': selected }]"
data-testid="article-editor-image"
@dragstart.prevent
>
<img v-bind="imageAttrs" draggable="false" class="block max-w-full" />
<div
v-if="failed"
data-testid="article-editor-image-missing"
class="flex min-h-40 flex-col items-center justify-center gap-2 border border-dashed border-accented bg-elevated p-6 text-center font-sans text-sm text-muted"
>
<UIcon name="i-lucide-image-off" class="size-6" />
<span class="font-medium text-highlighted">This image is no longer available</span>
<span>It was deleted from the library. Remove it from the article.</span>
</div>
<img
v-else
v-bind="displayAttrs"
draggable="false"
class="block max-w-full"
@error="failed = true"
/>
<UButton
v-if="showDeleteButton"
v-if="showDeleteButton || (failed && imageId !== null)"
data-testid="remove-image-button"
icon="i-lucide-trash-2"
color="neutral"
+9 -2
View File
@@ -2,6 +2,7 @@ import { ref } from 'vue'
import { tryOnBeforeUnmount } from '@vueuse/core'
import { ApiError, useApi } from '@/composables/useApi'
import { toRequestQuery, type MediaControls } from '@/lib/mediaQuery'
import { bumpMediaRevision } from '@/lib/mediaRevision'
export interface MediaItem {
id: number
@@ -150,6 +151,7 @@ export function useMedia() {
async function deleteItem(id: number) {
await request<void>(`/v1/media/${id}`, { method: 'DELETE' })
bumpMediaRevision()
removeLocal(id)
}
@@ -171,16 +173,21 @@ export function useMedia() {
const pick = (outcome: string) =>
outcomes.filter((entry) => entry.outcome === outcome).map((entry) => entry.item)
const deleted = pick('deleted')
if (deleted.length > 0) {
bumpMediaRevision()
}
items.value = items.value.filter((entry) => !deleted.some((gone) => gone.id === entry.id))
return { deleted, failed: pick('failed'), handled: pick('handled').length > 0 }
}
function mergeCopies(keepId: number, mergeIds: number[]) {
return request<MediaMergeResult>('/v1/media/merge', {
async function mergeCopies(keepId: number, mergeIds: number[]) {
const result = await request<MediaMergeResult>('/v1/media/merge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ keep_id: keepId, merge_ids: mergeIds }),
})
bumpMediaRevision()
return result
}
function removeLocalMany(ids: number[]) {
@@ -4,6 +4,7 @@ import { mount } from '@vue/test-utils'
import { Editor, EditorContent } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'
import { ArticleImage, findArticleImagePos } from '../articleImage'
import { bumpMediaRevision } from '../mediaRevision'
function mountHarness(onRequestRemove = vi.fn()) {
let editor: Editor
@@ -263,3 +264,68 @@ describe('removeImage removes the matching image node from the document', () =>
expect(findArticleImagePos(editor, 11)).not.toBeNull()
})
})
describe('An image that fails to load', () => {
it('shows a placeholder and keeps the remove button reachable without selecting it', async () => {
const { wrapper, getEditor } = mountHarness()
activeWrapper = wrapper
const editor = getEditor()
editor.commands.setArticleImage({
src: '/static/uploadedImages/gone.png',
alt: '',
imageId: 10,
})
await flush()
document.body.querySelector('img')!.dispatchEvent(new Event('error'))
await flush()
expect(document.body.querySelector('img')).toBeNull()
expect(
document.body.querySelector('[data-testid="article-editor-image-missing"]')
?.textContent,
).toContain('This image is no longer available')
expect(deleteButton()).toBeTruthy()
})
it('has no remove button for an old image without an id, but still shows the placeholder', async () => {
const { wrapper, getEditor } = mountHarness()
activeWrapper = wrapper
const editor = getEditor()
editor.commands.setArticleImage({ src: '/static/uploadedImages/gone.png', alt: '' })
await flush()
document.body.querySelector('img')!.dispatchEvent(new Event('error'))
await flush()
expect(
document.body.querySelector('[data-testid="article-editor-image-missing"]'),
).toBeTruthy()
expect(deleteButton()).toBeNull()
})
})
describe('An image shown after a media delete or merge', () => {
it('asks the server again by adding a display-only revision to uploaded image URLs', async () => {
const { wrapper, getEditor } = mountHarness()
activeWrapper = wrapper
const editor = getEditor()
editor.commands.setArticleImage({
src: '/static/uploadedImages/a.png',
alt: '',
imageId: 10,
})
await flush()
expect(document.body.querySelector('img')?.getAttribute('src')).toBe(
'/static/uploadedImages/a.png',
)
bumpMediaRevision()
await flush()
expect(document.body.querySelector('img')?.getAttribute('src')).toBe(
'/static/uploadedImages/a.png?rev=1',
)
expect(editor.getHTML()).not.toContain('rev=')
})
})
+1 -3
View File
@@ -54,9 +54,7 @@ describe('Outdenting a paragraph decreases the indent level', () => {
describe('Indenting inside a list item sinks it instead of touching the paragraph', () => {
it('nests the list item under the previous one', () => {
const editor = createEditor(
'<ul><li><p>First</p></li><li><p>Second</p></li></ul>',
)
const editor = createEditor('<ul><li><p>First</p></li><li><p>Second</p></li></ul>')
editor.commands.setTextSelection(editor.state.doc.content.size - 2)
const indented = editor.commands.indent()
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest'
import { withMediaRevision } from '../mediaRevision'
describe('withMediaRevision', () => {
it('leaves a URL alone while no media was deleted or merged', () => {
expect(withMediaRevision('/static/uploadedImages/a.png', 0)).toBe(
'/static/uploadedImages/a.png',
)
})
it('adds the revision to relative and absolute uploaded image URLs', () => {
expect(withMediaRevision('/static/uploadedImages/a.png', 2)).toBe(
'/static/uploadedImages/a.png?rev=2',
)
expect(
withMediaRevision('http://localhost:5173/static/uploadedImages/a.png?x=1#top', 3),
).toBe('http://localhost:5173/static/uploadedImages/a.png?x=1&rev=3#top')
})
it('never touches other URLs or an empty source', () => {
expect(withMediaRevision('https://example.com/a.png', 2)).toBe('https://example.com/a.png')
expect(withMediaRevision(undefined, 2)).toBeUndefined()
})
})
+9 -27
View File
@@ -5,56 +5,38 @@ const now = new Date('2026-08-25T12:00:00Z')
describe('formatRelativeDate', () => {
it('formats under an hour in minutes', () => {
expect(formatRelativeDate('2026-08-25T11:45:00Z', now)).toBe(
'15 minutes ago',
)
expect(formatRelativeDate('2026-08-25T11:45:00Z', now)).toBe('15 minutes ago')
})
it('clamps a sub-minute difference to "1 minute ago"', () => {
expect(formatRelativeDate('2026-08-25T11:59:45Z', now)).toBe(
'1 minute ago',
)
expect(formatRelativeDate('2026-08-25T11:59:45Z', now)).toBe('1 minute ago')
})
it('uses the singular for exactly one minute ago', () => {
expect(formatRelativeDate('2026-08-25T11:59:00Z', now)).toBe(
'1 minute ago',
)
expect(formatRelativeDate('2026-08-25T11:59:00Z', now)).toBe('1 minute ago')
})
it('formats under a day in hours', () => {
expect(formatRelativeDate('2026-08-25T10:00:00Z', now)).toBe(
'2 hours ago',
)
expect(formatRelativeDate('2026-08-25T10:00:00Z', now)).toBe('2 hours ago')
})
it('uses the singular for exactly one hour ago', () => {
expect(formatRelativeDate('2026-08-25T11:00:00Z', now)).toBe(
'1 hour ago',
)
expect(formatRelativeDate('2026-08-25T11:00:00Z', now)).toBe('1 hour ago')
})
it('formats exactly one day back as Yesterday', () => {
expect(formatRelativeDate('2026-08-24T11:00:00Z', now)).toBe(
'Yesterday',
)
expect(formatRelativeDate('2026-08-24T11:00:00Z', now)).toBe('Yesterday')
})
it('formats within the week in days', () => {
expect(formatRelativeDate('2026-08-22T12:00:00Z', now)).toBe(
'3 days ago',
)
expect(formatRelativeDate('2026-08-22T12:00:00Z', now)).toBe('3 days ago')
})
it('formats exactly 7 days ago as an absolute date, not "7 days ago"', () => {
expect(formatRelativeDate('2026-08-18T12:00:00Z', now)).toBe(
'18 Aug 2026',
)
expect(formatRelativeDate('2026-08-18T12:00:00Z', now)).toBe('18 Aug 2026')
})
it('formats a week or more ago as an absolute date', () => {
expect(formatRelativeDate('2026-08-10T12:00:00Z', now)).toBe(
'10 Aug 2026',
)
expect(formatRelativeDate('2026-08-10T12:00:00Z', now)).toBe('10 Aug 2026')
})
})
+5 -19
View File
@@ -12,9 +12,7 @@ declare module '@tiptap/core' {
}
}
function currentIndentableNode(state: {
selection: { $from: { parent: unknown } }
}) {
function currentIndentableNode(state: { selection: { $from: { parent: unknown } } }) {
return state.selection.$from.parent as {
type: { name: string }
attrs: { indent?: number }
@@ -30,8 +28,7 @@ export const ArticleIndent = Extension.create({
attributes: {
indent: {
default: 0,
parseHTML: (element) =>
Number(element.getAttribute('data-indent')) || 0,
parseHTML: (element) => Number(element.getAttribute('data-indent')) || 0,
renderHTML: (attributes) => {
if (!attributes.indent) {
return {}
@@ -55,17 +52,10 @@ export const ArticleIndent = Extension.create({
if (!INDENT_TYPES.includes(node.type.name)) {
return false
}
const level = Math.min(
INDENT_MAX,
(node.attrs.indent ?? 0) + 1,
)
const level = Math.min(INDENT_MAX, (node.attrs.indent ?? 0) + 1)
if (dispatch) {
const { $from } = state.selection
tr.setNodeAttribute(
$from.before($from.depth),
'indent',
level,
)
tr.setNodeAttribute($from.before($from.depth), 'indent', level)
dispatch(tr)
}
return true
@@ -83,11 +73,7 @@ export const ArticleIndent = Extension.create({
const level = Math.max(0, (node.attrs.indent ?? 0) - 1)
if (dispatch) {
const { $from } = state.selection
tr.setNodeAttribute(
$from.before($from.depth),
'indent',
level,
)
tr.setNodeAttribute($from.before($from.depth), 'indent', level)
dispatch(tr)
}
return true
+20
View File
@@ -0,0 +1,20 @@
import { ref } from 'vue'
const UPLOADED_IMAGES_PATH = '/static/uploadedImages/'
export const mediaRevision = ref(0)
export function bumpMediaRevision() {
mediaRevision.value += 1
}
export function withMediaRevision(src: string | undefined, revision = mediaRevision.value) {
if (!src || revision === 0 || !src.includes(UPLOADED_IMAGES_PATH)) {
return src
}
const hashAt = src.indexOf('#')
const base = hashAt === -1 ? src : src.slice(0, hashAt)
const hash = hashAt === -1 ? '' : src.slice(hashAt)
const separator = base.includes('?') ? '&' : '?'
return `${base}${separator}rev=${revision}${hash}`
}
+3 -3
View File
@@ -242,8 +242,8 @@ async function handleCopyUrl(id: number) {
function deleteDescription(article: Article) {
const count = bodyImageCount(article.body)
const imagesPhrase =
count === 0 ? '' : count === 1 ? ' and its image' : ` and its ${count} images`
return `"${article.title}"${imagesPhrase} will be deleted. This can't be undone.`
count === 0 ? '' : ' Its images stay in your library.'
return `"${article.title}" will be deleted.${imagesPhrase} This can't be undone.`
}
async function performDelete(article: Article) {
@@ -255,7 +255,7 @@ async function performDelete(article: Article) {
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Article deleted',
description: `"${article.title}" and its images are gone.`,
description: `"${article.title}" is gone.`,
})
} catch (err) {
if (err instanceof ApiError && err.status === 404) {
+3 -3
View File
@@ -167,8 +167,8 @@ function handleUnpublish() {
function deleteDescription() {
const count = bodyImageCount(article.value?.body ?? '')
const imagesPhrase =
count === 0 ? '' : count === 1 ? ' and its image' : ` and its ${count} images`
return `"${article.value?.title ?? ''}"${imagesPhrase} will be deleted. This can't be undone.`
count === 0 ? '' : ' Its images stay in your library.'
return `"${article.value?.title ?? ''}" will be deleted.${imagesPhrase} This can't be undone.`
}
async function performDelete() {
@@ -179,7 +179,7 @@ async function performDelete() {
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Article deleted',
description: `"${article.value?.title ?? ''}" and its images are gone.`,
description: `"${article.value?.title ?? ''}" is gone.`,
})
router.push('/article')
} catch (err) {
+2 -2
View File
@@ -599,7 +599,7 @@ describe('Delete confirm — opens the dialog with the row title and image count
const props = overlayMock.getProps() as Record<string, unknown>
expect(props.title).toBe('Delete this article?')
expect(props.description).toBe(
'"Winter checklist for timber windows" and its 3 images will be deleted. This can\'t be undone.',
'"Winter checklist for timber windows" will be deleted. Its images stay in your library. This can\'t be undone.',
)
expect(props.confirmLabel).toBe('Delete article')
expect(props.cancelLabel).toBe('Cancel')
@@ -632,7 +632,7 @@ describe('Deleted — a 204 closes the dialog, removes the row, and moves focus
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Article deleted',
description: '"Winter checklist for timber windows" and its images are gone.',
description: '"Winter checklist for timber windows" is gone.',
})
})
})