feat: entry editor at /content/:key/new and /content/:key/:id

This commit is contained in:
Florian Sylvain committed 2026-10-21 00:12:34 +02:00
1 parent 91d916fb74
commit fba20f83c5
94 files changed
+7116 -423

No files matched your search

+204 -4
View File
@@ -7095,7 +7095,7 @@ const contentRows = [
by,
]) => ({
id,
content_type_id: 1,
content_type: 'events',
data: {
title,
starts_on: startsOn,
@@ -7155,10 +7155,9 @@ function contentEntries(url: URL) {
total: 37,
page: 1,
size: 5,
sort: 'updated_at:desc',
}
}
return { items: contentRows, total: 7, page: 1, size: 25, sort: 'updated_at:desc' }
return { items: contentRows, total: 7, page: 1, size: 25 }
}
function contentApi(overrides: Record<string, ApiMock> = {}): Record<string, ApiMock> {
@@ -7205,7 +7204,7 @@ const contentStates: Record<string, VisualState> = {
api: contentApi({
'GET /v1/content-type/events/entry': {
status: 200,
body: { items: [], total: 0, page: 1, size: 25, sort: 'updated_at:desc' },
body: { items: [], total: 0, page: 1, size: 25 },
},
}),
},
@@ -7226,10 +7225,211 @@ const contentStates: Record<string, VisualState> = {
},
}
const entryDescription =
'Two days of hands-on work with **lime putty** and old stone.\n\n- Bring work clothes\n- Lunch is included'
function entryRecord(data: Record<string, unknown> = {}, rest: Record<string, unknown> = {}) {
return {
id: 42,
content_type: 'events',
data: {
title: 'Lime plaster workshop',
starts_on: '2026-11-21',
ends_on: '2026-11-22',
online: false,
capacity: 18,
category: 'Workshop',
description: entryDescription,
...data,
},
created_by: 4,
updated_by: 3,
created_at: '2026-10-12T09:14:00',
updated_at: '2026-10-15T11:02:00',
...rest,
}
}
function entryApi(overrides: Record<string, ApiMock> = {}): Record<string, ApiMock> {
return {
'GET /v1/auth/me': schemaSession('owner'),
'GET /v1/content-type': { status: 200, body: schemaCustomTypes },
'GET /v1/content-type/events': { status: 200, body: contentEventsType },
'GET /v1/content-type/events/entry/42': { status: 200, body: entryRecord() },
'GET /v1/user/lookup': { status: 200, body: contentUsers },
...overrides,
}
}
const entrySaved = entryRecord({}, { updated_by: 3, updated_at: '2026-10-19T11:02:00' })
function entryField(page: Page, key: string) {
return page.locator(`[data-entry-field="${key}"]`)
}
async function entryAppend(page: Page, key: string, text: string) {
const input = entryField(page, key).locator('input, textarea').first()
await input.click()
await input.press('End')
await input.pressSequentially(text)
}
async function entrySave(page: Page) {
await page.getByRole('button', { name: /^Save/ }).click()
}
async function entryCapacityEdit(page: Page) {
const input = entryField(page, 'capacity').getByRole('spinbutton')
await input.fill('18')
await input.press('Tab')
}
const entryLowCapacity = entryApi({
'GET /v1/content-type/events/entry/42': { status: 200, body: entryRecord({ capacity: 1 }) },
})
function entryEdited(overrides: Record<string, ApiMock>): Record<string, ApiMock> {
return { ...entryLowCapacity, ...overrides }
}
const entryStates: Record<string, VisualState> = {
'New entry': { path: '/content/events/new', api: entryApi() },
Editing: { path: '/content/events/42', api: entryApi() },
'Unsaved changes': {
path: '/content/events/42',
api: entryApi(),
steps: async (page) => {
await entryAppend(page, 'title', ', two days')
await page.mouse.move(0, 0)
},
},
'Markdown preview': {
path: '/content/events/42',
api: entryApi(),
steps: async (page) => {
await page.getByRole('tab', { name: 'Preview' }).click()
await schemaSettle(page)
},
},
Saving: {
path: '/content/events/42',
api: entryApi({ 'PUT /v1/content-type/events/entry/42': { status: 200, hang: true } }),
steps: async (page) => {
await entryAppend(page, 'title', ', two days')
await entrySave(page)
await page.getByText('Saving…').first().waitFor()
await schemaSettle(page)
},
},
Saved: {
path: '/content/events/42',
api: entryEdited({
'PUT /v1/content-type/events/entry/42': { status: 200, body: entrySaved },
}),
steps: async (page) => {
await entryCapacityEdit(page)
await entrySave(page)
await page.getByText('Entry saved').waitFor()
await schemaSettle(page)
},
},
'Save failed': {
path: '/content/events/42',
api: entryApi({ 'PUT /v1/content-type/events/entry/42': { status: 500 } }),
steps: async (page) => {
await entryAppend(page, 'title', ', two days')
await entrySave(page)
await page.getByText("Couldn't save the entry").waitFor()
await schemaSettle(page)
},
},
'Required fields empty': {
path: '/content/events/new',
api: entryApi(),
steps: async (page) => {
await entrySave(page)
await page.getByText('Fill in Title.').waitFor()
await schemaSettle(page)
},
},
'Rule errors': {
path: '/content/events/42',
api: entryApi({
'GET /v1/content-type/events/entry/42': {
status: 200,
body: entryRecord({
title: 'Lime plaster workshop, two days of hands-on work with the whole team on site and lunc',
ends_on: '31/11/2026',
capacity: 6000,
category: 'Seminar',
}),
},
}),
steps: async (page) => {
await entryAppend(page, 'title', 'h')
await entrySave(page)
await page.getByText('Title can be up to 80 characters.').waitFor()
await schemaSettle(page)
},
},
'Duplicate unique value (409)': {
path: '/content/events/42',
api: entryApi({
'PUT /v1/content-type/events/entry/42': {
status: 409,
body: 'invalid_entry: title: unique',
},
}),
steps: async (page) => {
const input = entryField(page, 'title').locator('input')
await input.fill('Heat pumps: what the numbers say')
await entrySave(page)
await page.getByText('Another entry already uses this Title.').first().waitFor()
await schemaSettle(page)
},
},
'Entry not found': {
path: '/content/events/999',
api: entryApi({
'GET /v1/content-type/events/entry/999': { status: 404, body: 'entry_not_found' },
}),
},
'Schema changed since load': {
path: '/content/events/42',
api: entryEdited({
'PUT /v1/content-type/events/entry/42': {
status: 422,
body: 'invalid_entry: room: required',
},
}),
steps: async (page) => {
await entryCapacityEdit(page)
await entrySave(page)
await page.getByText('The fields of Events changed').waitFor()
await schemaSettle(page)
},
exception: {
maxPercent: { desktop: 16, mobile: 13 },
reason: 'The light board screenshot of this state contains a stray miniature of the delete dialog state, about 12.6 % of the frame on its own',
approvedBy: 'user',
},
},
'Delete entry: confirm': {
path: '/content/events/42',
api: entryApi(),
steps: async (page) => {
await page.getByRole('button', { name: 'Delete', exact: true }).click()
await page.getByRole('alertdialog').waitFor()
await page.mouse.move(0, 0)
},
},
}
export const visualStates: Record<string, Record<string, VisualState>> = {
sites: sitesStates,
schema: schemaStates,
content: contentStates,
entry: entryStates,
authors: authorsStates,
activity: activityStates,
profile: {