491 lines
14 KiB
TypeScript
491 lines
14 KiB
TypeScript
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: [],
|
|
relation: null,
|
|
},
|
|
{
|
|
key: 'starts_on',
|
|
label: 'Starts on',
|
|
type: 'date',
|
|
required: false,
|
|
unique: false,
|
|
default: null,
|
|
min: '2026-01-01',
|
|
max: '2027-12-31',
|
|
options: [],
|
|
relation: null,
|
|
},
|
|
{
|
|
key: 'category',
|
|
label: 'Category',
|
|
type: 'select',
|
|
required: false,
|
|
unique: false,
|
|
default: null,
|
|
min: null,
|
|
max: null,
|
|
options: ['Talk', 'Workshop', 'Meetup'],
|
|
relation: null,
|
|
},
|
|
],
|
|
}
|
|
|
|
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 this page")
|
|
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')
|
|
})
|
|
})
|