feat: schema builder's create flow
This commit is contained in:
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')
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user