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,226 @@
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import {
|
||||
MOBILE_WIDTH,
|
||||
chip,
|
||||
control,
|
||||
entryWith,
|
||||
fieldWrapper,
|
||||
installSelectPolyfills,
|
||||
mountEditor,
|
||||
openChoices,
|
||||
text,
|
||||
typeInto,
|
||||
unmountAll,
|
||||
} from './entryEditorKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
beforeAll(installSelectPolyfills)
|
||||
|
||||
afterEach(unmountAll)
|
||||
|
||||
function preview() {
|
||||
return document.body.querySelector('[data-testid="markdown-preview"]')
|
||||
}
|
||||
|
||||
function tab(label: string) {
|
||||
return [...document.body.querySelectorAll<HTMLElement>('[role="tab"]')].find(
|
||||
(candidate) => text(candidate) === label,
|
||||
)!
|
||||
}
|
||||
|
||||
function toolbarButton(label: string) {
|
||||
return document.body.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`)!
|
||||
}
|
||||
|
||||
async function pressTab(label: string) {
|
||||
const target = tab(label)
|
||||
target.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 }))
|
||||
target.focus()
|
||||
target.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
describe('Form controls', () => {
|
||||
it('uses one control per field type', async () => {
|
||||
await mountEditor('/content/events/42')
|
||||
|
||||
expect(fieldWrapper('title').querySelector('input[type="text"]')).toBeTruthy()
|
||||
expect(fieldWrapper('description').querySelector('textarea')).toBeTruthy()
|
||||
expect(fieldWrapper('capacity').querySelector('input')).toBeTruthy()
|
||||
expect(fieldWrapper('online').querySelector('[role="switch"]')).toBeTruthy()
|
||||
expect(fieldWrapper('starts_on').querySelector('[role="spinbutton"]')).toBeTruthy()
|
||||
expect(fieldWrapper('category').querySelector('[role="combobox"]')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('toggles the switch text between Off and On and marks the change', async () => {
|
||||
await mountEditor('/content/events/42')
|
||||
expect(text(fieldWrapper('online'))).toContain('Off')
|
||||
|
||||
fieldWrapper('online').querySelector<HTMLElement>('[role="switch"]')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(text(fieldWrapper('online'))).toContain('On')
|
||||
expect(text(chip())).toBe('Unsaved changes')
|
||||
})
|
||||
|
||||
it('lists the choices in schema order with a None item for an optional select', async () => {
|
||||
await mountEditor('/content/events/42')
|
||||
|
||||
const options = await openChoices('category')
|
||||
|
||||
expect(options.map((option) => text(option))).toEqual([
|
||||
'None',
|
||||
'Talk',
|
||||
'Workshop',
|
||||
'Meetup',
|
||||
])
|
||||
})
|
||||
|
||||
it('clears an optional select with None', async () => {
|
||||
await mountEditor('/content/events/42')
|
||||
const options = await openChoices('category')
|
||||
|
||||
options[0]!.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }))
|
||||
options[0]!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(text(fieldWrapper('category'))).toContain('Choose an option')
|
||||
expect(text(chip())).toBe('Unsaved changes')
|
||||
})
|
||||
|
||||
it('shows a counter while typing', async () => {
|
||||
await mountEditor('/content/events/42')
|
||||
|
||||
await typeInto('title', 'Lime plaster workshop, two days')
|
||||
|
||||
expect(text(fieldWrapper('title'))).toContain('31 / 80')
|
||||
expect(
|
||||
fieldWrapper('title').querySelector('[data-testid="entry-counter"]')!.className,
|
||||
).not.toContain('text-error')
|
||||
})
|
||||
|
||||
it('has no counter for a text field without a max', async () => {
|
||||
await mountEditor('/content/events/42', {
|
||||
entry: () => entryWith({}),
|
||||
type: () => ({
|
||||
id: 1,
|
||||
key: 'events',
|
||||
name: 'Events',
|
||||
icon: null,
|
||||
created_at: '2026-09-01T09:30:00Z',
|
||||
updated_at: '2026-09-01T09:30:00Z',
|
||||
fields: [
|
||||
{
|
||||
key: 'title',
|
||||
label: 'Title',
|
||||
type: 'text',
|
||||
required: false,
|
||||
unique: false,
|
||||
default: null,
|
||||
min: null,
|
||||
max: null,
|
||||
options: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
expect(fieldWrapper('title').querySelector('[data-testid="entry-counter"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('puts every field in one column below md', async () => {
|
||||
await mountEditor('/content/events/42', { width: MOBILE_WIDTH })
|
||||
|
||||
const row = fieldWrapper('starts_on').parentElement!
|
||||
expect(row.className).toContain('md:grid-cols-2')
|
||||
expect(row.className).not.toMatch(/(^|\s)grid-cols-2/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('4 Markdown preview', () => {
|
||||
it('renders bold text and a list in the Preview tab and keeps the value', async () => {
|
||||
await mountEditor('/content/events/42')
|
||||
const value = control('description').value
|
||||
|
||||
await pressTab('Preview')
|
||||
|
||||
expect(preview()!.innerHTML).toContain('<strong>lime putty</strong>')
|
||||
expect(preview()!.querySelectorAll('li')).toHaveLength(2)
|
||||
expect(toolbarButton('Bold')).toBeNull()
|
||||
expect(text(chip())).toBe('Saved')
|
||||
|
||||
await pressTab('Write')
|
||||
|
||||
expect(control('description').value).toBe(value)
|
||||
expect(preview()).toBeNull()
|
||||
expect(toolbarButton('Bold')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows raw HTML as text and keeps unsafe links inert', async () => {
|
||||
await mountEditor('/content/events/42', {
|
||||
entry: () =>
|
||||
entryWith({
|
||||
description: '<b>raw</b> [bad](javascript:alert(1)) [ok](https://example.com)',
|
||||
}),
|
||||
})
|
||||
|
||||
await pressTab('Preview')
|
||||
|
||||
expect(preview()!.querySelector('b')).toBeNull()
|
||||
expect(text(preview())).toContain('<b>raw</b>')
|
||||
const links = [...preview()!.querySelectorAll('a')]
|
||||
expect(links.map((link) => link.getAttribute('href'))).toEqual(['https://example.com'])
|
||||
})
|
||||
|
||||
it('reads Nothing to preview yet. when empty', async () => {
|
||||
await mountEditor('/content/events/new')
|
||||
|
||||
await pressTab('Preview')
|
||||
|
||||
expect(text(preview())).toBe('Nothing to preview yet.')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Bold', '**lime**'],
|
||||
['Italic', '_lime_'],
|
||||
['Heading', '## lime'],
|
||||
['Link', '[lime](url)'],
|
||||
['List', '- lime'],
|
||||
])('the %s button turns the selection into %s', async (label, result) => {
|
||||
await mountEditor('/content/events/new')
|
||||
await typeInto('description', 'lime')
|
||||
const area = control('description') as HTMLTextAreaElement
|
||||
area.focus()
|
||||
area.setSelectionRange(0, 4)
|
||||
|
||||
toolbarButton(label).click()
|
||||
await flushPromises()
|
||||
|
||||
expect(area.value).toBe(result)
|
||||
expect(document.activeElement).toBe(area)
|
||||
})
|
||||
|
||||
it('exposes the tabs as a tablist and names the toolbar buttons', async () => {
|
||||
await mountEditor('/content/events/new')
|
||||
|
||||
expect(document.body.querySelector('[role="tablist"]')).toBeTruthy()
|
||||
for (const label of ['Bold', 'Italic', 'Heading', 'Link', 'List']) {
|
||||
expect(toolbarButton(label)).toBeTruthy()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('announces the chip politely and ties labels to controls', async () => {
|
||||
await mountEditor('/content/events/42')
|
||||
|
||||
expect(chip()!.getAttribute('role')).toBe('status')
|
||||
const label = fieldWrapper('title').querySelector('label')!
|
||||
expect(label.getAttribute('for')).toBe(control('title').id)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user