import { afterEach, 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 { MOBILE_WIDTH } from '@/components/__tests__/viewport' import { button, calls, failWith, mountAt, text, unmountAll } from './schemaTestKit' import { all, chip, click, discardButton, el, inspector, keyField, keyInput, labelInput, main, nameInput, rowLabels, saveButton, typeInto, } from './builderKit' vi.mock('@/composables/useApi', async (importOriginal) => { const actual = await importOriginal() return { ...actual, useApi: vi.fn() } }) addCollection(lucide) afterEach(unmountAll) const NOT_FOUND_TITLE = 'Content type not found' const ACCESS_TITLE = "You don't have access to Schema" function newTypeButtons() { return all('button').filter((candidate) => text(candidate) === 'New content type') } describe('Reaching the builder', () => { it('opens from the header button', async () => { await mountAt('/schema') await click(newTypeButtons()[0]) await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/schema/new')) expect(nameInput()).not.toBeNull() expect(text(main())).not.toContain(NOT_FOUND_TITLE) }) it('opens from the empty-state button', async () => { await mountAt('/schema', { content: () => [] }) await click(newTypeButtons().at(-1)) await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/schema/new')) expect(nameInput()).not.toBeNull() }) it('opens from the mobile floating button', async () => { await mountAt('/schema', { width: MOBILE_WIDTH }) await click(newTypeButtons().find((candidate) => candidate.className.includes('fixed'))) await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/schema/new')) expect(nameInput()).not.toBeNull() }) it('still says not found at /schema/unknown', async () => { await mountAt('/schema/unknown') expect(text(main())).toContain(NOT_FOUND_TITLE) expect(nameInput()).toBeNull() }) }) describe('Roles and sessions', () => { it.each(['owner', 'admin'])('shows the builder to a verified %s', async (role) => { await mountAt('/schema/new', { role }) expect(nameInput()).not.toBeNull() expect(text(main())).not.toContain(ACCESS_TITLE) }) it('tells a verified user who is neither to go home, leaving the URL', async () => { await mountAt('/schema/new', { role: 'user' }) expect(text(main())).toContain(ACCESS_TITLE) expect(button('Go to Home')).toBeInstanceOf(HTMLButtonElement) expect(router.currentRoute.value.path).toBe('/schema/new') expect(nameInput()).toBeNull() expect(el('[data-testid="field-row"]')).toBeNull() }) it('shows the same page when the content types answer 403', async () => { await mountAt('/schema/new', { content: failWith(403) }) expect(text(main())).toContain(ACCESS_TITLE) }) }) describe('The builder needs no built-in list', () => { it('calls the custom list once, never the built-in one, and shows no skeleton', async () => { await mountAt('/schema/new') expect(calls.filter((url) => url === '/v1/content-type')).toHaveLength(1) expect(calls).not.toContain('/v1/builtin-type') expect(nameInput()).not.toBeNull() expect(el('[data-testid="schema-skeleton-row"]')).toBeNull() }) it('shows the builder while the custom list is still loading', async () => { let release!: () => void const gate = new Promise((done) => (release = done)) await mountAt('/schema/new', { content: () => gate.then(() => []) }) expect(nameInput()).not.toBeNull() release() await flushPromises() }) }) describe('New type, desktop (design state 1)', () => { it('shows the breadcrumb, Name, Key and the list header', async () => { await mountAt('/schema/new') expect(text(el('nav[aria-label="breadcrumb"]'))).toBe('Schema New content type') expect(el('nav[aria-label="breadcrumb"] a[href="/schema"]')).not.toBeNull() expect(all('nav[aria-label="breadcrumb"] a')).toHaveLength(1) expect(nameInput()!.value).toBe('') expect(nameInput()!.placeholder).toBe('For example, Events') expect(nameInput()!.required).toBe(true) expect(nameInput()!.maxLength).toBe(100) expect(keyInput()!.value).toBe('') expect(keyInput()!.placeholder).toBe('events') expect(keyInput()!.readOnly).toBe(true) expect(text(main())).toContain( "Used in URLs and the API. Lowercase letters, digits and hyphens. It can't change after you save.", ) expect(text(main())).toContain('Fields') expect(text(el('[data-testid="field-count"]'))).toBe('1 of 50') }) it('shows the starter row and the inspector of Title', async () => { await mountAt('/schema/new') expect(rowLabels()).toEqual(['Title']) const row = el('[data-testid="field-row"]')! expect(text(row.querySelector('[data-testid="field-row-key"]'))).toBe('title') expect(row.querySelector('[data-field-row]')!.getAttribute('aria-current')).toBe('true') expect(text(row.querySelector('[data-testid="field-row-badges"]'))).toBe('New') expect(button('Add field')).toBeInstanceOf(HTMLButtonElement) expect(text(el('[data-testid="inspector-pane"] h2'))).toBe('Field: Title') expect(labelInput()!.value).toBe('Title') expect(el('input[name="field-key"]')!.value).toBe('title') const copy = text(inspector()) expect(copy).toContain('Auto') expect(copy).toContain( 'The name used in the API. Starts with a letter. Lowercase letters, digits and underscores.', ) expect(all('[role="radio"]')).toHaveLength(6) expect(el('[role="radio"][aria-checked="true"]')!.closest('label')!.textContent).toContain( 'Text', ) expect(copy).toContain("An entry can't be saved with this field empty.") expect(copy).toContain('Unique values') expect(copy).toContain('Used when an entry is created and this field is left out.') expect(copy).toContain('Shortest') expect(copy).toContain('Longest') expect(copy).toContain('Counted in characters. Entries show a counter.') expect(all('[role="switch"]').map((toggle) => toggle.getAttribute('aria-checked'))).toEqual( ['false', 'false'], ) }) it('disables Save and Discard, shows no chip, no entries bar and a collapsed API bar', async () => { await mountAt('/schema/new') expect(saveButton()!.disabled).toBe(true) expect(discardButton()!.disabled).toBe(true) expect(chip()).toBe('') expect(text(main())).not.toContain('entries use this content type') expect(el('[data-testid="api-preview-toggle"]')!.getAttribute('aria-expanded')).toBe( 'false', ) expect(el('[data-testid="api-json"]')).toBeNull() }) }) describe('New type, mobile (design state 1)', () => { it('shows the topbar, stacked Name and Key, the list and the API preview row', async () => { await mountAt('/schema/new', { width: MOBILE_WIDTH }) expect(el('a[href="/schema"][aria-label="Schema"]')).not.toBeNull() expect(text(el('[data-slot="title"]'))).toBe('New content type') expect(nameInput()).not.toBeNull() expect(keyInput()).not.toBeNull() expect(rowLabels()).toEqual(['Title']) expect(text(el('[data-testid="api-row"]'))).toBe('API preview') expect(el('[data-testid="api-row"] svg')).not.toBeNull() expect(inspector()).toBeNull() expect(el('[data-testid="inspector-pane"]')).toBeNull() expect(discardButton()).toBeInstanceOf(HTMLButtonElement) expect(saveButton()).toBeInstanceOf(HTMLButtonElement) expect(el('[data-testid="api-preview"]')).toBeNull() }) }) describe('The key follows the name until it is edited', () => { it.each([ ['Events', 'events', 'auto'], ['Press mentions', 'press-mentions', 'auto'], ['Café & Bar', 'cafe-bar', 'auto'], [' Job openings!', 'job-openings', 'auto'], ['2026 Awards', '2026-awards', 'auto'], ['!!!', '', 'empty'], ['x'.repeat(60), 'x'.repeat(40), 'auto'], ])('%j gives the key %j', async (name, key, mode) => { await mountAt('/schema/new') await typeInto(nameInput(), name) expect(keyInput()!.value).toBe(key) expect(keyInput()!.readOnly).toBe(true) expect(text(keyField()).includes('Auto')).toBe(mode === 'auto') }) it('unlocks with the pencil, keeps a typed key, and resets to the name', async () => { await mountAt('/schema/new') await typeInto(nameInput(), 'Events') await click(keyField().querySelector('button[aria-label="Edit key"]')) expect(keyInput()!.readOnly).toBe(false) expect(text(keyField())).not.toContain('Auto') expect(button('Reset to match the name')).toBeInstanceOf(HTMLButtonElement) await typeInto(keyInput(), 'agenda') await typeInto(nameInput(), 'Events and talks') expect(keyInput()!.value).toBe('agenda') await click(button('Reset to match the name')) expect(keyInput()!.value).toBe('events-and-talks') expect(keyInput()!.readOnly).toBe(true) expect(text(keyField())).toContain('Auto') }) })