feat: articles format option with markdown new default

This commit is contained in:
fsylvain committed 2026-10-04 19:04:12 +02:00
1 parent 68401d66e0
commit 7e9bd9ba30
61 files changed
+2269 -243

No files matched your search

+3
View File
@@ -10,6 +10,7 @@
"@tiptap/core": "3.31.3",
"@tiptap/extension-drag-handle-vue-3": "^3.31.3",
"@tiptap/extension-image": "3.31.3",
"@tiptap/extension-text-align": "3.31.3",
"@tiptap/pm": "3.31.3",
"@tiptap/vue-3": "3.31.3",
"@vueuse/core": "14.4.0",
@@ -425,6 +426,8 @@
"@tiptap/extension-text": ["@tiptap/extension-text@3.31.3", "", { "peerDependencies": { "@tiptap/core": "3.31.3" } }, "sha512-gdsWtF+taeaCu6V+5Ct10fGo0ACUy1GnYtbb+mcathBt8OqbT+Ws60p/yEmKesBDz2Hn+B5IcWy6+2BBZl5ZTg=="],
"@tiptap/extension-text-align": ["@tiptap/extension-text-align@3.31.3", "", { "peerDependencies": { "@tiptap/core": "3.31.3" } }, "sha512-26BJ7c28EyvHzJovNpuenKiIgfRI4KsT/OjBB00y58MyZ0kRXc6dwZVclw8y66clupKTLTWYCDUsy0P/xHj+cA=="],
"@tiptap/extension-underline": ["@tiptap/extension-underline@3.31.3", "", { "peerDependencies": { "@tiptap/core": "3.31.3" } }, "sha512-HghdJaOwRqYzsAxqSyNyb+IWyOMcdCl8IoiBETA9BZCJAqdXzFLcuWp7CqCqJPam9dgkWosSoHqTCAO4nTBfpw=="],
"@tiptap/extensions": ["@tiptap/extensions@3.31.3", "", { "peerDependencies": { "@tiptap/core": "3.31.3", "@tiptap/pm": "3.31.3" } }, "sha512-8sJNPGGUe8f3aDojcOW5cfVL7I5NrBbE0UWxG08qoi9Tea6qWbvQJsCR9tsrOapr/DaLr3kpbGZ9s1gEEfcNcA=="],
+1
View File
@@ -21,6 +21,7 @@
"@tiptap/core": "3.31.3",
"@tiptap/extension-drag-handle-vue-3": "^3.31.3",
"@tiptap/extension-image": "3.31.3",
"@tiptap/extension-text-align": "3.31.3",
"@tiptap/pm": "3.31.3",
"@tiptap/vue-3": "3.31.3",
"@vueuse/core": "14.4.0",
+2
View File
@@ -5,6 +5,7 @@ import { ref } from 'vue'
import type { EditorToolbarItem } from '@nuxt/ui'
import type { Editor } from '@tiptap/core'
import { ArticleIndent } from '@/lib/articleIndent'
import { ArticleTextAlign } from '@/lib/articleAlign'
import { ArticleImage, findArticleImagePos } from '@/lib/articleImage'
const props = withDefaults(
@@ -27,6 +28,7 @@ const emit = defineEmits<{
const editorInstance = ref<Editor | null>(null)
const extensions = [
ArticleIndent,
ArticleTextAlign,
ArticleImage.configure({
onRequestRemove: (imageId: number) => emit('request-remove-image', imageId),
}),
+1 -1
View File
@@ -120,7 +120,7 @@ async function onSubmit() {
formAlert.value = ''
submitting.value = true
try {
const article = await api.request<Article>('/v1/article/', {
const article = await api.request<Article>('/v1/article/?format=html', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: title.value, body: body.value }),
@@ -56,6 +56,35 @@ afterEach(async () => {
await router.push('/login')
})
describe('the alignment toolbar buttons align the current block', () => {
const ALIGNMENTS = ['left', 'center', 'right', 'justify']
const FIRST_ALIGN_BUTTON = 7
function alignButton(name: string) {
const buttons = document.body.querySelectorAll<HTMLButtonElement>('.article-editor button')
return buttons[FIRST_ALIGN_BUTTON + ALIGNMENTS.indexOf(name)] ?? null
}
it.each(ALIGNMENTS)('keeps the %s button enabled', async (name) => {
await mountArticleEditor()
const button = alignButton(name)
expect(button).not.toBeNull()
expect(button?.disabled).toBe(false)
})
it('writes the alignment into the body when a button is clicked', async () => {
const wrapper = await mountArticleEditor()
const editor = wrapper.findComponent(ArticleEditor)
alignButton('center')?.click()
await flushPromises()
expect(editor.props('modelValue')).toContain('text-align: center')
})
})
describe('insertImage stamps the inserted image with its numeric id', () => {
it('renders data-image-id on the img and emits the updated body', async () => {
const wrapper = await mountArticleEditor()
@@ -206,7 +206,7 @@ describe('Submitting disables the form and cannot be dismissed', () => {
await clickButton('Create draft')
expect(requestMock).toHaveBeenCalledWith(
'/v1/article/',
'/v1/article/?format=html',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ title: TITLE, body: BODY }),
@@ -336,7 +336,7 @@ describe('Retrying after an Error succeeds', () => {
expect(requestMock).toHaveBeenCalledTimes(2)
expect(requestMock).toHaveBeenNthCalledWith(
2,
'/v1/article/',
'/v1/article/?format=html',
expect.objectContaining({
body: JSON.stringify({ title: TITLE, body: BODY }),
}),
@@ -0,0 +1,23 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const request = vi.fn()
vi.mock('@/composables/useApi', () => ({
useApi: () => ({ request }),
}))
import { useArticles } from '@/composables/useArticles'
describe('useArticles', () => {
beforeEach(() => {
request.mockReset()
})
it('asks the API for html bodies when listing articles', async () => {
request.mockResolvedValue([])
await useArticles().listArticles()
expect(request).toHaveBeenCalledWith('/v1/article/?format=html')
})
})
+1 -1
View File
@@ -79,7 +79,7 @@ export function useArticleDraft(articleId: number) {
async function save(): Promise<SaveOutcome> {
businessState.value = 'saving'
try {
await request<Article>(`/v1/article/${articleId}`, {
await request<Article>(`/v1/article/${articleId}?format=html`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: body.value }),
+1 -1
View File
@@ -13,7 +13,7 @@ export function useArticles() {
const { request } = useApi()
function listArticles(): Promise<Article[]> {
return request<Article[]>('/v1/article/')
return request<Article[]>('/v1/article/?format=html')
}
return { listArticles }
+2 -2
View File
@@ -393,7 +393,7 @@ describe('Hardener localization outcomes', () => {
]).toEqual([window.location.origin, true, true, true])
await vi.waitFor(() =>
expect(document.body.textContent).toContain(
`const res = await fetch('${window.location.origin}/v1/article/')`,
`const res = await fetch('${window.location.origin}/v1/article/?format=html')`,
),
)
vi.unstubAllGlobals()
@@ -411,7 +411,7 @@ describe('Hardener localization outcomes', () => {
updated_at: '2025-02-14T12:00:00Z',
}
request.mockImplementation(async (url: string) =>
url === '/v1/article/' ? [article] : article,
url === '/v1/article/?format=html' ? [article] : article,
)
await mountRoute(path)
if (path === '/article') {
@@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest'
import { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import { ArticleIndent } from '../articleIndent'
import { ArticleTextAlign } from '../articleAlign'
function createEditor(content: string) {
return new Editor({
extensions: [StarterKit, ArticleIndent, ArticleTextAlign],
content,
})
}
describe('Aligning text writes text-align on paragraphs and headings', () => {
it.each(['center', 'right', 'justify'])('can align a paragraph to the %s', (align) => {
const editor = createEditor('<p>Hello</p>')
editor.commands.setTextSelection(2)
expect(editor.can().setTextAlign(align)).toBe(true)
editor.commands.setTextAlign(align)
expect(editor.getHTML()).toContain(`<p style="text-align: ${align};">Hello</p>`)
expect(editor.isActive({ textAlign: align })).toBe(true)
editor.destroy()
})
it('leaves unaligned paragraphs and headings without a style', () => {
const editor = createEditor('<h2>Title</h2><p>Hello</p>')
expect(editor.getHTML()).toBe('<h2>Title</h2><p>Hello</p>')
editor.destroy()
})
it('aligns a heading', () => {
const editor = createEditor('<h2>Title</h2>')
editor.commands.setTextSelection(2)
editor.commands.setTextAlign('right')
expect(editor.getHTML()).toContain('<h2 style="text-align: right;">Title</h2>')
editor.destroy()
})
it('returns to left alignment', () => {
const editor = createEditor('<p style="text-align: center">Hello</p>')
editor.commands.setTextSelection(2)
editor.commands.setTextAlign('left')
expect(editor.isActive({ textAlign: 'center' })).toBe(false)
expect(editor.getHTML()).not.toContain('center')
editor.destroy()
})
it('loads stored alignment back from HTML', () => {
const editor = createEditor('<p style="text-align: justify">Hello</p>')
editor.commands.setTextSelection(2)
expect(editor.isActive({ textAlign: 'justify' })).toBe(true)
editor.destroy()
})
it('keeps alignment and indentation together', () => {
const editor = createEditor('<p>Hello</p>')
editor.commands.setTextSelection(2)
editor.commands.indent()
editor.commands.setTextAlign('center')
expect(editor.getHTML()).toContain('data-indent="1"')
expect(editor.getHTML()).toContain('text-align: center')
editor.destroy()
})
})
+8
View File
@@ -0,0 +1,8 @@
import TextAlign from '@tiptap/extension-text-align'
export const ALIGN_TYPES = ['heading', 'paragraph']
export const ArticleTextAlign = TextAlign.configure({
types: ALIGN_TYPES,
alignments: ['left', 'center', 'right', 'justify'],
})
+1 -1
View File
@@ -188,7 +188,7 @@ async function setOnline(article: Article, makeOnline: boolean) {
setBusy(article.id, true)
try {
const updated = await request<Article>(
`/v1/article/${article.id}/${makeOnline ? 'publish' : 'unpublish'}`,
`/v1/article/${article.id}/${makeOnline ? 'publish' : 'unpublish'}?format=html`,
{ method: 'PATCH' },
)
articles.value = articles.value.map((existing) =>
+2 -2
View File
@@ -65,7 +65,7 @@ async function loadArticle() {
}, SKELETON_DELAY_MS)
try {
const result = await request<Article>(`/v1/article/${articleId}`)
const result = await request<Article>(`/v1/article/${articleId}?format=html`)
article.value = result
draft.load(result.body, new Date(result.updated_at))
loadState.value = 'loaded'
@@ -128,7 +128,7 @@ async function setOnline(makeOnline: boolean) {
publishPending.value = makeOnline ? t('Publishing…') : t('Unpublishing…')
try {
const updated = await request<Article>(
`/v1/article/${articleId}/${makeOnline ? 'publish' : 'unpublish'}`,
`/v1/article/${articleId}/${makeOnline ? 'publish' : 'unpublish'}?format=html`,
{ method: 'PATCH' },
)
article.value = updated
+5 -4
View File
@@ -32,6 +32,7 @@ const sampleArticle = {
updated_at: '2026-03-12T09:14:00Z',
},
],
body_format: 'html',
is_online: true,
created_at: '2026-03-12T09:14:00Z',
updated_at: '2026-09-10T07:00:00Z',
@@ -47,8 +48,8 @@ const endpoints = computed(() => [
return t('Lists every online article.')
},
examples: {
fetch: `const res = await fetch('${origin.value}/v1/article/')\nconst articles = await res.json()`,
curl: `curl ${origin.value}/v1/article/`,
fetch: `const res = await fetch('${origin.value}/v1/article/?format=html')\nconst articles = await res.json()`,
curl: `curl ${origin.value}/v1/article/?format=html`,
response: JSON.stringify(sampleArticleList, null, 2),
},
},
@@ -61,8 +62,8 @@ const endpoints = computed(() => [
)
},
examples: {
fetch: `const res = await fetch('${origin.value}/v1/article/12')\nif (res.status === 404) return notFound()\nconst article = await res.json()`,
curl: `curl ${origin.value}/v1/article/12`,
fetch: `const res = await fetch('${origin.value}/v1/article/12?format=html')\nif (res.status === 404) return notFound()\nconst article = await res.json()`,
curl: `curl ${origin.value}/v1/article/12?format=html`,
response: JSON.stringify(sampleArticle, null, 2),
},
},
+26 -26
View File
@@ -192,7 +192,7 @@ afterEach(async () => {
describe('Default — the list loads sorted by last updated, newest first', () => {
it('shows the breadcrumb, tab counts and every row in fixture order', async () => {
const wrapper = await mountArticles({
'GET /v1/article/': () => mockResponse(200, sixArticleFixture()),
'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
})
expect(wrapper.text()).toContain('All 6')
@@ -218,7 +218,7 @@ describe('Loading — skeleton rows while the first request is in flight', () =>
it('shows skeleton rows and aria-busy, then replaces them once resolved', async () => {
let resolveList: (() => void) | null = null
const wrapper = await mountArticles({
'GET /v1/article/': () =>
'GET /v1/article/?format=html': () =>
new Promise((resolve) => {
resolveList = () => resolve(mockResponse(200, sixArticleFixture()))
}),
@@ -239,7 +239,7 @@ describe('Loading — skeleton rows while the first request is in flight', () =>
describe('Row menu open shows Edit, the status-appropriate action, Copy API URL and Delete…', () => {
it('opens a menu named "Actions for Winter checklist for timber windows"', async () => {
const wrapper = await mountArticles({
'GET /v1/article/': () => mockResponse(200, sixArticleFixture()),
'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
})
await openRowMenu(wrapper, 'Winter checklist for timber windows')
@@ -260,7 +260,7 @@ describe('Copy API URL succeeds — the absolute URL is copied and confirmed', (
})
const wrapper = await mountArticles({
'GET /v1/article/': () => mockResponse(200, sixArticleFixture()),
'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
})
await openRowMenu(wrapper, 'Winter checklist for timber windows')
@@ -287,7 +287,7 @@ describe('Copy API URL fails — the clipboard write itself is rejected', () =>
})
const wrapper = await mountArticles({
'GET /v1/article/': () => mockResponse(200, sixArticleFixture()),
'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
})
await openRowMenu(wrapper, 'Winter checklist for timber windows')
@@ -305,7 +305,7 @@ describe('Copy API URL fails — the clipboard write itself is rejected', () =>
describe('Empty — zero articles', () => {
it('shows the no-articles empty state and opens NewArticleModal', async () => {
const wrapper = await mountArticles({
'GET /v1/article/': () => mockResponse(200, []),
'GET /v1/article/?format=html': () => mockResponse(200, []),
})
expect(wrapper.text()).toContain('All 0')
@@ -326,7 +326,7 @@ describe('Empty — zero articles', () => {
describe('Initial load failure — an error must not render the no-articles empty state', () => {
it('does not show "No articles yet" when GET /v1/article/ fails', async () => {
const wrapper = await mountArticles({
'GET /v1/article/': () => mockResponse(500),
'GET /v1/article/?format=html': () => mockResponse(500),
})
expect(wrapper.text()).not.toContain('No articles yet')
@@ -336,7 +336,7 @@ describe('Initial load failure — an error must not render the no-articles empt
describe('No results — a search matches nothing, but the tab counts still show the full list', () => {
it('shows the no-results empty state, then clears the search', async () => {
const wrapper = await mountArticles({
'GET /v1/article/': () => mockResponse(200, sixArticleFixture()),
'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
})
const searchInput = wrapper.find('input[type="search"]')
@@ -367,7 +367,7 @@ describe('An empty tab shows its own EmptyState, while the other tab still count
.slice(0, 4)
.map((a) => ({ ...a, is_online: true }))
const wrapper = await mountArticles({
'GET /v1/article/': () => mockResponse(200, onlineOnly),
'GET /v1/article/?format=html': () => mockResponse(200, onlineOnly),
})
await clickTab('Drafts 0')
@@ -384,7 +384,7 @@ describe('An empty tab shows its own EmptyState, while the other tab still count
.slice(0, 4)
.map((a) => ({ ...a, is_online: false }))
const wrapper = await mountArticles({
'GET /v1/article/': () => mockResponse(200, draftsOnly),
'GET /v1/article/?format=html': () => mockResponse(200, draftsOnly),
})
await clickTab('Online 0')
@@ -400,8 +400,8 @@ describe('An empty tab shows its own EmptyState, while the other tab still count
describe('Action in progress — publishing a row disables only that row', () => {
it('sends the PATCH and shows the busy label for that row alone', async () => {
const wrapper = await mountArticles({
'GET /v1/article/': () => mockResponse(200, sixArticleFixture()),
'PATCH /v1/article/4/publish': () => new Promise(() => {}),
'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
'PATCH /v1/article/4/publish?format=html': () => new Promise(() => {}),
})
await openRowMenu(wrapper, 'Winter checklist for timber windows')
@@ -420,8 +420,8 @@ describe('Published — a 200 updates the row in place and raises a toast', () =
updated_at: iso(0),
}
const wrapper = await mountArticles({
'GET /v1/article/': () => mockResponse(200, sixArticleFixture()),
'PATCH /v1/article/4/publish': () => mockResponse(200, updated),
'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
'PATCH /v1/article/4/publish?format=html': () => mockResponse(200, updated),
})
await openRowMenu(wrapper, 'Winter checklist for timber windows')
@@ -448,8 +448,8 @@ describe('Action failed, publish — a 500 or a network error leaves the row unc
])('shows Retry for $name and re-sends the same request', async ({ handler }) => {
const patchCall = vi.fn(handler)
const wrapper = await mountArticles({
'GET /v1/article/': () => mockResponse(200, sixArticleFixture()),
'PATCH /v1/article/4/publish': patchCall,
'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
'PATCH /v1/article/4/publish?format=html': patchCall,
})
await openRowMenu(wrapper, 'Winter checklist for timber windows')
@@ -487,8 +487,8 @@ describe('Action failed, unpublish — a 500 or a network error leaves the row u
])('shows Retry for $name and re-sends the same request', async ({ handler }) => {
const patchCall = vi.fn(handler)
const wrapper = await mountArticles({
'GET /v1/article/': () => mockResponse(200, sixArticleFixture()),
'PATCH /v1/article/3/unpublish': patchCall,
'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
'PATCH /v1/article/3/unpublish?format=html': patchCall,
})
await openRowMenu(wrapper, 'Before and after: the Rue Verte kitchen')
@@ -517,8 +517,8 @@ describe('Action failed, unpublish — a 500 or a network error leaves the row u
describe('Row vanished, draft row — a 404 on publish or delete removes the row and shows an info toast', () => {
it('removes the row on a 404 publish response', async () => {
const wrapper = await mountArticles({
'GET /v1/article/': () => mockResponse(200, sixArticleFixture()),
'PATCH /v1/article/2/publish': () => mockResponse(404),
'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
'PATCH /v1/article/2/publish?format=html': () => mockResponse(404),
})
await openRowMenu(wrapper, 'Choosing lime plaster for old stone walls')
@@ -540,7 +540,7 @@ describe('Row vanished, draft row — a 404 on publish or delete removes the row
it('removes the row when the delete confirmation resolves 404', async () => {
const wrapper = await mountArticles({
'GET /v1/article/': () => mockResponse(200, sixArticleFixture()),
'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
'DELETE /v1/article/2': () => mockResponse(404),
})
@@ -565,8 +565,8 @@ describe('Row vanished, draft row — a 404 on publish or delete removes the row
describe('Row vanished, online row — a 404 on unpublish removes the row and shows an info toast', () => {
it('removes the row and updates the tab counts', async () => {
const wrapper = await mountArticles({
'GET /v1/article/': () => mockResponse(200, sixArticleFixture()),
'PATCH /v1/article/3/unpublish': () => mockResponse(404),
'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
'PATCH /v1/article/3/unpublish?format=html': () => mockResponse(404),
})
await openRowMenu(wrapper, 'Before and after: the Rue Verte kitchen')
@@ -590,7 +590,7 @@ describe('Row vanished, online row — a 404 on unpublish removes the row and sh
describe('Delete confirm — opens the dialog with the row title and image count', () => {
it('opens ConfirmDialog with the title and 3 images', async () => {
const wrapper = await mountArticles({
'GET /v1/article/': () => mockResponse(200, sixArticleFixture()),
'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
})
await openRowMenu(wrapper, 'Winter checklist for timber windows')
@@ -611,7 +611,7 @@ describe('Delete confirm — opens the dialog with the row title and image count
describe('Deleted — a 204 closes the dialog, removes the row, and moves focus to the next row', () => {
it('removes the row, recomputes tabs and raises the success toast', async () => {
const wrapper = await mountArticles({
'GET /v1/article/': () => mockResponse(200, sixArticleFixture()),
'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
'DELETE /v1/article/4': () => mockResponse(204),
})
@@ -640,7 +640,7 @@ describe('Deleted — a 204 closes the dialog, removes the row, and moves focus
describe('Delete failed — a 500 keeps the article and rejects with the inline message', () => {
it('throws the inline error message and leaves the article in the list', async () => {
const wrapper = await mountArticles({
'GET /v1/article/': () => mockResponse(200, sixArticleFixture()),
'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
'DELETE /v1/article/4': () => mockResponse(500),
})
@@ -191,7 +191,7 @@ const EMPTY_LIBRARY = { items: [], next_cursor: null, counts: { all: 0, unused:
function articleHandlers(extra: Record<string, () => Response | Promise<Response>> = {}) {
return {
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()),
'GET /v1/media/?limit=24': () => mockResponse(200, EMPTY_LIBRARY),
...extra,
}
@@ -57,7 +57,7 @@ async function mountEditor(body = BODY_WITH_TWO_IMAGES) {
session.session = { verified: true, username: 'marta' }
session.status = 'ready'
global.fetch = vi.fn(async (url: string) => {
if (url === '/v1/article/12') {
if (url === '/v1/article/12?format=html') {
return mockResponse(200, articleFixture(body))
}
if (url === '/v1/media/?limit=24') {
+37 -37
View File
@@ -147,7 +147,7 @@ describe('Skeletons appear only after 150 ms, never for a fast response', () =>
it('shows no skeleton before 150 ms, a skeleton after, then the Saved state once resolved', async () => {
let resolveGet: (() => void) | null = null
const wrapper = await mountEditor({
'GET /v1/article/12': () =>
'GET /v1/article/12?format=html': () =>
new Promise((resolve) => {
resolveGet = () => resolve(mockResponse(200, articleFixture()))
}),
@@ -171,7 +171,7 @@ describe('Skeletons appear only after 150 ms, never for a fast response', () =>
it('never shows a skeleton when the response resolves in under 150 ms', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => Promise.resolve(mockResponse(200, articleFixture())),
'GET /v1/article/12?format=html': () => Promise.resolve(mockResponse(200, articleFixture())),
})
await vi.advanceTimersByTimeAsync(0)
@@ -190,7 +190,7 @@ describe('Saved — a freshly loaded article, draft or online', () => {
'reads the Saved state for is_online $is_online',
async ({ is_online, badge, publishLabel }) => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture({ is_online })),
'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture({ is_online })),
})
expect(wrapper.text()).toContain('Articles')
@@ -207,7 +207,7 @@ describe('Saved — a freshly loaded article, draft or online', () => {
describe('Unsaved changes — any edit marks the article dirty', () => {
it('shows Unsaved changes, enables Save and switches Publish to Save and publish', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()),
})
await typeBody(wrapper, paragraphOfLength(4320))
@@ -222,12 +222,12 @@ describe('Saving — Save sends the PATCH and disables itself while in flight',
it('sends the edited body and shows Saving…', async () => {
let patchBody: string | null = null
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()),
})
await typeBody(wrapper, paragraphOfLength(4320))
global.fetch = vi.fn((url: string, init?: RequestInit) => {
if (routeFor(url, init) === 'PATCH /v1/article/12') {
if (routeFor(url, init) === 'PATCH /v1/article/12?format=html') {
patchBody = init?.body as string
return new Promise(() => {})
}
@@ -248,13 +248,13 @@ describe('Saving — Save sends the PATCH and disables itself while in flight',
describe('Ctrl/⌘ S saves the same way as clicking Save', () => {
it('sends the same PATCH on Ctrl+S', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()),
})
await typeBody(wrapper, paragraphOfLength(4320))
let patchBody: string | null = null
global.fetch = vi.fn((url: string, init?: RequestInit) => {
if (routeFor(url, init) === 'PATCH /v1/article/12') {
if (routeFor(url, init) === 'PATCH /v1/article/12?format=html') {
patchBody = init?.body as string
return new Promise(() => {})
}
@@ -276,8 +276,8 @@ describe('A successful save returns to the Saved state with the new time', () =>
vi.setSystemTime(new Date('2026-09-26T11:30:00Z'))
let resolvePatch: (() => void) | null = null
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
'PATCH /v1/article/12': () =>
'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()),
'PATCH /v1/article/12?format=html': () =>
new Promise((resolve) => {
resolvePatch = () =>
resolve(
@@ -305,7 +305,7 @@ describe('A successful save returns to the Saved state with the new time', () =>
describe('SaveStatus switches to a relative time after an hour', () => {
it('reads "Saved n h ago" once more than an hour has passed', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()),
})
expect(wrapper.text()).toContain('Saved at 11:02')
@@ -327,8 +327,8 @@ describe('Save failed — a 500 or a network failure keeps the changes and offer
},
])('shows Save failed with Retry for $name', async ({ handler }) => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
'PATCH /v1/article/12': handler,
'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()),
'PATCH /v1/article/12?format=html': handler,
})
await typeBody(wrapper, paragraph('Revised opening paragraph.'))
await saveButton(wrapper).trigger('click')
@@ -354,8 +354,8 @@ describe('Retry resends the same body and can recover', () => {
),
)
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
'PATCH /v1/article/12': patchCall,
'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()),
'PATCH /v1/article/12?format=html': patchCall,
})
await typeBody(wrapper, paragraph('Revised opening paragraph.'))
await saveButton(wrapper).trigger('click')
@@ -373,8 +373,8 @@ describe('Retry resends the same body and can recover', () => {
describe('A save against an article deleted elsewhere shows a toast, not the error SaveStatus', () => {
it('shows the toast and never reads Save failed', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
'PATCH /v1/article/12': () => mockResponse(404),
'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()),
'PATCH /v1/article/12?format=html': () => mockResponse(404),
})
await typeBody(wrapper, paragraphOfLength(4320))
await saveButton(wrapper).trigger('click')
@@ -392,7 +392,7 @@ describe('A save against an article deleted elsewhere shows a toast, not the err
describe('Near the limit — the counter warns at 9000 characters without blocking Save', () => {
it('shows the warning icon and keeps Save enabled', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()),
})
await typeBody(wrapper, paragraphOfLength(9240))
@@ -405,7 +405,7 @@ describe('Near the limit — the counter warns at 9000 characters without blocki
describe('Too long — over 10000 characters disables Save and Publish until shortened', () => {
it('shows the over-limit text and disables Save and Publish, then re-enables once shortened', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()),
})
await typeBody(wrapper, paragraphOfLength(10214))
@@ -429,7 +429,7 @@ describe('Too long — over 10000 characters disables Save and Publish until sho
describe('Preview shows the rendered title and body without the editing surface', () => {
it('replaces the editor with a read-only rendering, then restores it unchanged', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () =>
'GET /v1/article/12?format=html': () =>
mockResponse(200, articleFixture({ body: '<p>Some body</p>' })),
})
@@ -454,7 +454,7 @@ describe('Preview shows the rendered title and body without the editing surface'
describe('Offline blocks Save and Publish, and recovers when the connection returns', () => {
it('shows the offline SaveStatus and re-enables Save once back online', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()),
})
await typeBody(wrapper, paragraphOfLength(4320))
@@ -478,7 +478,7 @@ describe('Offline blocks Save and Publish, and recovers when the connection retu
describe('Leaving with unsaved changes prompts, and "Discard changes" proceeds', () => {
it('opens ConfirmDialog with the exact copy and navigates away on Discard changes', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()),
})
await typeBody(wrapper, paragraphOfLength(4320))
@@ -507,7 +507,7 @@ describe('Leaving with unsaved changes prompts, and "Discard changes" proceeds',
describe('Leaving with unsaved changes, "Keep editing" stays on the page', () => {
it('keeps the user on the editor with the unsaved body intact', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()),
})
await typeBody(wrapper, paragraphOfLength(4320))
@@ -528,7 +528,7 @@ describe('Leaving with unsaved changes, "Keep editing" stays on the page', () =>
describe('Esc or the overlay on the leave guard behaves like "Keep editing"', () => {
it('keeps the user on /article/12/edit with the unsaved body intact', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()),
})
await typeBody(wrapper, paragraphOfLength(4320))
@@ -549,7 +549,7 @@ describe('Esc or the overlay on the leave guard behaves like "Keep editing"', ()
describe('Leaving a clean article never prompts', () => {
it('navigates directly with no ConfirmDialog', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()),
})
const articlesLink = wrapper.findAll('a').find((a) => a.attributes('href') === '/article')
@@ -564,8 +564,8 @@ describe('Leaving a clean article never prompts', () => {
describe('Session expired mid-save keeps the draft and restores it after logging back in', () => {
it('mirrors the body to sessionStorage and restores it on the next mount', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
'PATCH /v1/article/12': () => mockResponse(401),
'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()),
'PATCH /v1/article/12?format=html': () => mockResponse(401),
})
const body = paragraph('Paragraph added just before the session expired.')
await typeBody(wrapper, body)
@@ -581,7 +581,7 @@ describe('Session expired mid-save keeps the draft and restores it after logging
session.session = { verified: true, username: 'marta' }
session.status = 'ready'
global.fetch = fetchRouter({
'GET /v1/article/12': () =>
'GET /v1/article/12?format=html': () =>
mockResponse(
200,
articleFixture({
@@ -624,7 +624,7 @@ function requestCalls(wrapper: Awaited<ReturnType<typeof mount>>) {
describe('Requesting removal of an inline image opens the neutral confirm dialog', () => {
it('opens ConfirmDialog with the exact copy and sends nothing', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleWithImageFixture()),
'GET /v1/article/12?format=html': () => mockResponse(200, articleWithImageFixture()),
})
await requestRemoveImage(wrapper)
@@ -637,13 +637,13 @@ describe('Requesting removal of an inline image opens the neutral confirm dialog
expect(props.confirmLabel).toBe('Remove image')
expect(props.cancelLabel).toBe('Cancel')
expect(props.tone).toBe('neutral')
expect(requestCalls(wrapper)).toEqual(['GET /v1/article/12'])
expect(requestCalls(wrapper)).toEqual(['GET /v1/article/12?format=html'])
expect(articleEditorBody(wrapper)).toContain('data-image-id="10"')
})
it('previews the image the session has seen with its filename and size', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleWithImageFixture()),
'GET /v1/article/12?format=html': () => mockResponse(200, articleWithImageFixture()),
})
useSeenImagesStore().remember([{ id: 10, filename: 'before.png', size: 4089446 }])
@@ -659,7 +659,7 @@ describe('Requesting removal of an inline image opens the neutral confirm dialog
it('previews only the thumbnail for an image the session has not seen', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleWithImageFixture()),
'GET /v1/article/12?format=html': () => mockResponse(200, articleWithImageFixture()),
})
await requestRemoveImage(wrapper)
@@ -676,7 +676,7 @@ describe('Requesting removal of an inline image opens the neutral confirm dialog
describe('Cancelling the confirm changes nothing', () => {
it('keeps the image and the draft state when the dialog resolves to cancel', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleWithImageFixture()),
'GET /v1/article/12?format=html': () => mockResponse(200, articleWithImageFixture()),
})
await requestRemoveImage(wrapper)
@@ -686,14 +686,14 @@ describe('Cancelling the confirm changes nothing', () => {
expect(articleEditorBody(wrapper)).toContain('data-image-id="10"')
expect(wrapper.text()).not.toContain('Unsaved changes')
expect(toastAdd).not.toHaveBeenCalled()
expect(requestCalls(wrapper)).toEqual(['GET /v1/article/12'])
expect(requestCalls(wrapper)).toEqual(['GET /v1/article/12?format=html'])
})
})
describe('Confirming removes the image from the body only, marks the draft dirty and toasts', () => {
it('removes the image from the content, reads Unsaved changes and sends no request', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleWithImageFixture()),
'GET /v1/article/12?format=html': () => mockResponse(200, articleWithImageFixture()),
})
await requestRemoveImage(wrapper)
@@ -710,7 +710,7 @@ describe('Confirming removes the image from the body only, marks the draft dirty
title: 'Image removed',
description: "It's still in your library.",
})
expect(requestCalls(wrapper)).toEqual(['GET /v1/article/12'])
expect(requestCalls(wrapper)).toEqual(['GET /v1/article/12?format=html'])
})
})
@@ -718,7 +718,7 @@ describe('An unknown or deleted article id replaces the editor with an error sta
it('shows the not-found error state and navigates to /article', async () => {
const wrapper = await mountEditor(
{
'GET /v1/article/9999': () => mockResponse(404),
'GET /v1/article/9999?format=html': () => mockResponse(404),
},
'/article/9999/edit',
)
@@ -35,7 +35,7 @@ async function mountHome({ role, status, width = DESKTOP_WIDTH }: MountOptions)
session.session = { verified: true, username: 'marta', role }
session.status = 'ready'
global.fetch = vi.fn((url: string) => {
if (url === '/v1/article/') {
if (url === '/v1/article/?format=html') {
return Promise.resolve(mockResponse(200, []))
}
if (url === '/v1/system/update') {
@@ -35,7 +35,7 @@ async function mountHome(role: UserRole, status: UpdateStatus) {
session.session = { verified: true, username: 'marta', role }
session.status = 'ready'
global.fetch = vi.fn((url: string) => {
if (url === '/v1/article/') {
if (url === '/v1/article/?format=html') {
return Promise.resolve(mockResponse(200, []))
}
if (url === '/v1/system/update') {
+21 -21
View File
@@ -136,7 +136,7 @@ describe('Skeletons appear only after 150 ms, never for a fast response', () =>
vi.useFakeTimers()
let resolveList: (() => void) | null = null
const wrapper = await mountHome({
'GET /v1/article/': () =>
'GET /v1/article/?format=html': () =>
new Promise((resolve) => {
resolveList = () => resolve(mockResponse(200, sixArticles))
}),
@@ -160,7 +160,7 @@ describe('Skeletons appear only after 150 ms, never for a fast response', () =>
it('never shows a skeleton when the response resolves in under 150 ms', async () => {
vi.useFakeTimers()
const wrapper = await mountHome({
'GET /v1/article/': () => Promise.resolve(mockResponse(200, sixArticles)),
'GET /v1/article/?format=html': () => Promise.resolve(mockResponse(200, sixArticles)),
})
await vi.advanceTimersByTimeAsync(0)
@@ -174,7 +174,7 @@ describe('Skeletons appear only after 150 ms, never for a fast response', () =>
describe('Default — the list loaded with a mix of online and draft articles', () => {
it('shows the subtitle, stat tiles and the first 5 articles in order', async () => {
const wrapper = await mountHome({
'GET /v1/article/': () => mockResponse(200, sixArticles),
'GET /v1/article/?format=html': () => mockResponse(200, sixArticles),
})
expect(wrapper.text()).toContain("Here's where things stand on your site.")
@@ -217,8 +217,8 @@ describe('A successful create hands off to the editor with a toast', () => {
updated_at: '2026-01-01T00:00:00Z',
}
const wrapper = await mountHome({
'GET /v1/article/': () => mockResponse(200, sixArticles),
'POST /v1/article/': () => mockResponse(201, created),
'GET /v1/article/?format=html': () => mockResponse(200, sixArticles),
'POST /v1/article/?format=html': () => mockResponse(201, created),
})
await findButton(wrapper, 'New article')?.trigger('click')
@@ -258,7 +258,7 @@ describe('A successful create hands off to the editor with a toast', () => {
describe('Empty, first run — zero articles', () => {
it('shows the empty state and opens NewArticleModal', async () => {
const wrapper = await mountHome({
'GET /v1/article/': () => mockResponse(200, []),
'GET /v1/article/?format=html': () => mockResponse(200, []),
})
expect(wrapper.text()).toContain('Welcome to RenewCMS.')
@@ -284,7 +284,7 @@ describe('Error — a 500 or a network failure shows the same inline alert', ()
},
])('shows the alert with identical copy for $name', async ({ handler }) => {
const wrapper = await mountHome({
'GET /v1/article/': handler,
'GET /v1/article/?format=html': handler,
})
expect(wrapper.text()).toContain("Here's where things stand on your site.")
@@ -304,7 +304,7 @@ describe('Retry re-issues the request and recovers into the Default state', () =
.mockResolvedValueOnce(mockResponse(500))
.mockResolvedValueOnce(mockResponse(200, sixArticles))
const wrapper = await mountHome({
'GET /v1/article/': listCall,
'GET /v1/article/?format=html': listCall,
})
expect(wrapper.find('[role="alert"]').exists()).toBe(true)
@@ -323,7 +323,7 @@ describe('The list is fetched exactly once per navigation', () => {
it('calls GET /v1/article/ exactly once and derives both stats and the list from it', async () => {
const listCall = vi.fn(() => mockResponse(200, sixArticles))
const wrapper = await mountHome({
'GET /v1/article/': listCall,
'GET /v1/article/?format=html': listCall,
})
expect(listCall).toHaveBeenCalledTimes(1)
@@ -344,7 +344,7 @@ describe('The greeting matches the hour of day', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 0, 1, hour, 0, 0))
const wrapper = await mountHome({
'GET /v1/article/': () => mockResponse(200, sixArticles),
'GET /v1/article/?format=html': () => mockResponse(200, sixArticles),
})
expect(wrapper.text()).toContain(`${greeting}, marta`)
@@ -357,23 +357,23 @@ describe('The greeting is identical across every load state at a fixed hour', ()
vi.setSystemTime(new Date(2026, 0, 1, 9, 0, 0))
const loadingWrapper = await mountHome({
'GET /v1/article/': () => new Promise(() => {}),
'GET /v1/article/?format=html': () => new Promise(() => {}),
})
await vi.advanceTimersByTimeAsync(150)
expect(loadingWrapper.text()).toContain('Good morning, marta')
const defaultWrapper = await mountHome({
'GET /v1/article/': () => mockResponse(200, sixArticles),
'GET /v1/article/?format=html': () => mockResponse(200, sixArticles),
})
expect(defaultWrapper.text()).toContain('Good morning, marta')
const emptyWrapper = await mountHome({
'GET /v1/article/': () => mockResponse(200, []),
'GET /v1/article/?format=html': () => mockResponse(200, []),
})
expect(emptyWrapper.text()).toContain('Good morning, marta')
const errorWrapper = await mountHome({
'GET /v1/article/': () => mockResponse(500),
'GET /v1/article/?format=html': () => mockResponse(500),
})
expect(errorWrapper.text()).toContain('Good morning, marta')
})
@@ -394,7 +394,7 @@ describe('The greeting names the person by display name, else username', () => {
session.session = { verified: true, username: 'marta', displayName }
session.status = 'ready'
global.fetch = fetchRouter({
'GET /v1/article/': () => mockResponse(200, sixArticles),
'GET /v1/article/?format=html': () => mockResponse(200, sixArticles),
}) as unknown as typeof fetch
await router.push('/home')
const wrapper = mount(HomeView, {
@@ -414,7 +414,7 @@ describe('The greeting names the person by display name, else username', () => {
session.session = { verified: true, username: 'marta', displayName: '' }
session.status = 'ready'
global.fetch = fetchRouter({
'GET /v1/article/': () => mockResponse(200, sixArticles),
'GET /v1/article/?format=html': () => mockResponse(200, sixArticles),
}) as unknown as typeof fetch
await router.push('/home')
const wrapper = mount(HomeView, {
@@ -440,7 +440,7 @@ describe('The greeting handles a missing username', () => {
session.session = { verified: true }
session.status = 'ready'
global.fetch = fetchRouter({
'GET /v1/article/': () => mockResponse(200, sixArticles),
'GET /v1/article/?format=html': () => mockResponse(200, sixArticles),
}) as unknown as typeof fetch
await router.push('/home')
const wrapper = mount(HomeView, {
@@ -461,7 +461,7 @@ describe('The "Connect your website" card renders in every state', () => {
it('renders the same card while loading, in Default, Empty and Error', async () => {
vi.useFakeTimers()
const wrapper = await mountHome({
'GET /v1/article/': () => new Promise(() => {}),
'GET /v1/article/?format=html': () => new Promise(() => {}),
})
await vi.advanceTimersByTimeAsync(150)
expect(wrapper.text()).toContain('Connect your website')
@@ -471,17 +471,17 @@ describe('The "Connect your website" card renders in every state', () => {
vi.useRealTimers()
const defaultWrapper = await mountHome({
'GET /v1/article/': () => mockResponse(200, sixArticles),
'GET /v1/article/?format=html': () => mockResponse(200, sixArticles),
})
expect(defaultWrapper.text()).toContain('Connect your website')
const emptyWrapper = await mountHome({
'GET /v1/article/': () => mockResponse(200, []),
'GET /v1/article/?format=html': () => mockResponse(200, []),
})
expect(emptyWrapper.text()).toContain('Connect your website')
const errorWrapper = await mountHome({
'GET /v1/article/': () => mockResponse(500),
'GET /v1/article/?format=html': () => mockResponse(500),
})
expect(errorWrapper.text()).toContain('Connect your website')
})
@@ -71,12 +71,12 @@ describe("Default — the page loads with the app's own origin", () => {
expect(cardTexts).toHaveLength(2)
expect(cardTexts[0]).toContain('/v1/article/')
expect(cardTexts[0]).toContain('Lists every online article.')
expect(cardTexts[0]).toContain(`${STUB_ORIGIN}/v1/article/`)
expect(cardTexts[0]).toContain(`${STUB_ORIGIN}/v1/article/?format=html`)
expect(cardTexts[1]).toContain('/v1/article/{id}')
expect(cardTexts[1]).toContain(
'Returns one online article. Drafts and unknown ids both return 404.',
)
expect(cardTexts[1]).toContain(`${STUB_ORIGIN}/v1/article/12`)
expect(cardTexts[1]).toContain(`${STUB_ORIGIN}/v1/article/12?format=html`)
const fetchTabs = wrapper.findAll('[role="tab"]').filter((tab) => tab.text() === 'fetch')
expect(fetchTabs.length).toBeGreaterThan(0)
@@ -138,7 +138,7 @@ describe("Each EndpointCard's own Copy button is independent of the Base URL's",
await flushPromises()
const expectedCode =
`const res = await fetch('${STUB_ORIGIN}/v1/article/')\n` +
`const res = await fetch('${STUB_ORIGIN}/v1/article/?format=html')\n` +
'const articles = await res.json()'
expect(writeText).toHaveBeenCalledWith(expectedCode)
@@ -178,7 +178,7 @@ describe('curl and Response tabs render, unchecked visually', () => {
const firstCard = wrapper.findAllComponents(EndpointCard)[0]
await activateTab(firstCard.element as HTMLElement, 'curl')
expect(firstCard.text()).toContain(`curl ${STUB_ORIGIN}/v1/article/`)
expect(firstCard.text()).toContain(`curl ${STUB_ORIGIN}/v1/article/?format=html`)
await activateTab(firstCard.element as HTMLElement, 'Response')