fix: deleted image wrongfully caching
This commit is contained in:
1 parent
b3793d5dbb
commit
ff0854f331
19 files changed
+201
-87
No files matched your search
@@ -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=')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
Reference in new issue
Block a user