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 { MOBILE_WIDTH } from '@/components/__tests__/viewport' import { useContentTypesStore } from '@/stores/contentTypes' import { button, calls, eventsFields, failWithBody, mountAt, networkFailure, pending, puts, text, unmountAll, updatedType, } from './schemaTestKit' import { addField, all, badgesOf, byLabel, chip, click, dialog, discardButton, el, fieldKeyInput, keyInput, labelInput, main, makeUnsavedChanges, nameInput, notice, rowLabels, rows, saveButton, selectRow, setMax, typeInto, undoButtons, undoRows, } from './builderKit' vi.mock('@/composables/useApi', async (importOriginal) => { const actual = await importOriginal() 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 SAVED_AT = /^Saved at \d{2}:\d{2}$/ const FIELD_KEYS = [ 'key', 'label', 'type', 'required', 'unique', 'default', 'min', 'max', 'options', 'relation', ] async function editRecipes() { await selectRow('Servings') await typeInto(labelInput(), 'Portions') } describe('Saving a type that has no entries', () => { it('sends one PUT with no review and exactly name, icon and fields', async () => { await mountAt('/schema/recipes') await editRecipes() await click(saveButton()) expect(puts).toHaveLength(1) expect(puts[0]!.url).toBe('/v1/content-type/recipes') expect(dialog()).toBeNull() const body = puts[0]!.body as { name: string icon: unknown fields: Record[] } expect(Object.keys(body)).toEqual(['name', 'icon', 'fields']) expect(body.icon).toBeNull() expect(body.fields.map((field) => field.label)).toEqual([ 'Title', 'Portions', 'Vegetarian', 'Method', ]) expect(body.fields.every((field) => Object.keys(field).join() === FIELD_KEYS.join())).toBe( true, ) }) }) describe('The PUT body', () => { it('keeps the loaded icon, sends no key, trims the name and keeps the saved keys and types', async () => { await mountAt('/schema/events') await typeInto(nameInput(), ' Events and talks ') await click(saveButton()) expect(puts).toHaveLength(1) const body = puts[0]!.body as { name: string icon: unknown fields: Record[] } expect(Object.keys(body)).toEqual(['name', 'icon', 'fields']) expect(body.name).toBe('Events and talks') expect(body.icon).toBe('files') expect('key' in body).toBe(false) expect(body.fields.map((field) => [field.key, field.type])).toEqual( eventsFields.map((field) => [field.key, field.type]), ) expect(body.fields[3]).toMatchObject({ key: 'online', default: null, options: [], }) expect(body.fields[4]).toMatchObject({ min: 1, max: 5000 }) expect(body.fields[5]).toMatchObject({ options: ['Talk', 'Workshop', 'Meetup'], }) }) it('keeps a null icon null and leaves undone removals out', async () => { await mountAt('/schema/recipes') await selectRow('Method') await click(byLabel('Remove')) await click(saveButton()) const body = puts[0]!.body as { icon: unknown fields: { key: string }[] } expect(body.icon).toBeNull() expect(body.fields.map((field) => field.key)).toEqual(['title', 'servings', 'vegetarian']) }) }) describe('Saving (design state 11, no entries)', () => { async function startSaving() { const gate = pending() await mountAt('/schema/recipes', { update: () => gate.promise }) await editRecipes() await selectRow('Method') await click(byLabel('Remove')) await click(saveButton()) return gate } it('shows the chip and the loader, dims and disables everything, and sends once', 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(labelInput()!.disabled).toBe(true) expect((button('Add field') as HTMLButtonElement).disabled).toBe(true) expect(all('[data-grip]').every((grip) => grip.disabled)).toBe(true) expect((byLabel('Move up') as HTMLButtonElement).disabled).toBe(true) expect((byLabel('Remove') as HTMLButtonElement).disabled).toBe(true) expect(undoButtons().every((undo) => undo.disabled)).toBe(true) saving.click() await flushPromises() expect(puts).toHaveLength(1) }) it('ignores a second Save however it is activated', async () => { const gate = pending() const mounted = await mountAt('/schema/recipes', { update: () => gate.promise, }) await editRecipes() await click(saveButton()) const actions = mounted.findComponent(BuilderActions) actions.vm.$emit('save') actions.vm.$emit('save') await flushPromises() expect(puts).toHaveLength(1) }) }) describe('Saved (design state 12)', () => { it('toasts, stays, clears the marks, locks the new fields and reads Saved at', async () => { await mountAt('/schema/recipes') await editRecipes() await click(button('Add field')) await typeInto(labelInput(), 'Notes') await selectRow('Method') await click(byLabel('Remove')) await click(saveButton()) await flushPromises() expect(text(document.body)).toContain('Content type saved') expect(text(document.body)).toContain('Recipes now has 4 fields.') expect(router.currentRoute.value.path).toBe('/schema/recipes') expect(rows().every((row) => !/New|Changed/.test(text(row)))).toBe(true) expect(undoRows()).toEqual([]) expect(chip()).toMatch(SAVED_AT) expect(saveButton()!.disabled).toBe(true) expect(discardButton()!.disabled).toBe(true) await selectRow('Notes') expect(fieldKeyInput()!.readOnly).toBe(true) expect(el('[data-testid="field-inspector"] [data-testid="slug-lock"]')).not.toBeNull() expect(text(el('[data-testid="field-inspector"]'))).toContain("Can't change after saving") }) it('writes the answered type into the store, the list and the sidebar with no second GET', async () => { await mountAt('/schema/events', { update: (body) => ({ ...updatedType('events', body), name: 'Events and talks', }), }) await typeInto(nameInput(), 'Events and talks') await click(saveButton()) await flushPromises() const store = useContentTypesStore() expect(store.items[0]!.name).toBe('Events and talks') expect(el('nav a[href="/content/events"]')!.textContent).toContain('Events and talks') expect(calls.filter((url) => url === '/v1/content-type')).toHaveLength(1) await router.push('/schema') await flushPromises() expect( all('[data-testid="schema-row"]').some((row) => text(row).includes('Events and talks')), ).toBe(true) }) it('reads "1 field" for a single field', async () => { await mountAt('/schema/recipes') for (let count = 0; count < 3; count++) { await click(byLabel('Remove')) } await click(saveButton()) await flushPromises() expect(text(document.body)).toContain('Recipes now has 1 field.') }) it('does not prompt when leaving afterwards', async () => { await mountAt('/schema/recipes') await editRecipes() await click(saveButton()) await flushPromises() await router.push('/schema') await flushPromises() expect(router.currentRoute.value.path).toBe('/schema') expect(text(document.body)).not.toContain('Leave without saving?') }) it('goes back to Unsaved changes on an edit and Discard returns to the last saved state', async () => { await mountAt('/schema/recipes') await editRecipes() await click(saveButton()) await flushPromises() await typeInto(nameInput(), 'Dishes') expect(chip()).toBe('Unsaved changes') await click(discardButton()) expect(nameInput()!.value).toBe('Recipes') expect(rowLabels()).toEqual(['Title', 'Portions', 'Vegetarian', 'Method']) expect(chip()).toMatch(SAVED_AT) }) }) describe('Save on a type with entries opens the review (group 3, state 1)', () => { it('sends nothing yet and opens the dialog with the summary, the rows in order and the note', async () => { await mountAt('/schema/events') await makeUnsavedChanges() await click(saveButton()) expect(puts).toHaveLength(0) const open = dialog()! expect(text(open)).toContain('Review changes to Events') expect(text(open)).toContain('Apply them to save the content type.') expect(text(el('[data-testid="review-summary"]'))).toBe('Add 2 fields, change 1, remove 1') expect(text(open)).toContain( 'Events has 37 entries. Each one is checked against the new fields when you apply.', ) expect(all('[data-testid="change-title"]').map(text)).toEqual([ 'Add Speaker', 'Add Room', 'Change Capacity', 'Remove Description', ]) expect(all('[data-testid="change-text"]').map(text)).toEqual([ 'Text, optional.', 'Choice, required, default "Main hall". Existing entries get the default, so they still fit.', 'Highest value goes from 5000 to 2000.', 'Entries stop returning its values.', ]) expect(all('[data-testid="change-flag"]').map(text)).toEqual([ 'Entries may not fit', 'Removes data', ]) expect(text(open)).toContain( "Flagged changes can be refused if existing entries don't fit. If that happens, nothing is changed.", ) expect(button('Back to editing')).toBeInstanceOf(HTMLButtonElement) expect(button('Apply changes')).toBeInstanceOf(HTMLButtonElement) }) it('opens as a bottom sheet on mobile', async () => { await mountAt('/schema/events', { width: MOBILE_WIDTH }) await click(rows()[6]!.querySelector('[data-field-row]')) await click(byLabel('Remove')) await vi.waitFor(() => expect(el('[data-testid="field-sheet-title"]')).toBeNull()) await click(saveButton()) expect(dialog()).not.toBeNull() expect(dialog()!.className).toContain('max-sm:') expect(text(dialog())).toContain('Review changes to Events') }) }) describe('When the review opens', () => { it('sends a name-only edit straight away', async () => { await mountAt('/schema/events') await typeInto(nameInput(), 'Events and talks') await click(saveButton()) expect(dialog()).toBeNull() expect(puts).toHaveLength(1) }) it('sends a reorder-only edit straight away', async () => { await mountAt('/schema/events') await selectRow('Ends on') await click(byLabel('Move up')) await click(saveButton()) expect(dialog()).toBeNull() expect(puts).toHaveLength(1) }) it.each([ ['a field added', async () => addField('Speaker')], ['a field changed', async () => setMax('2000')], [ 'a field removed', async () => { await selectRow('Description') await click(byLabel('Remove')) }, ], ])('opens for %s', async (_, edit) => { await mountAt('/schema/events') await edit() await click(saveButton()) expect(dialog()).not.toBeNull() expect(puts).toHaveLength(0) }) it('lists only the field change when the name changed too', async () => { await mountAt('/schema/events') await typeInto(nameInput(), 'Events and talks') await setMax('2000') await click(saveButton()) expect(all('[data-testid="change-title"]').map(text)).toEqual(['Change Capacity']) expect(text(el('[data-testid="review-summary"]'))).toBe('Change 1') }) }) describe('The review title after a rename', () => { it('reads the saved name, not the name loaded with the page', async () => { await mountAt('/schema/events') await typeInto(nameInput()!, 'Seminars') await setMax('2000') await click(saveButton()) await click(button('Apply changes')) await flushPromises() await setMax('3000') await click(saveButton()) expect(text(dialog()!)).toContain('Review changes to Seminars') }) }) describe('Back to editing from the review', () => { it('closes with the button and keeps every edit', async () => { await mountAt('/schema/events') await makeUnsavedChanges() await click(saveButton()) await click(button('Back to editing')) await vi.waitFor(() => expect(dialog()).toBeNull()) expect(puts).toHaveLength(0) expect(chip()).toBe('Unsaved changes') expect(badgesOf('Speaker')).toContain('New') expect(undoRows()).toHaveLength(1) }) it('closes on Esc', async () => { await mountAt('/schema/events') await setMax('2000') await click(saveButton()) document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) await vi.waitFor(() => expect(dialog()).toBeNull()) expect(puts).toHaveLength(0) }) it('returns the focus to Save', async () => { await mountAt('/schema/events') await setMax('2000') const save = saveButton()! save.focus() await click(save) await click(button('Back to editing')) await vi.waitFor(() => expect(dialog()).toBeNull()) expect(document.activeElement).toBe(save) }) }) describe('Applying (group 3, state 2) and Applied', () => { async function apply() { const gate = pending() await mountAt('/schema/events', { update: () => gate.promise }) await setMax('2000') await click(saveButton()) await click(button('Apply changes')) return gate } it('sends one PUT, reads Applying… with a loader and cannot be dismissed', async () => { await apply() expect(puts).toHaveLength(1) const confirm = button('Applying…') as HTMLButtonElement expect(confirm.disabled).toBe(true) expect(confirm.querySelector('svg')).not.toBeNull() expect((button('Back to editing') as HTMLButtonElement).disabled).toBe(true) expect(dialog()!.getAttribute('aria-busy')).toBe('true') document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) await flushPromises() expect(dialog()).not.toBeNull() confirm.click() await flushPromises() expect(puts).toHaveLength(1) }) it('keeps the focus on the title of the dialog', async () => { await apply() await vi.waitFor(() => expect(document.activeElement?.textContent).toBe('Review changes to Events'), ) }) it('closes the dialog on success with the toast, the cleared marks and Saved at', async () => { const gate = await apply() gate.resolve(updatedType('events', puts[0]!.body)) await flushPromises() expect(dialog()).toBeNull() expect(text(document.body)).toContain('Content type saved') expect(text(document.body)).toContain('Events now has 7 fields.') expect(rows().every((row) => !/New|Changed/.test(text(row)))).toBe(true) expect(chip()).toMatch(SAVED_AT) expect(notice()).toContain('37 entries') expect(text(document.body)).not.toContain('Leave without saving?') }) }) describe('Refused (design state 15 and group 3, state 3)', () => { const REFUSAL_TEXT = "RenewCMS refused the changes because some entries don't fit the new fields. It doesn't say which entries." async function refused( edit: () => Promise = async () => { await setMax('2000') await selectRow('Description') await click(byLabel('Remove')) }, ) { await mountAt('/schema/events', { update: failWithBody(409, 'schema_incompatible'), }) await edit() await click(saveButton()) await click(button('Apply changes')) } it('replaces the review with the refusal dialog that lists only the flagged change', async () => { await refused() const open = dialog()! expect(text(open)).toContain('Nothing was changed') expect(text(open)).toContain(REFUSAL_TEXT) expect(text(open)).toContain('Your content type is as it was') expect(text(open)).toContain('No field was added, changed or removed.') expect(text(open)).toContain('The change that can cause this in your edit') expect(all('[data-testid="change-title"]').map(text)).toEqual(['Change Capacity']) expect(text(el('[data-testid="change-text"]'))).toBe( 'Highest value goes from 5000 to 2000. An entry may have a higher value.', ) expect(text(el('[data-testid="change-flag"]'))).toBe('Possible cause') expect(text(open)).not.toContain('Description') expect(text(open)).toContain( 'This kind of change is refused when an entry is over the new limit, a new required field has no default, a field becomes unique while entries share a value, or a removed choice is still in use.', ) expect(button('Discard changes')).toBeInstanceOf(HTMLButtonElement) expect(button('Back to editing')).toBeInstanceOf(HTMLButtonElement) expect(text(open)).not.toContain('37') const store = useContentTypesStore() expect(store.items[0]!.fields).toHaveLength(7) expect((store.items[0]!.fields[4] as { max: number }).max).toBe(5000) expect(nameInput()).not.toBeNull() }) it('omits the cause section when no change is flagged', async () => { await refused(async () => { await selectRow('Description') await click(byLabel('Remove')) }) const open = dialog()! expect(text(open)).toContain('Nothing was changed') expect(text(open)).toContain('Your content type is as it was') expect(text(open)).not.toContain('The change that can cause this in your edit') expect(el('[data-testid="refusal-causes"]')).toBeNull() expect(text(open)).toContain('This kind of change is refused') }) it('returns to a banner with Review changes, marks the flagged row and keeps Save', async () => { await refused() await click(button('Back to editing')) await vi.waitFor(() => expect(dialog()).toBeNull()) expect(text(main())).toContain('Nothing was changed') expect(text(main())).toContain( "Some entries don't fit the new fields, so the whole change was refused. RenewCMS can't tell which entries. The changes that can cause this are marked.", ) expect(badgesOf('Capacity')).toContain('Changed') expect(badgesOf('Capacity')).toContain('Possible cause') expect(chip()).toBe('Unsaved changes') expect(saveButton()!.disabled).toBe(false) await click(button('Review changes')) expect(text(dialog())).toContain('Review changes to Events') expect(all('[data-testid="change-title"]').map(text)).toEqual([ 'Change Capacity', 'Remove Description', ]) }) it('also returns to the banner on Esc or the close icon', async () => { await refused() document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) await vi.waitFor(() => expect(dialog()).toBeNull()) expect(text(main())).toContain('Nothing was changed') }) it('resets the form on Discard changes, with no banner and no marker', async () => { await refused() await click(button('Discard changes')) await vi.waitFor(() => expect(dialog()).toBeNull()) await flushPromises() expect(text(main())).not.toContain('Nothing was changed') expect(chip()).toBe('Saved') expect(rowLabels()).toHaveLength(7) expect(badgesOf('Capacity')).toBe('') }) it('loses the marker when the flagged change is reverted, and the banner on success', async () => { await refused() await click(button('Back to editing')) await vi.waitFor(() => expect(dialog()).toBeNull()) await selectRow('Capacity') await typeInto(el('input[name="max"]'), '5000') expect(badgesOf('Capacity')).not.toContain('Possible cause') expect(badgesOf('Description')).toBe('') expect(text(main())).toContain('Nothing was changed') }) it('clears the banner and every marker once a PUT succeeds', async () => { let answer: (body: Record) => unknown = () => { throw Object.assign(new Error('refused'), { status: 409, body: 'schema_incompatible', }) } const { ApiError } = await import('@/composables/useApi') answer = () => { throw new ApiError(409, undefined, 'schema_incompatible') } await mountAt('/schema/events', { update: (body) => answer(body) }) await setMax('2000') await click(saveButton()) await click(button('Apply changes')) await click(button('Back to editing')) await vi.waitFor(() => expect(dialog()).toBeNull()) await selectRow('Ends on') expect(badgesOf('Capacity')).toContain('Possible cause') answer = (body) => updatedType('events', body) await click(button('Review changes')) await click(button('Apply changes')) await flushPromises() expect(text(main())).not.toContain('Nothing was changed') expect(badgesOf('Capacity')).toBe('') expect(chip()).toMatch(SAVED_AT) }) }) describe('Other answers of the PUT', () => { async function answered(failure: () => unknown, edit = async () => setMax('2000')) { await mountAt('/schema/events', { update: failure }) await edit() await click(saveButton()) if (dialog()) { await click(button('Apply changes')) } } it('closes the review and shows the banner for 422 invalid_field, keeping the edits', async () => { await answered(failWithBody(422, 'invalid_field')) expect(dialog()).toBeNull() expect(text(main())).toContain('Check the fields marked in the list.') expect(badgesOf('Capacity')).toContain('Changed') expect(saveButton()!.disabled).toBe(false) }) it('shows the name message for 400 and keeps the edits', async () => { await answered(failWithBody(400, 'Bad request body')) expect(dialog()).toBeNull() expect(text(nameInput()!.closest('[data-orientation]'))).toContain( 'Give the content type a name.', ) expect(badgesOf('Capacity')).toContain('Changed') expect(puts).toHaveLength(1) }) it('keeps the name message off a 400 that is about the key', async () => { await answered(failWithBody(400, 'invalid_content_type_key')) expect(dialog()).toBeNull() expect(text(nameInput()!.closest('[data-orientation]'))).not.toContain( 'Give the content type a name.', ) expect(badgesOf('Capacity')).toContain('Changed') }) it('shows the not-found page for 404 and drops the type from the store', async () => { await answered(failWithBody(404, 'content_type_not_found')) expect(text(main())).toContain('Content type not found') expect(useContentTypesStore().items.map((type) => type.key)).toEqual([ 'recipes', 'press-mentions', ]) expect(nameInput()).toBeNull() }) it('shows the access page for 403', async () => { await answered(failWithBody(403, 'Only owners and admins')) 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 answered(failWithBody(401, '')) expect(dialog()).toBeNull() expect(text(main())).not.toContain(SAVE_FAILED_TITLE) }) }) describe('Save failed (design state 13)', () => { it.each([ ['answers 500', failWithBody(500, 'boom')], ['fails (network)', networkFailure], ])( 'closes the review, keeps the edits and retries through the review when the PUT %s', async (_, failure) => { let respond: () => unknown = failure await mountAt('/schema/events', { update: (body) => respond() ?? body, }) await setMax('2000') await click(saveButton()) await click(button('Apply changes')) expect(dialog()).toBeNull() expect(text(main())).toContain(SAVE_FAILED_TITLE) expect(text(main())).toContain(SAVE_FAILED_TEXT) expect(chip()).toBe('Save failed') expect(badgesOf('Capacity')).toContain('Changed') expect(saveButton()!.disabled).toBe(false) respond = () => undefined const first = puts[0]!.body await click(button('Try again')) expect(dialog()).not.toBeNull() expect(puts).toHaveLength(1) await click(button('Apply changes')) expect(puts).toHaveLength(2) expect(puts[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 the edits and sends nothing on reconnect', async () => { await mountAt('/schema/events') await typeInto(nameInput(), 'Events and talks') 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 and talks') setOnline(true) await flushPromises() expect(text(document.body)).not.toContain("You're offline.") expect(saveButton()!.disabled).toBe(false) expect(puts).toHaveLength(0) }) it('disables Apply changes while the review is open offline and keeps the edits', async () => { await mountAt('/schema/events') await setMax('2000') await click(saveButton()) setOnline(false) await flushPromises() expect((button('Apply changes') as HTMLButtonElement).disabled).toBe(true) expect(badgesOf('Capacity')).toContain('Changed') setOnline(true) await flushPromises() expect((button('Apply changes') as HTMLButtonElement).disabled).toBe(false) }) }) describe('Accessibility of the edit mode', () => { it('keeps Undo a reachable button', async () => { await mountAt('/schema/events') await selectRow('Description') await click(byLabel('Remove')) const undo = undoButtons()[0]! expect(undo.tagName).toBe('BUTTON') expect(undo.tabIndex).toBe(0) expect(undo.getAttribute('type')).toBe('button') }) it('ties the lock icons to a text or a help', async () => { await mountAt('/schema/events') expect(keyInput()!.readOnly).toBe(true) expect(keyInput()!.closest('[data-orientation]')).not.toBeNull() await selectRow('Starts on') expect(text(el('[data-testid="field-inspector"]'))).toContain( "Used in the API, so it can't change.", ) expect(text(el('[data-testid="field-inspector"]'))).toContain("Can't change after saving") }) it('names the dialog and keeps the focus inside it', async () => { await mountAt('/schema/events') await setMax('2000') await click(saveButton()) const open = dialog()! expect(open.getAttribute('role')).toBe('alertdialog') expect(open.getAttribute('aria-labelledby')).not.toBeNull() expect(open.contains(document.activeElement)).toBe(true) }) })