feat: entry editor at /content/:key/new and /content/:key/:id
This commit is contained in:
1 parent
91d916fb74
commit
fba20f83c5
94 files changed
+7116
-423
No files matched your search
@@ -0,0 +1,363 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import router from '@/router'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import {
|
||||
MOBILE_WIDTH,
|
||||
arrived,
|
||||
button,
|
||||
calls,
|
||||
chip,
|
||||
control,
|
||||
customTypes,
|
||||
entry42,
|
||||
entryWith,
|
||||
eventsFields,
|
||||
failWith,
|
||||
failWithBody,
|
||||
fieldWrapper,
|
||||
hasText,
|
||||
setField,
|
||||
typeInto,
|
||||
main,
|
||||
mountEditor,
|
||||
networkFailure,
|
||||
pending,
|
||||
text,
|
||||
unmountAll,
|
||||
} from './entryEditorKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
afterEach(unmountAll)
|
||||
|
||||
function entryRequests() {
|
||||
return calls.filter((url) => /\/entry\/\d+$/.test(url))
|
||||
}
|
||||
|
||||
describe('1 New entry', () => {
|
||||
it('shows the title, the subtitle, the chip and the actions', async () => {
|
||||
await mountEditor('/content/events/new')
|
||||
|
||||
expect(text(main())).toContain('New entry Events · Fields marked * are required.')
|
||||
expect(text(chip())).toBe('New entry')
|
||||
expect(button('Cancel')).toBeTruthy()
|
||||
expect(button('Save')!.disabled).toBe(false)
|
||||
expect(button('Delete')).toBeUndefined()
|
||||
expect(entryRequests()).toEqual([])
|
||||
})
|
||||
|
||||
it('marks required labels and shows helps, placeholders and defaults', async () => {
|
||||
await mountEditor('/content/events/new')
|
||||
|
||||
expect(text(fieldWrapper('title'))).toContain('Title *')
|
||||
expect(text(fieldWrapper('starts_on'))).toContain('Starts on *')
|
||||
expect(text(fieldWrapper('ends_on'))).not.toContain('*')
|
||||
expect(text(fieldWrapper('title'))).toContain('Each entry needs a different title.')
|
||||
expect(text(fieldWrapper('capacity'))).toContain('Between 1 and 5000.')
|
||||
expect(control('capacity').getAttribute('placeholder')).toBe('Enter a number')
|
||||
expect(text(fieldWrapper('category'))).toContain('Choose an option')
|
||||
expect(text(fieldWrapper('starts_on'))).toContain('Pick a date')
|
||||
expect(text(fieldWrapper('online'))).toContain('Off')
|
||||
expect(text(fieldWrapper('online'))).toContain('Default')
|
||||
expect(text(fieldWrapper('title'))).not.toContain('Default')
|
||||
})
|
||||
|
||||
it('shows the footer of an unsaved entry', async () => {
|
||||
await mountEditor('/content/events/new')
|
||||
|
||||
expect(text(document.body.querySelector('[data-testid="entry-meta"]'))).toBe(
|
||||
'Entry Not saved yet Created by – Updated by –',
|
||||
)
|
||||
})
|
||||
|
||||
it('reads Content, Events and New entry in the breadcrumb', async () => {
|
||||
await mountEditor('/content/events/new')
|
||||
|
||||
expect(text(document.body.querySelector('nav[aria-label="breadcrumb"]'))).toBe(
|
||||
'Content Events New entry',
|
||||
)
|
||||
})
|
||||
|
||||
it('has no required marker for assistive technology', async () => {
|
||||
await mountEditor('/content/events/new')
|
||||
|
||||
const star = fieldWrapper('title').querySelector('[aria-hidden="true"]')
|
||||
expect(star?.textContent?.trim()).toBe('*')
|
||||
expect(control('title').getAttribute('aria-required')).toBe('true')
|
||||
})
|
||||
})
|
||||
|
||||
describe('2 Editing', () => {
|
||||
it('shows the entry, the chip, the actions and the footer', async () => {
|
||||
await mountEditor('/content/events/42')
|
||||
|
||||
expect(text(main())).toContain('Lime plaster workshop Events · Entry 42')
|
||||
expect(text(document.body.querySelector('nav[aria-label="breadcrumb"]'))).toBe(
|
||||
'Content Events Lime plaster workshop',
|
||||
)
|
||||
expect(text(chip())).toBe('Saved')
|
||||
expect(button('Delete')).toBeTruthy()
|
||||
expect(button('Save')!.disabled).toBe(true)
|
||||
expect(control('title').value).toBe('Lime plaster workshop')
|
||||
expect(control('capacity').value).toBe('18')
|
||||
expect(text(fieldWrapper('online'))).not.toContain('Default')
|
||||
expect(text(document.body.querySelector('[data-testid="entry-meta"]'))).toBe(
|
||||
'Entry events / 42 Created by Inès Duarte · 12 Oct 2026, 09:14 Updated by Marta Alves · 15 Oct 2026, 11:02',
|
||||
)
|
||||
})
|
||||
|
||||
it('sends the entry request with the type request and the lookup once', async () => {
|
||||
await mountEditor('/content/events/42')
|
||||
|
||||
expect(calls).toContain('/v1/content-type/events')
|
||||
expect(calls).toContain('/v1/content-type/events/entry/42')
|
||||
expect(calls.filter((url) => url.startsWith('/v1/user/lookup'))).toEqual([
|
||||
'/v1/user/lookup?ids=4,3',
|
||||
])
|
||||
})
|
||||
|
||||
it('reads a dash and keeps the date when the lookup fails or lacks an id', async () => {
|
||||
await mountEditor('/content/events/42', {
|
||||
lookup: () => [{ id: 3, display_name: 'Marta Alves' }],
|
||||
})
|
||||
|
||||
expect(text(document.body.querySelector('[data-testid="entry-meta"]'))).toContain(
|
||||
'Created by – · 12 Oct 2026, 09:14 Updated by Marta Alves · 15 Oct 2026, 11:02',
|
||||
)
|
||||
})
|
||||
|
||||
it('reads dashes when the lookup fails', async () => {
|
||||
await mountEditor('/content/events/42', { lookup: failWith(500) })
|
||||
|
||||
expect(text(document.body.querySelector('[data-testid="entry-meta"]'))).toContain(
|
||||
'Updated by – · 15 Oct 2026, 11:02',
|
||||
)
|
||||
})
|
||||
|
||||
it('shows a trash action on mobile and no Delete button', async () => {
|
||||
await mountEditor('/content/events/42', { width: MOBILE_WIDTH })
|
||||
|
||||
expect(button('Delete')).toBeUndefined()
|
||||
expect(document.body.querySelector('button[aria-label="Delete"]')).toBeTruthy()
|
||||
expect(button('Cancel')).toBeTruthy()
|
||||
expect(button('Save')!.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('shows the new entry layout on mobile without an action', async () => {
|
||||
await mountEditor('/content/events/new', { width: MOBILE_WIDTH })
|
||||
|
||||
expect(document.body.querySelector('button[aria-label="Delete"]')).toBeNull()
|
||||
expect(button('Cancel')).toBeTruthy()
|
||||
expect(text(chip())).toBe('New entry')
|
||||
})
|
||||
})
|
||||
|
||||
describe('The page title', () => {
|
||||
it.each([
|
||||
['text', { title: 'Lime plaster workshop' }, 'Lime plaster workshop'],
|
||||
['null', { title: null }, 'Entry 42'],
|
||||
['number', { title: 18 }, '18'],
|
||||
])('reads %s', async (_name, data, title) => {
|
||||
await mountEditor('/content/events/42', { entry: () => entryWith(data) })
|
||||
|
||||
expect(text(main().querySelector('h1'))).toBe(title)
|
||||
})
|
||||
|
||||
it('reads the first line of rich text', async () => {
|
||||
const types = customTypes.map((type) =>
|
||||
type.key === 'events'
|
||||
? { ...type, fields: [{ ...eventsFields[6]!, key: 'body', label: 'Body' }] }
|
||||
: type,
|
||||
)
|
||||
await mountEditor('/content/events/42', {
|
||||
type: () => types[0],
|
||||
entry: () => entryWith({ body: 'First line\n\nSecond' }),
|
||||
})
|
||||
|
||||
expect(text(main().querySelector('h1'))).toBe('First line')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Defaults', () => {
|
||||
it.each([
|
||||
['Capacity', 'capacity', { default: 100 }, '100'],
|
||||
['Title', 'title', { default: 'Untitled' }, 'Untitled'],
|
||||
])('pre-fills and tags %s', async (_label, key, rest, shown) => {
|
||||
const fields = eventsFields.map((field) =>
|
||||
field.key === key ? { ...field, ...rest } : field,
|
||||
)
|
||||
await mountEditor('/content/events/new', {
|
||||
type: () => ({ ...customTypes[0]!, fields }),
|
||||
})
|
||||
|
||||
expect(control(key).value).toBe(shown)
|
||||
expect(text(fieldWrapper(key))).toContain('Default')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Create-only defaults', () => {
|
||||
const withDefaults = eventsFields.map((field) =>
|
||||
field.key === 'capacity'
|
||||
? { ...field, default: 100 }
|
||||
: field.key === 'title'
|
||||
? { ...field, default: 'Untitled' }
|
||||
: field,
|
||||
)
|
||||
const type = () => ({ ...customTypes[0]!, fields: withDefaults })
|
||||
|
||||
it('pre-fills and tags every default on a new entry', async () => {
|
||||
await mountEditor('/content/events/new', { type })
|
||||
|
||||
expect(control('capacity').value).toBe('100')
|
||||
expect(control('title').value).toBe('Untitled')
|
||||
expect(text(fieldWrapper('capacity'))).toContain('Default')
|
||||
expect(text(fieldWrapper('title'))).toContain('Default')
|
||||
})
|
||||
|
||||
it('never pre-fills or tags a default on an existing entry', async () => {
|
||||
await mountEditor('/content/events/42', {
|
||||
type,
|
||||
entry: () => entryWith({ capacity: null, title: 'Stored' }),
|
||||
})
|
||||
|
||||
expect(control('capacity').value).toBe('')
|
||||
expect(control('title').value).toBe('Stored')
|
||||
expect(text(main())).not.toContain('Default')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Session not ready', () => {
|
||||
it('shows the shell and issues no request', async () => {
|
||||
await mountEditor('/content/events/42', { status: 'checking' })
|
||||
|
||||
expect(calls.filter((url) => url.includes('/entry') || url.includes('lookup'))).toEqual([])
|
||||
expect(document.body.querySelector('[data-entry-field]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Navigating between entries', () => {
|
||||
const entry = (_key: string, id: number) =>
|
||||
entryWith({ title: `Entry title ${id}`, capacity: id }, { id })
|
||||
|
||||
it('carries no value, error or chip from /42 to /43', async () => {
|
||||
const wrapper = await mountEditor('/content/events/42', {
|
||||
entry,
|
||||
writeEntry: failWith(500),
|
||||
})
|
||||
await setField(wrapper, 'capacity', 20)
|
||||
document.body.querySelector<HTMLButtonElement>('button[type="submit"]')!.click()
|
||||
await flushPromises()
|
||||
await setField(wrapper, 'capacity', 42)
|
||||
expect(hasText("Couldn't save the entry")).toBe(true)
|
||||
|
||||
await router.push('/content/events/43')
|
||||
await flushPromises()
|
||||
|
||||
expect(control('title').value).toBe('Entry title 43')
|
||||
expect(control('capacity').value).toBe('43')
|
||||
expect(hasText("Couldn't save the entry")).toBe(false)
|
||||
expect(text(chip())).toBe('Saved')
|
||||
expect(text(main().querySelector('h1'))).toBe('Entry title 43')
|
||||
})
|
||||
|
||||
it('carries nothing from an entry to another type new form', async () => {
|
||||
await mountEditor('/content/events/42', { entry })
|
||||
await typeInto('title', 'Typed here')
|
||||
|
||||
void router.push('/content/recipes/new')
|
||||
await vi.waitFor(() => expect(hasText('Leave without saving?')).toBe(true))
|
||||
button('Discard changes')!.click()
|
||||
await arrived('/content/recipes/new')
|
||||
await flushPromises()
|
||||
|
||||
expect(control('title').value).toBe('')
|
||||
expect(text(chip())).toBe('New entry')
|
||||
expect(text(main().querySelector('h1'))).toBe('New entry')
|
||||
expect(hasText('Typed here')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Entry not found and load failures', () => {
|
||||
it.each(['abc', '4294967296', '-1'])(
|
||||
'shows Entry not found for /content/events/%s with no entry request',
|
||||
async (id) => {
|
||||
await mountEditor(`/content/events/${id}`)
|
||||
|
||||
expect(text(main())).toContain('Entry not found')
|
||||
expect(text(main())).toContain('It may have been deleted by someone else.')
|
||||
expect(entryRequests()).toEqual([])
|
||||
expect(button('Back to Events')).toBeTruthy()
|
||||
},
|
||||
)
|
||||
|
||||
it('sends id 0 as is and shows Entry not found when the API answers 404', async () => {
|
||||
await mountEditor('/content/events/0', { entry: failWithBody(404, 'entry_not_found') })
|
||||
|
||||
expect(entryRequests()).toEqual(['/v1/content-type/events/entry/0'])
|
||||
expect(text(main())).toContain('Entry not found')
|
||||
})
|
||||
|
||||
it('shows Entry not found on 404 entry_not_found and goes back to the list', async () => {
|
||||
await mountEditor('/content/events/999', { entry: failWithBody(404, 'entry_not_found') })
|
||||
|
||||
expect(text(main())).toContain('Entry not found')
|
||||
button('Back to Events')!.click()
|
||||
await arrived('/content/events')
|
||||
})
|
||||
|
||||
it('shows the content type page on 404 content_type_not_found', async () => {
|
||||
await mountEditor('/content/events/42', {
|
||||
type: failWithBody(404, 'content_type_not_found'),
|
||||
entry: failWithBody(404, 'content_type_not_found'),
|
||||
})
|
||||
|
||||
expect(text(main())).toContain('Content type not found')
|
||||
})
|
||||
|
||||
it('shows the access page on 403', async () => {
|
||||
await mountEditor('/content/events/42', { entry: failWith(403) })
|
||||
|
||||
expect(text(main())).toContain("You don't have access to Events")
|
||||
})
|
||||
|
||||
it.each([
|
||||
['500', failWith(500)],
|
||||
['a network failure', networkFailure],
|
||||
])('shows the load error on %s and retries only the entry', async (_name, failure) => {
|
||||
let attempts = 0
|
||||
await mountEditor('/content/events/42', {
|
||||
entry: () => {
|
||||
attempts++
|
||||
if (attempts === 1) {
|
||||
return failure()
|
||||
}
|
||||
return entry42
|
||||
},
|
||||
})
|
||||
|
||||
expect(text(main())).toContain("Couldn't load the entry")
|
||||
expect(text(main())).toContain(
|
||||
'GET /v1/content-type/events/entry/42 failed. Check your connection and try again.',
|
||||
)
|
||||
expect(document.body.querySelector('form')).toBeNull()
|
||||
const typeRequests = calls.filter((url) => url === '/v1/content-type/events').length
|
||||
|
||||
button('Try again')!.click()
|
||||
await vi.waitFor(() => expect(control('title').value).toBe('Lime plaster workshop'))
|
||||
|
||||
expect(calls.filter((url) => url === '/v1/content-type/events').length).toBe(typeRequests)
|
||||
})
|
||||
|
||||
it('shows a skeleton with aria-busy while loading', async () => {
|
||||
const gate = pending()
|
||||
await mountEditor('/content/events/42', { entry: () => gate.promise })
|
||||
|
||||
const skeleton = document.body.querySelector('[data-testid="entry-editor-skeleton"]')
|
||||
expect(skeleton?.getAttribute('aria-busy')).toBe('true')
|
||||
expect(button('Save')).toBeUndefined()
|
||||
expect(button('Delete')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user