feat: schema builder's create flow

This commit is contained in:
Florian Sylvain committed 2026-10-20 00:29:01 +02:00
1 parent f514146fed
commit 083cc9e23e
71 files changed
+7223 -61

No files matched your search

@@ -0,0 +1,487 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { addCollection } from '@iconify/vue'
import lucide from 'virtual:lucide-subset'
import router from '@/router'
import BuilderActions from '@/components/BuilderActions.vue'
import { useContentTypesStore } from '@/stores/contentTypes'
import {
button,
calls,
createdType,
failWithBody,
mountAt,
networkFailure,
pending,
posts,
text,
unmountAll,
} from './schemaTestKit'
import {
addField,
all,
buildEvents,
byLabel,
chip,
click,
discardButton,
el,
keyField,
keyInput,
labelInput,
main,
nameInput,
rowLabels,
saveButton,
typeInto,
} from './builderKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
addCollection(lucide)
beforeEach(() => {
Element.prototype.scrollIntoView = () => undefined
})
afterEach(async () => {
vi.restoreAllMocks()
await unmountAll()
})
const SAVE_FAILED_TITLE = "Couldn't save the content type"
const SAVE_FAILED_TEXT =
'Something went wrong on our side. Your changes are still here. Try again in a moment.'
const EVENTS_BODY = {
key: 'events',
name: 'Events',
fields: [
{
key: 'title',
label: 'Title',
type: 'text',
required: true,
unique: true,
default: null,
min: null,
max: 80,
options: [],
},
{
key: 'starts_on',
label: 'Starts on',
type: 'date',
required: false,
unique: false,
default: null,
min: '2026-01-01',
max: '2027-12-31',
options: [],
},
{
key: 'category',
label: 'Category',
type: 'select',
required: false,
unique: false,
default: null,
min: null,
max: null,
options: ['Talk', 'Workshop', 'Meetup'],
},
],
}
describe('Saving a valid type', () => {
it('posts once the exact body, in list order, with no icon', async () => {
await mountAt('/schema/new')
await buildEvents()
await click(saveButton())
expect(posts).toHaveLength(1)
expect(posts[0]!.url).toBe('/v1/content-type')
expect(posts[0]!.body).toEqual(EVENTS_BODY)
expect('icon' in posts[0]!.body).toBe(false)
})
})
describe('Saving (design state 11, create)', () => {
async function startSaving() {
const gate = pending()
await mountAt('/schema/new', { create: () => gate.promise })
await buildEvents()
await click(saveButton())
return gate
}
it('shows the chip with a loader, dims and disables the form, and sends one request only', async () => {
await startSaving()
expect(chip()).toBe('Saving…')
expect(el('[data-testid="builder-chip"] svg')).not.toBeNull()
expect(saveButton()).toBeUndefined()
const saving = button('Saving…') as HTMLButtonElement
expect(saving.disabled).toBe(true)
expect(saving.querySelector('svg')).not.toBeNull()
expect(discardButton()!.disabled).toBe(true)
expect(nameInput()!.disabled).toBe(true)
expect(keyInput()!.disabled).toBe(true)
expect(labelInput()!.disabled).toBe(true)
expect((button('Add field') as HTMLButtonElement).disabled).toBe(true)
expect(all<HTMLButtonElement>('[data-grip]').every((grip) => grip.disabled)).toBe(true)
expect((byLabel('Remove') as HTMLButtonElement).disabled).toBe(true)
saving.click()
await flushPromises()
expect(posts).toHaveLength(1)
})
it('leaves the form editable again once the request settles', async () => {
const gate = await startSaving()
gate.resolve(createdType(posts[0]!.body))
await flushPromises()
expect(posts).toHaveLength(1)
})
})
describe('Double activation while saving', () => {
it('sends one POST however often Save is activated programmatically', async () => {
const gate = pending()
const mounted = await mountAt('/schema/new', { create: () => gate.promise })
await buildEvents()
await click(saveButton())
const actions = mounted.findComponent(BuilderActions)
actions.vm.$emit('save')
actions.vm.$emit('save')
await flushPromises()
expect(posts).toHaveLength(1)
expect((button('Saving…') as HTMLButtonElement).disabled).toBe(true)
expect(actions.props('canSave')).toBe(false)
expect(actions.props('canReveal')).toBe(false)
gate.resolve(createdType(posts[0]!.body))
await flushPromises()
expect(posts).toHaveLength(1)
})
})
describe('Created type reaching the store', () => {
it('inserts the new type before the first later name and needs no second list request', async () => {
await mountAt('/schema/new')
await typeInto(nameInput(), 'Newsletters')
await click(saveButton())
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/schema'))
await flushPromises()
expect(useContentTypesStore().items.map((type) => type.name)).toEqual([
'Events',
'Newsletters',
'Recipes',
'Press mentions',
])
expect(el('nav a[href="/content/newsletters"]')).not.toBeNull()
expect(calls.filter((url) => url === '/v1/content-type')).toHaveLength(1)
})
})
describe('Saved (design state 12, create)', () => {
it('toasts, adds the type to the store and the sidebar, replaces the URL and does not prompt', async () => {
await mountAt('/schema/new', {
create: (body) => ({ ...createdType(body), fields: new Array(6) }),
})
await buildEvents()
await click(saveButton())
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/schema'))
await flushPromises()
expect(text(document.body)).toContain('Content type saved')
expect(text(document.body)).toContain('Events now has 6 fields.')
expect(text(document.body)).not.toContain('Leave without saving?')
expect(el('nav a[href="/content/events"]')).not.toBeNull()
const listed = all('[data-testid="schema-row"]').map((row) => text(row))
expect(listed.some((row) => row.includes('Events'))).toBe(true)
expect(calls.filter((url) => url === '/v1/content-type')).toHaveLength(1)
router.back()
await flushPromises()
expect(router.currentRoute.value.path).not.toBe('/schema/new')
})
it('reads "1 field" for a single field', async () => {
await mountAt('/schema/new', {
create: (body) => ({ ...createdType(body), fields: new Array(1) }),
})
await typeInto(nameInput(), 'Events')
await click(saveButton())
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/schema'))
await flushPromises()
expect(text(document.body)).toContain('Events now has 1 field.')
})
})
describe('Navigation failing after a created type', () => {
async function saveWithBrokenNavigation() {
vi.spyOn(router, 'replace').mockRejectedValue(new Error('chunk load failed'))
await mountAt('/schema/new')
await typeInto(nameInput(), 'Events')
await click(saveButton())
}
it('keeps the type, shows no failure and blocks a second POST', async () => {
await saveWithBrokenNavigation()
expect(posts).toHaveLength(1)
expect(text(document.body)).not.toContain(SAVE_FAILED_TITLE)
expect(chip()).toBe('')
expect(saveButton()!.disabled).toBe(true)
expect(el('nav a[href="/content/events"]')).not.toBeNull()
expect(text(document.body)).toContain('Content type saved')
})
it('treats the form as editable again after the next edit', async () => {
await saveWithBrokenNavigation()
await typeInto(nameInput(), 'Events 2')
expect(chip()).toBe('Unsaved changes')
expect(saveButton()!.disabled).toBe(false)
})
})
describe('A failed save then an edit', () => {
it('returns to editing so Save works again', async () => {
let attempts = 0
await mountAt('/schema/new', {
create: (body) => {
attempts++
if (attempts === 1) {
throw new Error('boom')
}
return createdType(body)
},
})
await typeInto(nameInput(), 'Events')
await click(saveButton())
expect(chip()).toBe('Save failed')
await typeInto(nameInput(), 'Events 2')
expect(chip()).toBe('Unsaved changes')
expect(saveButton()!.disabled).toBe(false)
})
})
describe('Key already taken (design state 14)', () => {
async function reachTaken() {
await mountAt('/schema/new', {
create: failWithBody(409, 'content_type_key_taken'),
})
await typeInto(nameInput(), 'Events')
await click(saveButton())
}
it('shows the message under Key, makes it editable with focus, and counts one problem', async () => {
await reachTaken()
expect(text(keyField().querySelector('[role="alert"]'))).toBe(
'The key "events" is already used by another content type. Choose a different key.',
)
expect(keyInput()!.readOnly).toBe(false)
expect(document.activeElement).toBe(keyInput())
expect(text(keyField())).not.toContain('Auto')
expect(chip()).toBe('1 problem to fix')
expect(saveButton()!.disabled).toBe(true)
expect(nameInput()!.value).toBe('Events')
expect(rowLabels()).toEqual(['Title'])
})
it('clears the error and enables Save when the key is edited', async () => {
await reachTaken()
await typeInto(keyInput(), 'events-2')
expect(keyField().querySelector('[role="alert"]')).toBeNull()
expect(saveButton()!.disabled).toBe(false)
})
})
describe('The server refuses a key or a field', () => {
it('shows the key message under Key for 400 invalid_content_type_key', async () => {
await mountAt('/schema/new', {
create: failWithBody(400, 'invalid_content_type_key'),
})
await typeInto(nameInput(), 'Events')
await click(saveButton())
expect(text(keyField().querySelector('[role="alert"]'))).toBe(
'Use 2 to 40 lowercase letters, digits or hyphens.',
)
expect(chip()).toBe('1 problem to fix')
})
it('shows the banner for 422 invalid_field and keeps the edits and Save', async () => {
await mountAt('/schema/new', { create: failWithBody(422, 'invalid_field') })
await typeInto(nameInput(), 'Events')
await click(saveButton())
expect(text(main())).toContain('Check the fields marked in the list.')
expect(nameInput()!.value).toBe('Events')
expect(saveButton()!.disabled).toBe(false)
})
it('shows the access page for 403', async () => {
await mountAt('/schema/new', { create: failWithBody(403, 'Only owners and admins') })
await typeInto(nameInput(), 'Events')
await click(saveButton())
expect(text(main())).toContain("You don't have access to Schema")
expect(nameInput()).toBeNull()
})
it('leaves the redirect to the API client for 401', async () => {
await mountAt('/schema/new', { create: failWithBody(401, '') })
await typeInto(nameInput(), 'Events')
await click(saveButton())
expect(text(main())).not.toContain(SAVE_FAILED_TITLE)
expect(nameInput()!.value).toBe('Events')
})
})
describe('Save failed (design state 13)', () => {
it.each([
['answers 500', failWithBody(500, 'boom')],
['fails (network)', networkFailure],
])(
'shows the alert, keeps every edit and retries with the same body when the POST %s',
async (_, failure) => {
let respond = failure
await mountAt('/schema/new', { create: (body) => respond() ?? body })
await buildEvents()
await click(saveButton())
expect(text(main())).toContain(SAVE_FAILED_TITLE)
expect(text(main())).toContain(SAVE_FAILED_TEXT)
expect(chip()).toBe('Save failed')
expect(nameInput()!.value).toBe('Events')
expect(rowLabels()).toEqual(['Title', 'Starts on', 'Category'])
expect(saveButton()!.disabled).toBe(false)
respond = () => undefined
const first = posts[0]!.body
await click(button('Try again'))
expect(posts).toHaveLength(2)
expect(posts[1]!.body).toEqual(first)
},
)
})
describe('Offline (design state 16)', () => {
function setOnline(value: boolean) {
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(value)
window.dispatchEvent(new Event(value ? 'online' : 'offline'))
}
it('shows the banner and the chip, disables Save, keeps edits, and sends nothing on reconnect', async () => {
await mountAt('/schema/new')
await typeInto(nameInput(), 'Events')
setOnline(false)
await flushPromises()
expect(text(document.body)).toContain(
"You're offline. Changes will be saved when you reconnect.",
)
expect(chip()).toBe('Waiting for connection')
expect(saveButton()!.disabled).toBe(true)
expect(nameInput()!.value).toBe('Events')
setOnline(true)
await flushPromises()
expect(text(document.body)).not.toContain("You're offline.")
expect(chip()).toBe('Unsaved changes')
expect(saveButton()!.disabled).toBe(false)
expect(posts).toHaveLength(0)
})
})
describe('Discard', () => {
it('resets to the blank form, sends nothing and keeps the URL', async () => {
await mountAt('/schema/new')
await buildEvents()
await click(discardButton())
expect(nameInput()!.value).toBe('')
expect(keyInput()!.value).toBe('')
expect(rowLabels()).toEqual(['Title'])
expect(chip()).toBe('')
expect(discardButton()!.disabled).toBe(true)
expect(saveButton()!.disabled).toBe(true)
expect(posts).toHaveLength(0)
expect(router.currentRoute.value.path).toBe('/schema/new')
})
})
describe('Leaving with unsaved edits', () => {
const DIALOG_TITLE = 'Leave without saving?'
it('navigates without a prompt from a pristine form', async () => {
await mountAt('/schema/new')
await router.push('/schema')
await flushPromises()
expect(router.currentRoute.value.path).toBe('/schema')
expect(text(document.body)).not.toContain(DIALOG_TITLE)
})
it('asks first when something was edited, and stays on Keep editing', async () => {
await mountAt('/schema/new')
await typeInto(nameInput(), 'Events')
const leaving = router.push('/schema')
await flushPromises()
expect(text(document.body)).toContain(DIALOG_TITLE)
expect(button('Keep editing')).toBeInstanceOf(HTMLButtonElement)
expect(button('Discard changes')).toBeInstanceOf(HTMLButtonElement)
await click(button('Keep editing'))
await leaving
await flushPromises()
expect(router.currentRoute.value.path).toBe('/schema/new')
expect(nameInput()!.value).toBe('Events')
})
it('leaves on Discard changes', async () => {
await mountAt('/schema/new')
await addField('Starts on')
const leaving = router.push('/schema')
await flushPromises()
await click(button('Discard changes'))
await leaving
await flushPromises()
expect(router.currentRoute.value.path).toBe('/schema')
})
})