feat(SPA): screens for Sites and Authors
This commit is contained in:
1 parent
b34a173d83
commit
3d81d2c717
82 files changed
+10676
-37
No files matched your search
@@ -62,8 +62,8 @@ afterEach(async () => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('DashboardShell renders the seven navigation destinations', () => {
|
||||
it('lists Home, Articles, Media, Users, Activity, Integration and Settings in order, with only Articles carrying aria-current', async () => {
|
||||
describe('DashboardShell renders the eight navigation destinations', () => {
|
||||
it('lists Home, Articles, Media, Authors, Users, Activity, Integration and Settings in order, with only Articles carrying aria-current', async () => {
|
||||
const wrapper = await mountShell('/article')
|
||||
|
||||
const hrefs = wrapper
|
||||
@@ -73,6 +73,7 @@ describe('DashboardShell renders the seven navigation destinations', () => {
|
||||
'/home',
|
||||
'/article',
|
||||
'/media',
|
||||
'/authors',
|
||||
'/users',
|
||||
'/activity',
|
||||
'/integration',
|
||||
@@ -90,7 +91,7 @@ describe('DashboardShell renders the seven navigation destinations', () => {
|
||||
expect(integration.attributes('aria-current')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('lists the same seven destinations in the mobile drawer and Settings current', async () => {
|
||||
it('lists the same eight destinations in the mobile drawer and Settings current', async () => {
|
||||
await mountShell('/settings', { width: MOBILE_WIDTH })
|
||||
|
||||
const toggle = document.body.querySelector('[data-slot="toggle"]') as HTMLButtonElement
|
||||
@@ -104,13 +105,14 @@ describe('DashboardShell renders the seven navigation destinations', () => {
|
||||
'/home',
|
||||
'/article',
|
||||
'/media',
|
||||
'/authors',
|
||||
'/users',
|
||||
'/activity',
|
||||
'/integration',
|
||||
'/settings',
|
||||
])
|
||||
expect(links[6].querySelector('[class*="lucide"], svg')).not.toBeNull()
|
||||
expect(links[6].getAttribute('aria-current')).toBe('page')
|
||||
expect(links[7].querySelector('[class*="lucide"], svg')).not.toBeNull()
|
||||
expect(links[7].getAttribute('aria-current')).toBe('page')
|
||||
})
|
||||
|
||||
it('marks Media with aria-current on /media, in the sidebar and in the mobile drawer', async () => {
|
||||
@@ -269,11 +271,11 @@ describe('The account chip shows the display name and no item is current on /pro
|
||||
expect(trigger?.getAttribute('aria-label')).toBe('Marta Alves — account menu')
|
||||
})
|
||||
|
||||
it('marks none of the seven navigation items current on /profile', async () => {
|
||||
it('marks none of the eight navigation items current on /profile', async () => {
|
||||
const wrapper = await mountShell('/profile')
|
||||
|
||||
const links = wrapper.findAll('nav[aria-label="Main"] a')
|
||||
expect(links).toHaveLength(7)
|
||||
expect(links).toHaveLength(8)
|
||||
links.forEach((link) => expect(link.attributes('aria-current')).toBeUndefined())
|
||||
})
|
||||
})
|
||||
@@ -747,7 +749,7 @@ describe('DashboardShell mobile drawer rows', () => {
|
||||
const drawer = await openDrawer('/settings')
|
||||
|
||||
const links = Array.from(drawer.querySelectorAll('nav[aria-label="Main"] a'))
|
||||
expect(links).toHaveLength(9)
|
||||
expect(links).toHaveLength(11)
|
||||
for (const link of links) {
|
||||
expect(classesOf(link)).toContain('max-lg:h-11')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { h } from 'vue'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import DirectoryList from '../DirectoryList.vue'
|
||||
import type { DirectoryMenuItem } from '@/lib/directoryMenu'
|
||||
import { MOBILE_WIDTH } from './viewport'
|
||||
import { byText, mountInApp, text, unmountApp } from './directoryKit'
|
||||
|
||||
interface Row {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
const ROWS: Row[] = [
|
||||
{ id: 1, name: 'First' },
|
||||
{
|
||||
id: 2,
|
||||
name: 'A very long name that must be truncated in the row and shown in full on hover',
|
||||
},
|
||||
]
|
||||
|
||||
function menu(row: Row): DirectoryMenuItem[] {
|
||||
return [
|
||||
{ key: 'edit', label: 'Edit', icon: 'i-lucide-pencil', onSelect: () => undefined },
|
||||
{
|
||||
key: 'delete',
|
||||
label: 'Delete…',
|
||||
icon: 'i-lucide-trash-2',
|
||||
destructive: true,
|
||||
separated: true,
|
||||
unavailable: row.id === 1,
|
||||
hint: 'Not on the first row.',
|
||||
onSelect: () => undefined,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function props(extra: Record<string, unknown> = {}) {
|
||||
return {
|
||||
items: ROWS,
|
||||
columns: 'minmax(0,1fr) 44px',
|
||||
headings: ['Name', ''],
|
||||
label: 'Rows',
|
||||
rowTitle: (row: Row) => row.name,
|
||||
menuItems: menu,
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
const slots = {
|
||||
cells: ({ item }: { item: Row }) =>
|
||||
h('span', { class: 'truncate', title: item.name }, item.name),
|
||||
card: ({ item }: { item: Row }) =>
|
||||
h('span', { class: 'truncate', title: item.name }, item.name),
|
||||
}
|
||||
|
||||
afterEach(unmountApp)
|
||||
|
||||
describe('DirectoryList', () => {
|
||||
it('draws a header and one row per item with a menu button on desktop', async () => {
|
||||
await mountInApp(DirectoryList, props(), slots)
|
||||
|
||||
expect(
|
||||
text(document.querySelector('[data-testid="directory-row"]')!.previousElementSibling),
|
||||
).toBe('Name')
|
||||
expect(document.querySelectorAll('[data-testid="directory-row"]')).toHaveLength(2)
|
||||
expect(byText('button', 'Actions for First')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps the full name available on hover for a truncated row', async () => {
|
||||
await mountInApp(DirectoryList, props(), slots)
|
||||
|
||||
const long = document.querySelectorAll<HTMLElement>('span.truncate')[1]!
|
||||
expect(long.getAttribute('title')).toBe(ROWS[1]!.name)
|
||||
})
|
||||
|
||||
it('draws cards below md', async () => {
|
||||
await mountInApp(DirectoryList, props(), slots, MOBILE_WIDTH)
|
||||
|
||||
expect(document.querySelectorAll('[data-testid="directory-card"]')).toHaveLength(2)
|
||||
expect(document.querySelector('[data-testid="directory-row"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('draws skeleton rows with aria-busy while loading', async () => {
|
||||
await mountInApp(DirectoryList, props({ loading: true, skeletonRows: 4 }), slots)
|
||||
|
||||
expect(document.querySelector('[aria-busy="true"]')).not.toBeNull()
|
||||
expect(document.querySelectorAll('[data-testid="directory-skeleton-row"]')).toHaveLength(4)
|
||||
expect(document.querySelector('[data-testid="directory-row"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('swaps the menu for a chevron and emits open in the read-only variant', async () => {
|
||||
const wrapper = await mountInApp(DirectoryList, props({ readOnly: true }), slots)
|
||||
|
||||
expect(document.querySelector('button[aria-label^="Actions for"]')).toBeNull()
|
||||
byText('button', 'View First')!.click()
|
||||
await flushPromises()
|
||||
|
||||
const list = wrapper.findComponent(DirectoryList)
|
||||
expect(list.emitted('open')![0]).toEqual([ROWS[0]])
|
||||
})
|
||||
|
||||
it('keeps an unavailable item focusable with aria-disabled and its hint', async () => {
|
||||
await mountInApp(DirectoryList, props(), slots)
|
||||
byText('button', 'Actions for First')!.click()
|
||||
await flushPromises()
|
||||
|
||||
const item = byText('[role="menuitem"]', /^Delete…/)!
|
||||
expect(item.getAttribute('aria-disabled')).toBe('true')
|
||||
expect(item.hasAttribute('disabled')).toBe(false)
|
||||
expect(text(document.getElementById(item.getAttribute('aria-describedby')!))).toBe(
|
||||
'Not on the first row.',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,122 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { h } from 'vue'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import DirectoryPanel from '../DirectoryPanel.vue'
|
||||
import { MOBILE_WIDTH } from './viewport'
|
||||
import { byText, mountInApp, text, unmountApp } from './directoryKit'
|
||||
|
||||
function body() {
|
||||
return h('input', { 'data-autofocus': '', placeholder: 'Name' })
|
||||
}
|
||||
|
||||
function content() {
|
||||
return document.body.querySelector<HTMLElement>('[role="dialog"]')!
|
||||
}
|
||||
|
||||
function escape() {
|
||||
content().dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||||
return flushPromises()
|
||||
}
|
||||
|
||||
afterEach(unmountApp)
|
||||
|
||||
describe('DirectoryPanel', () => {
|
||||
it('opens a right slide-over on desktop with a title, a Cancel and a submit button', async () => {
|
||||
await mountInApp(
|
||||
DirectoryPanel,
|
||||
{ title: 'Add thing', submitLabel: 'Add thing' },
|
||||
{ default: body },
|
||||
)
|
||||
|
||||
expect(text(content().querySelector('h2'))).toBe('Add thing')
|
||||
expect(content().className).toContain('max-w-120')
|
||||
expect(byText('button', 'Cancel', content())).toBeTruthy()
|
||||
expect(byText('button', 'Add thing', content())).toBeTruthy()
|
||||
})
|
||||
|
||||
it('opens a 92 percent bottom sheet below md', async () => {
|
||||
await mountInApp(
|
||||
DirectoryPanel,
|
||||
{ title: 'Add thing', submitLabel: 'Add thing' },
|
||||
{ default: body },
|
||||
MOBILE_WIDTH,
|
||||
)
|
||||
|
||||
expect(content().className).toContain('h-23/25')
|
||||
expect(content().className).toContain('before:w-9')
|
||||
})
|
||||
|
||||
it('moves focus into the panel', async () => {
|
||||
await mountInApp(
|
||||
DirectoryPanel,
|
||||
{ title: 'Add thing', submitLabel: 'Add' },
|
||||
{ default: body },
|
||||
)
|
||||
await flushPromises()
|
||||
|
||||
expect(content().contains(document.activeElement)).toBe(true)
|
||||
})
|
||||
|
||||
it('closes straight away when nothing changed', async () => {
|
||||
const wrapper = await mountInApp(
|
||||
DirectoryPanel,
|
||||
{ title: 'T', submitLabel: 'Add' },
|
||||
{ default: body },
|
||||
)
|
||||
await escape()
|
||||
|
||||
expect(wrapper.findComponent(DirectoryPanel).emitted('close')).toHaveLength(1)
|
||||
expect(document.body.querySelector('[role="alertdialog"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('asks Discard changes? when the form is dirty and closes only on confirmation', async () => {
|
||||
const wrapper = await mountInApp(
|
||||
DirectoryPanel,
|
||||
{ title: 'T', submitLabel: 'Add', dirty: true },
|
||||
{ default: body },
|
||||
)
|
||||
await escape()
|
||||
|
||||
const ask = document.body.querySelector<HTMLElement>('[role="alertdialog"]')!
|
||||
expect(text(ask)).toContain('Discard changes?')
|
||||
expect(wrapper.findComponent(DirectoryPanel).emitted('close')).toBeUndefined()
|
||||
|
||||
byText('button', 'Discard changes', ask)!.click()
|
||||
await flushPromises()
|
||||
expect(wrapper.findComponent(DirectoryPanel).emitted('close')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('ignores Escape while a request is running and labels the button Saving…', async () => {
|
||||
const wrapper = await mountInApp(
|
||||
DirectoryPanel,
|
||||
{ title: 'T', submitLabel: 'Add', saving: true, dirty: true },
|
||||
{ default: body },
|
||||
)
|
||||
await escape()
|
||||
|
||||
expect(wrapper.findComponent(DirectoryPanel).emitted('close')).toBeUndefined()
|
||||
expect(document.body.querySelector('[role="alertdialog"]')).toBeNull()
|
||||
expect(byText('button', 'Saving…', content())).toBeTruthy()
|
||||
expect(byText('button', 'Cancel', content())!.hasAttribute('disabled')).toBe(true)
|
||||
})
|
||||
|
||||
it('shows the failure notice inside the panel', async () => {
|
||||
await mountInApp(
|
||||
DirectoryPanel,
|
||||
{ title: 'T', submitLabel: 'Add', notice: { description: 'Could not save.' } },
|
||||
{ default: body },
|
||||
)
|
||||
|
||||
expect(text(content().querySelector('[role="alert"]'))).toBe('Could not save.')
|
||||
})
|
||||
|
||||
it('offers Close as the only action in the read-only variant', async () => {
|
||||
await mountInApp(DirectoryPanel, { title: 'Detail', readOnly: true }, { default: body })
|
||||
|
||||
const labels = Array.from(content().querySelectorAll('button'))
|
||||
.map((button) => text(button) || button.getAttribute('aria-label'))
|
||||
.filter(Boolean)
|
||||
expect(labels.filter((label) => label !== 'Close')).toEqual([])
|
||||
expect(byText('button', 'Cancel', content())).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,131 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import MediaField from '../MediaField.vue'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { byText, mountInApp, text, unmountApp } from './directoryKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
const ITEM = {
|
||||
id: 55,
|
||||
filename: 'face.jpg',
|
||||
url: '/media/face.jpg',
|
||||
size: 1000,
|
||||
content_type: 'image/jpeg',
|
||||
width: 400,
|
||||
height: 400,
|
||||
sha256: '0abc',
|
||||
uploaded_by: 'Marta Alves',
|
||||
created_at: '2026-09-12T12:00:00Z',
|
||||
used_in: 0,
|
||||
duplicate: false,
|
||||
variant_count: 0,
|
||||
variant_size: 0,
|
||||
}
|
||||
|
||||
const PICKER = {
|
||||
title: 'Choose an image',
|
||||
description: 'The default social image for Journal.',
|
||||
hint: 'Shown as a square.',
|
||||
confirmLabel: 'Use image',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(useApi).mockReturnValue({
|
||||
getMe: vi.fn(),
|
||||
request: vi.fn().mockResolvedValue({
|
||||
items: [ITEM],
|
||||
total: 1,
|
||||
page: 1,
|
||||
size: 25,
|
||||
counts: { all: 1, unused: 1, duplicates: 0 },
|
||||
}),
|
||||
} as unknown as ReturnType<typeof useApi>)
|
||||
})
|
||||
|
||||
afterEach(unmountApp)
|
||||
|
||||
describe('MediaField', () => {
|
||||
it('shows an optional label, its help and a Choose image button when empty', async () => {
|
||||
await mountInApp(MediaField, {
|
||||
label: 'Default social image',
|
||||
help: 'Used when an article has no image of its own.',
|
||||
picker: PICKER,
|
||||
})
|
||||
|
||||
const content = text(document.body)
|
||||
expect(content).toContain('Default social image')
|
||||
expect(content).toContain('(optional)')
|
||||
expect(content).toContain('Used when an article has no image of its own.')
|
||||
expect(byText('button', 'Choose image')).toBeTruthy()
|
||||
expect(byText('button', 'Remove')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('opens the existing picker with the given title, description, hint and confirm label', async () => {
|
||||
await mountInApp(MediaField, {
|
||||
label: 'Avatar',
|
||||
picker: PICKER,
|
||||
square: true,
|
||||
avatarName: 'Camille',
|
||||
})
|
||||
byText('button', 'Choose image')!.click()
|
||||
await flushPromises()
|
||||
|
||||
const picker = document.body.querySelector<HTMLElement>('[role="dialog"]')!
|
||||
expect(text(picker)).toContain('Choose an image')
|
||||
expect(text(picker)).toContain('The default social image for Journal.')
|
||||
expect(text(picker)).toContain('Shown as a square.')
|
||||
expect(byText('button', 'Use image', picker)!.hasAttribute('disabled')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns the picked image id through Use image and offers Change image and Remove', async () => {
|
||||
const wrapper = await mountInApp(MediaField, { label: 'Avatar', picker: PICKER })
|
||||
byText('button', 'Choose image')!.click()
|
||||
await flushPromises()
|
||||
const picker = document.body.querySelector<HTMLElement>('[role="dialog"]')!
|
||||
picker.querySelector<HTMLElement>('[data-picker-tile]')!.click()
|
||||
await flushPromises()
|
||||
byText('button', 'Use image', picker)!.click()
|
||||
await flushPromises()
|
||||
|
||||
const field = wrapper.findComponent(MediaField)
|
||||
expect(field.emitted('update:modelValue')![0]).toEqual([55])
|
||||
expect(byText('button', 'Change image')).toBeTruthy()
|
||||
expect(byText('button', 'Remove')).toBeTruthy()
|
||||
expect(text(document.body)).toContain('face.jpg')
|
||||
})
|
||||
|
||||
it('shows the chosen filename, Change image and Remove, and Remove clears the id', async () => {
|
||||
const wrapper = await mountInApp(MediaField, {
|
||||
label: 'Avatar',
|
||||
picker: PICKER,
|
||||
modelValue: 55,
|
||||
item: ITEM,
|
||||
})
|
||||
|
||||
expect(text(document.body)).toContain('face.jpg')
|
||||
expect(byText('button', 'Change image')).toBeTruthy()
|
||||
byText('button', 'Remove')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.findComponent(MediaField).emitted('update:modelValue')![0]).toEqual([null])
|
||||
})
|
||||
|
||||
it('draws the avatar variant as a square with initials when no image is chosen', async () => {
|
||||
await mountInApp(MediaField, {
|
||||
label: 'Avatar',
|
||||
picker: PICKER,
|
||||
square: true,
|
||||
avatarName: 'Camille Roche',
|
||||
})
|
||||
|
||||
const avatar = document.body.querySelector<HTMLElement>(
|
||||
'[data-slot="root"] [style*="64px"]',
|
||||
)
|
||||
expect(avatar ?? document.body.querySelector('[style*="width: 64px"]')).not.toBeNull()
|
||||
expect(text(document.body)).toContain('CR')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import ReassignDialog from '../ReassignDialog.vue'
|
||||
import { byText, mountInApp, text, unmountApp } from './directoryKit'
|
||||
|
||||
const OPTIONS = [
|
||||
{ value: 1, label: 'Renew', description: 'renew.example', display: 'Renew · renew.example' },
|
||||
{
|
||||
value: 3,
|
||||
label: 'Projects',
|
||||
description: 'projects.example',
|
||||
display: 'Projects · projects.example',
|
||||
},
|
||||
]
|
||||
|
||||
function props(extra: Record<string, unknown> = {}) {
|
||||
return {
|
||||
title: 'Delete this thing?',
|
||||
description: 'It will be deleted.',
|
||||
confirmLabel: 'Move 5 and delete',
|
||||
pendingLabel: 'Moving…',
|
||||
selectLabel: 'Move to',
|
||||
options: OPTIONS,
|
||||
selected: 1,
|
||||
summary: '5 will move to Renew.',
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
function dialog() {
|
||||
return document.body.querySelector<HTMLElement>('[role="alertdialog"]')!
|
||||
}
|
||||
|
||||
afterEach(unmountApp)
|
||||
|
||||
describe('ReassignDialog', () => {
|
||||
it('shows the copy, the selected option, the summary and both buttons', async () => {
|
||||
await mountInApp(ReassignDialog, props())
|
||||
|
||||
const content = text(dialog())
|
||||
expect(content).toContain('Delete this thing?')
|
||||
expect(content).toContain('It will be deleted.')
|
||||
expect(content).toContain('Move to')
|
||||
expect(text(dialog().querySelector('button[aria-haspopup="listbox"]'))).toBe(
|
||||
'Renew · renew.example',
|
||||
)
|
||||
expect(content).toContain('5 will move to Renew.')
|
||||
expect(byText('button', 'Cancel', dialog())).toBeTruthy()
|
||||
expect(byText('button', 'Move 5 and delete', dialog())).toBeTruthy()
|
||||
})
|
||||
|
||||
it('lists the options it is given with a name and a description', async () => {
|
||||
await mountInApp(ReassignDialog, props())
|
||||
dialog().querySelector<HTMLElement>('button[aria-haspopup="listbox"]')!.click()
|
||||
await flushPromises()
|
||||
|
||||
const labels = Array.from(dialog().querySelectorAll('[role="option"]')).map(text)
|
||||
expect(labels).toEqual(['Renew renew.example', 'Projects projects.example'])
|
||||
})
|
||||
|
||||
it('draws no select when there are no options', async () => {
|
||||
await mountInApp(ReassignDialog, props({ options: [], summary: '' }))
|
||||
|
||||
expect(dialog().querySelector('button[aria-haspopup="listbox"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('switches the confirm label and disables the buttons while pending', async () => {
|
||||
await mountInApp(ReassignDialog, props({ pending: true }))
|
||||
|
||||
expect(byText('button', 'Moving…', dialog())).toBeTruthy()
|
||||
expect(byText('button', 'Cancel', dialog())!.hasAttribute('disabled')).toBe(true)
|
||||
expect(dialog().getAttribute('aria-busy')).toBe('true')
|
||||
})
|
||||
|
||||
it('shows the failure notice with its title and text', async () => {
|
||||
await mountInApp(
|
||||
ReassignDialog,
|
||||
props({ notice: { title: 'Failed', description: 'Nothing was changed.' } }),
|
||||
)
|
||||
|
||||
expect(text(dialog().querySelector('[role="alert"]'))).toBe('Failed Nothing was changed.')
|
||||
})
|
||||
|
||||
it('emits confirm from the primary button', async () => {
|
||||
const wrapper = await mountInApp(ReassignDialog, props())
|
||||
byText('button', 'Move 5 and delete', dialog())!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.findComponent(ReassignDialog).emitted('confirm')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import SlugField from '../SlugField.vue'
|
||||
import { byText, mountInApp, text, unmountApp } from './directoryKit'
|
||||
|
||||
function props(extra: Record<string, unknown> = {}) {
|
||||
return {
|
||||
label: 'Slug',
|
||||
mode: 'empty',
|
||||
value: '',
|
||||
help: 'Generated from the name.',
|
||||
manualHelp: 'Lowercase letters.',
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
function input() {
|
||||
return document.body.querySelector<HTMLInputElement>('input')!
|
||||
}
|
||||
|
||||
afterEach(unmountApp)
|
||||
|
||||
describe('SlugField', () => {
|
||||
it('shows a read-only placeholder when there is no name yet', async () => {
|
||||
await mountInApp(SlugField, props())
|
||||
|
||||
expect(input().readOnly).toBe(true)
|
||||
expect(input().placeholder).toBe('Generated from the name')
|
||||
expect(text(document.body)).toContain('Generated from the name.')
|
||||
})
|
||||
|
||||
it('shows the automatic value with the Auto badge and a pencil that unlocks it', async () => {
|
||||
const wrapper = await mountInApp(SlugField, props({ mode: 'auto', value: 'camille-roche' }))
|
||||
|
||||
expect(input().value).toBe('camille-roche')
|
||||
expect(input().readOnly).toBe(true)
|
||||
expect(text(document.body)).toContain('Auto')
|
||||
byText('button', 'Edit slug')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.findComponent(SlugField).emitted('unlock')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('is editable in manual mode and offers Reset to match the name', async () => {
|
||||
const wrapper = await mountInApp(SlugField, props({ mode: 'manual', value: 'custom' }))
|
||||
const field = wrapper.findComponent(SlugField)
|
||||
|
||||
expect(input().readOnly).toBe(false)
|
||||
expect(text(document.body)).toContain('Lowercase letters.')
|
||||
input().value = 'custom-2'
|
||||
input().dispatchEvent(new Event('input', { bubbles: true }))
|
||||
expect(field.emitted('update:value')![0]).toEqual(['custom-2'])
|
||||
|
||||
byText('button', 'Reset to match the name')!.click()
|
||||
await flushPromises()
|
||||
expect(field.emitted('reset')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('shows the error and a suggestion that emits its value', async () => {
|
||||
const wrapper = await mountInApp(
|
||||
SlugField,
|
||||
props({
|
||||
mode: 'manual',
|
||||
value: 'camille-roche',
|
||||
error: 'Already used.',
|
||||
suggestion: 'camille-roche-2',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(text(document.body)).toContain('Already used.')
|
||||
byText('button', 'Use camille-roche-2')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.findComponent(SlugField).emitted('suggest')![0]).toEqual(['camille-roche-2'])
|
||||
})
|
||||
|
||||
it('draws the locked variant with a lock and its own help, with no pencil', async () => {
|
||||
await mountInApp(
|
||||
SlugField,
|
||||
props({ mode: 'saved', value: 'renew', locked: true, lockedHelp: "Can't change." }),
|
||||
)
|
||||
|
||||
expect(input().readOnly).toBe(true)
|
||||
expect(document.querySelector('[data-testid="slug-lock"]')).not.toBeNull()
|
||||
expect(text(document.body)).toContain("Can't change.")
|
||||
expect(byText('button', 'Edit slug')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import { defineComponent, h, type Component } from 'vue'
|
||||
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import UApp from '@nuxt/ui/components/App.vue'
|
||||
import router from '@/router'
|
||||
import { DESKTOP_WIDTH, setViewportWidth } from './viewport'
|
||||
|
||||
export const holder: { wrapper: VueWrapper | null } = { wrapper: null }
|
||||
|
||||
export async function mountInApp(
|
||||
component: Component,
|
||||
props: Record<string, unknown> = {},
|
||||
slots: Record<string, () => unknown> = {},
|
||||
width = DESKTOP_WIDTH,
|
||||
) {
|
||||
setViewportWidth(width)
|
||||
Element.prototype.scrollIntoView = () => undefined
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
holder.wrapper = mount(
|
||||
defineComponent({
|
||||
render: () =>
|
||||
h(UApp, null, {
|
||||
default: () => h(component, props, slots as never),
|
||||
}),
|
||||
}),
|
||||
{ attachTo: document.body, global: { plugins: [router, uiPlugin, pinia] } },
|
||||
)
|
||||
await flushPromises()
|
||||
return holder.wrapper
|
||||
}
|
||||
|
||||
export function unmountApp() {
|
||||
holder.wrapper?.unmount()
|
||||
holder.wrapper = null
|
||||
document.body.innerHTML = ''
|
||||
}
|
||||
|
||||
export function text(element: Element | null | undefined) {
|
||||
if (!element) {
|
||||
return ''
|
||||
}
|
||||
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT)
|
||||
const parts: string[] = []
|
||||
while (walker.nextNode()) {
|
||||
const value = walker.currentNode.textContent?.replace(/\s+/g, ' ').trim()
|
||||
if (value) {
|
||||
parts.push(value)
|
||||
}
|
||||
}
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
export function byText(selector: string, label: string | RegExp, root: ParentNode = document.body) {
|
||||
return Array.from(root.querySelectorAll<HTMLElement>(selector)).find((element) => {
|
||||
const value = element.getAttribute('aria-label') || text(element)
|
||||
return typeof label === 'string' ? value === label : label.test(value)
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user