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
@@ -79,12 +79,13 @@ describe('Navigation', () => {
|
||||
'/home',
|
||||
'/article',
|
||||
'/media',
|
||||
'/authors',
|
||||
'/users',
|
||||
'/activity',
|
||||
'/integration',
|
||||
'/settings',
|
||||
])
|
||||
const activity = links[4]!
|
||||
const activity = links[5]!
|
||||
expect(activity.getAttribute('aria-current')).toBe('page')
|
||||
await expectIcon(activity, 'history')
|
||||
expect(textOf(document.body.querySelector('nav[aria-label="breadcrumb"]'))).toBe('Activity')
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import {
|
||||
button,
|
||||
callsTo,
|
||||
chooseMenuItem,
|
||||
click,
|
||||
createBackend,
|
||||
dialog,
|
||||
failWith,
|
||||
mountDirectory,
|
||||
networkFailure,
|
||||
openRowMenu,
|
||||
pending,
|
||||
rowTexts,
|
||||
textOf,
|
||||
unmountDirectory,
|
||||
} from './directoryTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
afterEach(unmountDirectory)
|
||||
|
||||
async function openDelete(name: string, backend = createBackend()) {
|
||||
await mountDirectory('authors', '/authors', { backend })
|
||||
await openRowMenu(name)
|
||||
await chooseMenuItem('Delete…')
|
||||
return backend
|
||||
}
|
||||
|
||||
function box() {
|
||||
return dialog()!
|
||||
}
|
||||
|
||||
function trigger() {
|
||||
return box().querySelector<HTMLElement>('button[aria-haspopup="listbox"]')!
|
||||
}
|
||||
|
||||
async function openSelect() {
|
||||
trigger().click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function optionLabels() {
|
||||
return Array.from(box().querySelectorAll('[role="option"]')).map((option) => textOf(option))
|
||||
}
|
||||
|
||||
describe('Authors delete: reassign (recommended)', () => {
|
||||
it('explains, preselects the first other author and lists the buttons', async () => {
|
||||
const backend = await openDelete('Camille Roche')
|
||||
|
||||
const text = textOf(box())
|
||||
expect(box().getAttribute('role')).toBe('alertdialog')
|
||||
expect(text).toContain('Delete this author?')
|
||||
expect(text).toContain(
|
||||
'"Camille Roche" will be deleted and is credited on 3 articles. This can\'t be undone.',
|
||||
)
|
||||
expect(text).toContain('Credit these articles to')
|
||||
expect(textOf(trigger())).toBe('Inès Duarte')
|
||||
expect(text).toContain(
|
||||
'Where an article has several authors, Camille Roche is removed and the others stay.',
|
||||
)
|
||||
expect(button('Cancel', box())).toBeTruthy()
|
||||
expect(button('Delete and reassign 3 articles', box())).toBeTruthy()
|
||||
expect(
|
||||
backend.calls.filter((call) => call.raw === '/v1/author/?page=1&size=100'),
|
||||
).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('adds the linked-account line for a linked author', async () => {
|
||||
await openDelete('Inès Duarte')
|
||||
|
||||
expect(textOf(box())).toContain(
|
||||
"Linked to ines.duarte@renew.example. The user account isn't affected.",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Authors delete: reassign succeeds', () => {
|
||||
it('sends one DELETE with reassign_to, reads Reassigning… and toasts', async () => {
|
||||
const backend = createBackend()
|
||||
const hold = pending<unknown>()
|
||||
backend.responders['DELETE /v1/author/7'] = () => hold.promise
|
||||
await openDelete('Camille Roche', backend)
|
||||
await click('Delete and reassign 3 articles', box())
|
||||
|
||||
expect(button('Reassigning…', box())).toBeTruthy()
|
||||
hold.resolve(undefined)
|
||||
await flushPromises()
|
||||
|
||||
const deletes = callsTo(backend, 'DELETE', '/v1/author/7')
|
||||
expect(deletes).toHaveLength(1)
|
||||
expect(deletes[0]!.query.get('reassign_to')).toBe('2')
|
||||
expect(rowTexts().join(' ')).not.toContain('Camille Roche')
|
||||
expect(document.body.textContent).toContain('Author deleted')
|
||||
expect(document.body.textContent).toContain('3 articles now credit Inès Duarte.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Authors delete: choose who', () => {
|
||||
it('does not list the deleted author and ends with Nobody', async () => {
|
||||
await openDelete('Camille Roche')
|
||||
await openSelect()
|
||||
|
||||
const labels = optionLabels()
|
||||
expect(labels.some((label) => label.includes('Camille Roche'))).toBe(false)
|
||||
expect(labels.at(-1)).toContain('Nobody')
|
||||
expect(labels.at(-1)).toContain('Remove the byline')
|
||||
expect(labels[0]).toContain('Inès Duarte')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Authors delete: nobody', () => {
|
||||
it('explains the byline and sends reassign_to=none', async () => {
|
||||
const backend = await openDelete('Camille Roche')
|
||||
await openSelect()
|
||||
Array.from(box().querySelectorAll<HTMLElement>('[role="option"]'))
|
||||
.find((option) => textOf(option).includes('Nobody'))!
|
||||
.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(textOf(box())).toContain(
|
||||
'The articles keep any other authors. Articles with only this author will have no byline.',
|
||||
)
|
||||
await click('Delete and reassign 3 articles', box())
|
||||
|
||||
expect(callsTo(backend, 'DELETE', '/v1/author/7')[0]!.query.get('reassign_to')).toBe('none')
|
||||
})
|
||||
})
|
||||
|
||||
describe.each([
|
||||
['status 500', failWith(500)],
|
||||
['a network error', networkFailure],
|
||||
])('Authors delete: failed with %s', (_name, responder) => {
|
||||
it('keeps the dialog open with the notice and enables the buttons again', async () => {
|
||||
const backend = createBackend()
|
||||
backend.responders['DELETE /v1/author/7'] = responder
|
||||
await openDelete('Camille Roche', backend)
|
||||
await click('Delete and reassign 3 articles', box())
|
||||
|
||||
const alert = textOf(box().querySelector('[role="alert"]'))
|
||||
expect(alert).toContain("Couldn't delete the author")
|
||||
expect(alert).toContain('Nothing was changed. Try again in a moment.')
|
||||
expect(button('Cancel', box())!.hasAttribute('disabled')).toBe(false)
|
||||
expect(button('Delete and reassign 3 articles', box())!.hasAttribute('disabled')).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Authors delete: server fallback (409)', () => {
|
||||
it('opens the fallback dialog and Reassign articles reopens the reassign dialog', async () => {
|
||||
const backend = createBackend()
|
||||
backend.responders['DELETE /v1/author/7'] = failWith(409, 'author_has_articles')
|
||||
await openDelete('Camille Roche', backend)
|
||||
await click('Delete and reassign 3 articles', box())
|
||||
await flushPromises()
|
||||
|
||||
const text = textOf(box())
|
||||
expect(text).toContain("Can't delete this author")
|
||||
expect(text).toContain(
|
||||
'"Camille Roche" is credited on 3 articles. Choose another author to credit instead, or remove Camille Roche from those articles first.',
|
||||
)
|
||||
expect(button('Close', box())).toBeTruthy()
|
||||
expect(button('Reassign articles', box())).toBeTruthy()
|
||||
|
||||
await click('Reassign articles', box())
|
||||
await flushPromises()
|
||||
|
||||
expect(textOf(box())).toContain('Delete this author?')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Authors delete: no articles', () => {
|
||||
it('asks a plain confirmation with no select and sends no reassign_to', async () => {
|
||||
const backend = await openDelete('Léa Fontaine')
|
||||
|
||||
const text = textOf(box())
|
||||
expect(text).toContain('Delete this author?')
|
||||
expect(text).toContain(
|
||||
'"Léa Fontaine" will be deleted. No articles credit this author. This can\'t be undone.',
|
||||
)
|
||||
expect(trigger()).toBeNull()
|
||||
expect(button('Delete author', box())).toBeTruthy()
|
||||
|
||||
await click('Delete author', box())
|
||||
|
||||
const deletes = callsTo(backend, 'DELETE', '/v1/author/10')
|
||||
expect(deletes).toHaveLength(1)
|
||||
expect(deletes[0]!.query.has('reassign_to')).toBe(false)
|
||||
expect(rowTexts().join(' ')).not.toContain('Léa Fontaine')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Authors delete: author already gone', () => {
|
||||
it('closes, removes the row and shows the info toast on 404', async () => {
|
||||
const backend = createBackend()
|
||||
backend.responders['DELETE /v1/author/7'] = failWith(404, 'author_not_found')
|
||||
await openDelete('Camille Roche', backend)
|
||||
await click('Delete and reassign 3 articles', box())
|
||||
|
||||
expect(dialog()).toBeNull()
|
||||
expect(rowTexts().join(' ')).not.toContain('Camille Roche')
|
||||
expect(document.body.textContent).toContain('That author no longer exists')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,543 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import {
|
||||
authorsFixture,
|
||||
blur,
|
||||
button,
|
||||
callsTo,
|
||||
chooseMenuItem,
|
||||
click,
|
||||
createBackend,
|
||||
dialog,
|
||||
failWith,
|
||||
field,
|
||||
labelled,
|
||||
mountDirectory,
|
||||
networkFailure,
|
||||
openRowMenu,
|
||||
pending,
|
||||
rowTexts,
|
||||
textOf,
|
||||
typeInto,
|
||||
unmountDirectory,
|
||||
} from './directoryTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
const URL_MESSAGE = 'Enter a full URL starting with https://.'
|
||||
const SAVE_FAILED = "Couldn't save the author. Your changes are still here. Try again in a moment."
|
||||
|
||||
afterEach(unmountDirectory)
|
||||
|
||||
function box() {
|
||||
return dialog()!
|
||||
}
|
||||
|
||||
function nameField() {
|
||||
return field('e.g. Camille Roche')
|
||||
}
|
||||
|
||||
function bioField() {
|
||||
return field('A sentence or two about the author.')
|
||||
}
|
||||
|
||||
function websiteField() {
|
||||
return field('https://example.com')
|
||||
}
|
||||
|
||||
function slugField() {
|
||||
return labelled('Slug') as HTMLInputElement
|
||||
}
|
||||
|
||||
function submit(label = 'Add author') {
|
||||
return click(label, box())
|
||||
}
|
||||
|
||||
function userTrigger() {
|
||||
return box().querySelector<HTMLElement>('button[aria-haspopup="listbox"]')!
|
||||
}
|
||||
|
||||
async function openAdd(options: Parameters<typeof mountDirectory>[2] = {}) {
|
||||
const backend = await mountDirectory('authors', '/authors', options)
|
||||
await click('Add author')
|
||||
return backend
|
||||
}
|
||||
|
||||
async function openEdit(name: string, options: Parameters<typeof mountDirectory>[2] = {}) {
|
||||
const backend = await mountDirectory('authors', '/authors', options)
|
||||
await openRowMenu(name)
|
||||
await chooseMenuItem('Edit')
|
||||
return backend
|
||||
}
|
||||
|
||||
describe('Authors form: add, empty', () => {
|
||||
it('opens a slide-over with every field, placeholder, counter and the byline preview', async () => {
|
||||
await openAdd()
|
||||
|
||||
const text = textOf(box())
|
||||
for (const copy of [
|
||||
'Add author',
|
||||
'Avatar (optional)',
|
||||
'Choose image',
|
||||
'Name',
|
||||
'Slug',
|
||||
'Bio (optional)',
|
||||
'0 / 280',
|
||||
'Website (optional)',
|
||||
'Social links (optional)',
|
||||
'Add link',
|
||||
'0 of 5',
|
||||
'Linked user (optional)',
|
||||
'Link a CMS user if this author has an account. Guest writers need no link.',
|
||||
'Byline preview',
|
||||
'Author name',
|
||||
'The bio appears here.',
|
||||
'Cancel',
|
||||
]) {
|
||||
expect(text).toContain(copy)
|
||||
}
|
||||
expect(nameField()).toBeTruthy()
|
||||
expect(field('Generated from the name')).toBeTruthy()
|
||||
expect(bioField()).toBeTruthy()
|
||||
expect(websiteField()).toBeTruthy()
|
||||
expect(textOf(userTrigger())).toBe('No linked user')
|
||||
expect(button('Add author', box())).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Authors form: slug from the name', () => {
|
||||
it('fills the slug with an Auto badge, an Edit slug pencil and follows the byline preview', async () => {
|
||||
await openAdd()
|
||||
await typeInto(nameField(), 'Camille Roche')
|
||||
|
||||
expect(slugField().value).toBe('camille-roche')
|
||||
const text = textOf(box())
|
||||
expect(text).toContain('Auto')
|
||||
expect(button('Edit slug', box())).toBeTruthy()
|
||||
expect(text).toContain(
|
||||
'Generated from the name. Used in public author URLs and API paths. Unique across all authors.',
|
||||
)
|
||||
expect(text).toContain('Byline preview Camille Roche')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Authors form: add succeeds', () => {
|
||||
it('sends exactly one POST with the exact body, reads Saving… and lists the author', async () => {
|
||||
const backend = createBackend()
|
||||
const hold = pending<unknown>()
|
||||
backend.responders['POST /v1/author/'] = (call) => {
|
||||
backend.authors.push({
|
||||
id: 200,
|
||||
is_linked: false,
|
||||
article_count: 0,
|
||||
...call.body,
|
||||
} as never)
|
||||
return hold.promise
|
||||
}
|
||||
await openAdd({ backend })
|
||||
await typeInto(nameField(), 'Zoe Lambert')
|
||||
await typeInto(bioField(), 'Writes about plaster.')
|
||||
await typeInto(websiteField(), 'https://zoe.example')
|
||||
await click('Add link', box())
|
||||
await typeInto(
|
||||
box().querySelector<HTMLInputElement>('input[placeholder="https://x.com/username"]')!,
|
||||
'https://x.com/zoe',
|
||||
)
|
||||
await submit()
|
||||
|
||||
expect(button('Saving…', box())).toBeTruthy()
|
||||
hold.resolve({ id: 200 })
|
||||
await flushPromises()
|
||||
|
||||
const posts = callsTo(backend, 'POST', '/v1/author/')
|
||||
expect(posts).toHaveLength(1)
|
||||
expect(posts[0]!.body).toEqual({
|
||||
name: 'Zoe Lambert',
|
||||
slug: 'zoe-lambert',
|
||||
bio: 'Writes about plaster.',
|
||||
avatar_id: null,
|
||||
website: 'https://zoe.example',
|
||||
social_links: [{ type: 'x', url: 'https://x.com/zoe' }],
|
||||
user_id: null,
|
||||
})
|
||||
expect(dialog()).toBeNull()
|
||||
expect(rowTexts().join(' ')).toContain('Zoe Lambert')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Authors form: slug collision', () => {
|
||||
it('shows the message, a suggestion and the reset link, and the suggestion keeps the other values', async () => {
|
||||
const backend = createBackend()
|
||||
backend.responders['POST /v1/author/'] = failWith(409, 'author_slug_taken')
|
||||
await openAdd({ backend })
|
||||
await typeInto(nameField(), 'Camille Roche')
|
||||
await typeInto(bioField(), 'A bio')
|
||||
await submit()
|
||||
|
||||
const text = textOf(box())
|
||||
expect(text).toContain('The slug "camille-roche" is already used by another author.')
|
||||
expect(text).toContain('Use camille-roche-2')
|
||||
expect(text).toContain('Reset to match the name')
|
||||
|
||||
await click('Use camille-roche-2', box())
|
||||
|
||||
expect(slugField().value).toBe('camille-roche-2')
|
||||
expect(bioField().value).toBe('A bio')
|
||||
expect(nameField().value).toBe('Camille Roche')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Authors form: validation', () => {
|
||||
it('asks for a name and sends nothing', async () => {
|
||||
const backend = await openAdd()
|
||||
await submit()
|
||||
|
||||
expect(textOf(box())).toContain('Enter a name.')
|
||||
expect(callsTo(backend, 'POST', '/v1/author/')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('refuses a bio of 291 characters with the counter in the error colour', async () => {
|
||||
const backend = await openAdd()
|
||||
await typeInto(nameField(), 'Zoe')
|
||||
await typeInto(bioField(), 'x'.repeat(291))
|
||||
await submit()
|
||||
|
||||
expect(textOf(box())).toContain('Shorten the bio to 280 characters.')
|
||||
const counter = Array.from(box().querySelectorAll('span')).find(
|
||||
(span) => textOf(span) === '291 / 280' && !span.querySelector('span'),
|
||||
)!
|
||||
expect(counter.className).toContain('text-error')
|
||||
expect(callsTo(backend, 'POST', '/v1/author/')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('refuses a website without https', async () => {
|
||||
const backend = await openAdd()
|
||||
await typeInto(nameField(), 'Zoe')
|
||||
await typeInto(websiteField(), 'example.com')
|
||||
await submit()
|
||||
|
||||
expect(textOf(box())).toContain(URL_MESSAGE)
|
||||
expect(callsTo(backend, 'POST', '/v1/author/')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('refuses a social link with an http URL under its row', async () => {
|
||||
const backend = await openAdd()
|
||||
await typeInto(nameField(), 'Zoe')
|
||||
await click('Add link', box())
|
||||
await typeInto(
|
||||
box().querySelector<HTMLInputElement>('input[placeholder="https://x.com/username"]')!,
|
||||
'http://x',
|
||||
)
|
||||
await submit()
|
||||
|
||||
const row = box()
|
||||
.querySelector('[data-testid="social-link-row"]')!
|
||||
.closest('[data-slot="root"]')!
|
||||
expect(textOf(row)).toContain(URL_MESSAGE)
|
||||
expect(callsTo(backend, 'POST', '/v1/author/')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe.each([
|
||||
[422, 'bio_too_long', 'Shorten the bio to 280 characters.'],
|
||||
[422, 'invalid_url', URL_MESSAGE],
|
||||
[409, 'user_already_linked', 'That user already has an author. Choose another user.'],
|
||||
])('Authors form: server validation %s %s', (status, code, message) => {
|
||||
it('shows the message under the field', async () => {
|
||||
const backend = createBackend()
|
||||
backend.responders['POST /v1/author/'] = failWith(status, code)
|
||||
await openAdd({ backend })
|
||||
await typeInto(nameField(), 'Zoe')
|
||||
await typeInto(websiteField(), 'https://zoe.example')
|
||||
await submit()
|
||||
|
||||
expect(textOf(box())).toContain(message)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Authors form: social links', () => {
|
||||
it('adds rows with a type select, a type-specific placeholder, and a limit of five', async () => {
|
||||
await openAdd()
|
||||
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
await click('Add link', box())
|
||||
}
|
||||
|
||||
expect(box().querySelectorAll('[data-testid="social-link-row"]')).toHaveLength(5)
|
||||
expect(button('Add link', box())!.hasAttribute('disabled')).toBe(true)
|
||||
expect(textOf(box())).toContain('You can add up to 5 links.')
|
||||
expect(textOf(box())).toContain('5 of 5')
|
||||
expect(box().querySelectorAll('input[placeholder="https://x.com/username"]')).toHaveLength(
|
||||
5,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Authors form: linked user, choose', () => {
|
||||
it('lists the users without an author under their group', async () => {
|
||||
const backend = await openAdd()
|
||||
userTrigger().click()
|
||||
await flushPromises()
|
||||
|
||||
const text = textOf(box())
|
||||
expect(text).toContain('Users without an author')
|
||||
expect(text).toContain('Marta Alves marta@renew.example')
|
||||
expect(text).toContain('Rui Santos rui@renew.example')
|
||||
expect(text).not.toContain('Inès Duarte ines.duarte')
|
||||
expect(callsTo(backend, 'GET', '/v1/user/')).toHaveLength(1)
|
||||
expect(
|
||||
backend.calls.filter((call) => call.raw === '/v1/author/?page=1&size=100'),
|
||||
).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('pages through the authors to find every linked user', async () => {
|
||||
const backend = createBackend()
|
||||
backend.authors = Array.from({ length: 120 }, (_, index) => ({
|
||||
...authorsFixture()[1]!,
|
||||
id: 600 + index,
|
||||
name: `Writer ${index}`,
|
||||
slug: `writer-${index}`,
|
||||
user_id: index === 119 ? 1 : null,
|
||||
}))
|
||||
await openAdd({ backend })
|
||||
userTrigger().click()
|
||||
await flushPromises()
|
||||
|
||||
const paths = backend.calls
|
||||
.filter((call) => call.path === '/v1/author/')
|
||||
.map((call) => call.raw)
|
||||
expect(paths).toContain('/v1/author/?page=1&size=100')
|
||||
expect(paths).toContain('/v1/author/?page=2&size=100')
|
||||
expect(textOf(box())).not.toContain('Marta Alves')
|
||||
expect(textOf(box())).toContain('Rui Santos')
|
||||
})
|
||||
|
||||
it('says so when every user already has an author', async () => {
|
||||
const backend = createBackend()
|
||||
backend.authors = authorsFixture().map((item, index) => ({ ...item, user_id: index + 1 }))
|
||||
backend.users = backend.users.slice(0, 1)
|
||||
await openAdd({ backend })
|
||||
userTrigger().click()
|
||||
await flushPromises()
|
||||
|
||||
expect(textOf(box())).toContain('Every user already has an author.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Authors form: edit a linked author', () => {
|
||||
it('fills the fields, shows the linked user with Unlink and sends only the changed fields', async () => {
|
||||
const backend = await openEdit('Inès Duarte')
|
||||
|
||||
const text = textOf(box())
|
||||
expect(text).toContain('Edit Inès Duarte')
|
||||
expect(nameField().value).toBe('Inès Duarte')
|
||||
expect(slugField().value).toBe('ines-duarte')
|
||||
expect(text).toContain('ines.duarte@renew.example')
|
||||
expect(button('Unlink', box())).toBeTruthy()
|
||||
expect(text).toContain('New articles by this user start with this author.')
|
||||
|
||||
await typeInto(bioField(), 'A new bio')
|
||||
await submit('Save changes')
|
||||
|
||||
const patches = callsTo(backend, 'PATCH', '/v1/author/2')
|
||||
expect(patches).toHaveLength(1)
|
||||
expect(patches[0]!.body).toEqual({ bio: 'A new bio' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('Authors form: unlink', () => {
|
||||
it('asks, sends one PATCH with user_id null, returns to No linked user and toasts', async () => {
|
||||
const backend = await openEdit('Inès Duarte')
|
||||
await click('Unlink', box())
|
||||
|
||||
const ask = document.body.querySelector('[role="alertdialog"]')!
|
||||
const text = textOf(ask)
|
||||
expect(text).toContain('Unlink Inès Duarte?')
|
||||
expect(text).toContain(
|
||||
'Inès Duarte will stay on their articles. New articles by ines.duarte@renew.example will no longer start with this author.',
|
||||
)
|
||||
expect(button('Cancel', ask)).toBeTruthy()
|
||||
await click('Unlink user', ask)
|
||||
|
||||
const patches = callsTo(backend, 'PATCH', '/v1/author/2')
|
||||
expect(patches).toHaveLength(1)
|
||||
expect(patches[0]!.body).toEqual({ user_id: null })
|
||||
expect(textOf(userTrigger())).toBe('No linked user')
|
||||
expect(document.body.textContent).toContain('User unlinked')
|
||||
expect(document.body.textContent).toContain('Inès Duarte is now a guest author.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Authors form: link a guest', () => {
|
||||
it('links a user from the row menu and shows Linked on the row', async () => {
|
||||
const backend = await mountDirectory('authors', '/authors')
|
||||
await openRowMenu('Camille Roche')
|
||||
await chooseMenuItem('Link user')
|
||||
|
||||
const ask = document.body.querySelector('[role="alertdialog"]')!
|
||||
expect(textOf(ask)).toContain('Link Camille Roche to a user?')
|
||||
expect(textOf(ask)).toContain('Only users without an author are listed.')
|
||||
expect(button('Link user', ask)!.hasAttribute('disabled')).toBe(true)
|
||||
|
||||
ask.querySelector<HTMLElement>('button[aria-haspopup="listbox"]')!.click()
|
||||
await flushPromises()
|
||||
expect(textOf(ask)).toContain('Marta Alves marta@renew.example')
|
||||
const option = Array.from(ask.querySelectorAll<HTMLElement>('[role="option"]')).find(
|
||||
(item) => textOf(item).includes('Marta Alves'),
|
||||
)!
|
||||
option.click()
|
||||
await flushPromises()
|
||||
await click('Link user', ask)
|
||||
|
||||
const patches = callsTo(backend, 'PATCH', '/v1/author/7')
|
||||
expect(patches).toHaveLength(1)
|
||||
expect(patches[0]!.body).toEqual({ user_id: 1 })
|
||||
expect(rowTexts()[1]).toContain('Linked')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Authors form: avatar picker', () => {
|
||||
it('opens the media picker for the avatar, returns the id and sends avatar_id on save', async () => {
|
||||
const backend = createBackend()
|
||||
backend.responders['GET /v1/media/'] = () => ({
|
||||
items: [
|
||||
{
|
||||
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,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
page: 1,
|
||||
size: 25,
|
||||
counts: { all: 1, unused: 1, duplicates: 0 },
|
||||
})
|
||||
await openEdit('Camille Roche', { backend })
|
||||
await click('Choose image', box())
|
||||
await flushPromises()
|
||||
|
||||
const picker = Array.from(document.body.querySelectorAll('[role="dialog"]')).at(-1)!
|
||||
const text = textOf(picker)
|
||||
expect(text).toContain('Choose an image')
|
||||
expect(text).toContain('The avatar for Camille Roche.')
|
||||
expect(text).toContain(
|
||||
'Shown as a square. Choose an image with the face or logo near the middle.',
|
||||
)
|
||||
picker.querySelector<HTMLElement>('[data-picker-tile]')!.click()
|
||||
await flushPromises()
|
||||
await click('Use image', picker)
|
||||
|
||||
expect(textOf(box())).toContain('face.jpg')
|
||||
await submit('Save changes')
|
||||
|
||||
expect(callsTo(backend, 'PATCH', '/v1/author/7')[0]!.body).toEqual({ avatar_id: 55 })
|
||||
})
|
||||
})
|
||||
|
||||
describe.each([
|
||||
['status 500', failWith(500)],
|
||||
['a network error', networkFailure],
|
||||
])('Authors form: save failed with %s', (_name, responder) => {
|
||||
it('shows the notice and keeps the values', async () => {
|
||||
const backend = createBackend()
|
||||
backend.responders['POST /v1/author/'] = responder
|
||||
await openAdd({ backend })
|
||||
await typeInto(nameField(), 'Zoe')
|
||||
await typeInto(bioField(), 'Keep me')
|
||||
await submit()
|
||||
|
||||
expect(textOf(box().querySelector('[role="alert"]'))).toContain(SAVE_FAILED)
|
||||
expect(nameField().value).toBe('Zoe')
|
||||
expect(bioField().value).toBe('Keep me')
|
||||
expect(nameField().disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Authors form: role changed while open', () => {
|
||||
it('raises an error toast on 403 author_rights', async () => {
|
||||
const backend = createBackend()
|
||||
backend.responders['POST /v1/author/'] = failWith(403, 'author_rights')
|
||||
await openAdd({ backend })
|
||||
await typeInto(nameField(), 'Zoe')
|
||||
await submit()
|
||||
|
||||
expect(document.body.textContent).toContain('Only an owner or admin can change authors')
|
||||
expect(document.body.textContent).toContain('You can still edit your own author profile.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Authors form: deep link', () => {
|
||||
it('opens the edit slide-over for an owner or admin', async () => {
|
||||
await mountDirectory('authors', '/authors?edit=camille-roche', { role: 'admin' })
|
||||
|
||||
expect(textOf(box())).toContain('Edit Camille Roche')
|
||||
})
|
||||
|
||||
it('shows an info toast and opens nothing for an unknown slug', async () => {
|
||||
await mountDirectory('authors', '/authors?edit=nobody-here')
|
||||
|
||||
expect(dialog()).toBeNull()
|
||||
expect(document.body.textContent).toContain('That author no longer exists')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Authors form: own author, any role, by deep link only', () => {
|
||||
function memberBackend() {
|
||||
const backend = createBackend()
|
||||
backend.authors = authorsFixture(false)
|
||||
return backend
|
||||
}
|
||||
const own = { id: 2, name: 'Inès Duarte', slug: 'ines-duarte' }
|
||||
|
||||
it('opens the form with the linked user locked, no Unlink, no Link user and no delete', async () => {
|
||||
const backend = memberBackend()
|
||||
await mountDirectory('authors', '/authors?edit=ines-duarte', {
|
||||
backend,
|
||||
role: 'member',
|
||||
author: own,
|
||||
email: 'ines.duarte@renew.example',
|
||||
})
|
||||
|
||||
const text = textOf(box())
|
||||
expect(text).toContain('Edit Inès Duarte')
|
||||
expect(text).toContain('ines.duarte@renew.example')
|
||||
expect(text).toContain(
|
||||
'This is your author profile. Only an owner or admin can change the link.',
|
||||
)
|
||||
expect(button('Unlink', box())).toBeUndefined()
|
||||
expect(button('Link user', box())).toBeUndefined()
|
||||
expect(button(/Delete/, box())).toBeUndefined()
|
||||
|
||||
await typeInto(bioField(), 'Mine')
|
||||
await submit('Save changes')
|
||||
|
||||
expect(callsTo(backend, 'PATCH', '/v1/author/2')[0]!.body).toEqual({ bio: 'Mine' })
|
||||
})
|
||||
|
||||
it('opens the read-only detail for another author', async () => {
|
||||
await mountDirectory('authors', '/authors?edit=camille-roche', {
|
||||
backend: memberBackend(),
|
||||
role: 'member',
|
||||
author: own,
|
||||
})
|
||||
|
||||
expect(textOf(box())).toContain('Only an owner or admin can change this author.')
|
||||
expect(box().querySelectorAll('input, textarea')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
void blur
|
||||
@@ -0,0 +1,324 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import router from '@/router'
|
||||
import type { Author } from '@/composables/useAuthors'
|
||||
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
||||
import {
|
||||
authorsFixture,
|
||||
button,
|
||||
callsTo,
|
||||
click,
|
||||
createBackend,
|
||||
dialog,
|
||||
failWith,
|
||||
mountDirectory,
|
||||
networkFailure,
|
||||
openRowMenu,
|
||||
pending,
|
||||
rowTexts,
|
||||
rows,
|
||||
textOf,
|
||||
typeInto,
|
||||
unmountDirectory,
|
||||
} from './directoryTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
const SKELETON_DELAY_MS = 150
|
||||
const SEARCH_DEBOUNCE_MS = 120
|
||||
const OWNER_DESCRIPTION =
|
||||
"The names readers see on your articles. An author doesn't need an account."
|
||||
const READ_ONLY_DESCRIPTION =
|
||||
'The names readers see on your articles. Only an owner or admin can change authors.'
|
||||
|
||||
afterEach(unmountDirectory)
|
||||
|
||||
function listCalls(backend: ReturnType<typeof createBackend>) {
|
||||
return backend.calls.filter((call) => call.method === 'GET' && call.path === '/v1/author/')
|
||||
}
|
||||
|
||||
function manyAuthors(count: number): Author[] {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
...authorsFixture()[1]!,
|
||||
id: 500 + index,
|
||||
name: `Writer ${String(index).padStart(2, '0')}`,
|
||||
slug: `writer-${index}`,
|
||||
}))
|
||||
}
|
||||
|
||||
describe.each(['owner', 'admin'])('Authors: default for %s', (role) => {
|
||||
it('shows the header, the search field, the columns and the rows', async () => {
|
||||
const backend = await mountDirectory('authors', '/authors', { role })
|
||||
|
||||
expect(document.body.querySelector('h1')?.textContent).toBe('Authors')
|
||||
expect(document.body.textContent).toContain(OWNER_DESCRIPTION)
|
||||
expect(button('Add author')).toBeTruthy()
|
||||
expect(
|
||||
document.body.querySelector('input[placeholder="Search by name or slug"]'),
|
||||
).toBeTruthy()
|
||||
const header = document.body.querySelector(
|
||||
'[data-testid="directory-row"]',
|
||||
)!.previousElementSibling!
|
||||
expect(textOf(header)).toBe('Author Slug Account Articles')
|
||||
expect(rowTexts()).toEqual([
|
||||
'ID Inès Duarte ines-duarte Linked 9 articles',
|
||||
'CR Camille Roche camille-roche Guest 3 articles',
|
||||
'AK Dr. Alexandra Konstantinopoulou-Whitfield dr-alexandra-konstantinopoulou-whitfield Guest 1 article',
|
||||
'LF Léa Fontaine lea-fontaine Guest No articles',
|
||||
])
|
||||
expect(listCalls(backend)[0]!.raw).toBe('/v1/author/?page=1&size=25')
|
||||
})
|
||||
|
||||
it('puts Authors between Media and Users in the sidebar, current on /authors', async () => {
|
||||
await mountDirectory('authors', '/authors', { role })
|
||||
|
||||
const links = Array.from(
|
||||
document.body.querySelectorAll<HTMLAnchorElement>('nav[aria-label="Main"] a'),
|
||||
)
|
||||
const hrefs = links.map((link) => link.getAttribute('href'))
|
||||
expect(hrefs.slice(2, 5)).toEqual(['/media', '/authors', '/users'])
|
||||
expect(links[3]!.getAttribute('aria-current')).toBe('page')
|
||||
})
|
||||
|
||||
it('shows a long name in full on hover', async () => {
|
||||
await mountDirectory('authors', '/authors', { role })
|
||||
|
||||
const name = Array.from(document.body.querySelectorAll('span.truncate')).find((span) =>
|
||||
textOf(span).startsWith('Dr. Alexandra'),
|
||||
)!
|
||||
expect(name.getAttribute('title')).toBe('Dr. Alexandra Konstantinopoulou-Whitfield')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Authors: loading', () => {
|
||||
it('draws skeleton rows after the delay and marks the list busy', async () => {
|
||||
vi.useFakeTimers()
|
||||
const backend = createBackend()
|
||||
const hold = pending<unknown>()
|
||||
backend.responders['GET /v1/author/'] = () => hold.promise
|
||||
await mountDirectory('authors', '/authors', { backend })
|
||||
|
||||
expect(document.body.querySelector('[aria-busy="true"]')).toBeNull()
|
||||
await vi.advanceTimersByTimeAsync(SKELETON_DELAY_MS)
|
||||
await flushPromises()
|
||||
|
||||
expect(document.body.querySelector('[aria-busy="true"]')).not.toBeNull()
|
||||
expect(
|
||||
document.body.querySelectorAll('[data-testid="directory-skeleton-row"]'),
|
||||
).toHaveLength(6)
|
||||
})
|
||||
})
|
||||
|
||||
describe.each([
|
||||
['status 500', failWith(500)],
|
||||
['a network error', networkFailure],
|
||||
])('Authors: error on load with %s', (_name, responder) => {
|
||||
it('shows the alert and requests the list again on Try again', async () => {
|
||||
const backend = createBackend()
|
||||
backend.responders['GET /v1/author/'] = responder
|
||||
await mountDirectory('authors', '/authors', { backend })
|
||||
|
||||
const alert = textOf(document.body.querySelector('[role="alert"]'))
|
||||
expect(alert).toContain("Couldn't load authors")
|
||||
expect(alert).toContain('Check your connection and try again.')
|
||||
|
||||
delete backend.responders['GET /v1/author/']
|
||||
await click('Try again')
|
||||
|
||||
expect(listCalls(backend)).toHaveLength(2)
|
||||
expect(rows()).toHaveLength(4)
|
||||
})
|
||||
})
|
||||
|
||||
describe.each([
|
||||
['owner', true],
|
||||
['admin', true],
|
||||
['member', false],
|
||||
])('Authors: empty, first run for %s', (role, canAdd) => {
|
||||
it('hides the toolbar and shows the empty state', async () => {
|
||||
const backend = createBackend()
|
||||
backend.authors = []
|
||||
await mountDirectory('authors', '/authors', { backend, role })
|
||||
|
||||
expect(
|
||||
document.body.querySelector('input[placeholder="Search by name or slug"]'),
|
||||
).toBeNull()
|
||||
expect(document.body.textContent).toContain('No authors yet')
|
||||
expect(document.body.textContent).toContain(
|
||||
"Authors are the names readers see on articles. They don't need an account.",
|
||||
)
|
||||
const emptyButton = Array.from(document.body.querySelectorAll('button')).filter(
|
||||
(element) => textOf(element) === 'Add author',
|
||||
)
|
||||
expect(emptyButton.length > 0).toBe(canAdd)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Authors: no results', () => {
|
||||
it('names the search and clears it from the URL', async () => {
|
||||
const backend = await mountDirectory('authors', '/authors?q=zoe')
|
||||
|
||||
expect(document.body.textContent).toContain('No authors match "zoe"')
|
||||
expect(document.body.textContent).toContain(
|
||||
'Check the spelling, or search for a shorter part of the name.',
|
||||
)
|
||||
await click('Clear search')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.query.q).toBeUndefined()
|
||||
expect(listCalls(backend).at(-1)!.raw).toBe('/v1/author/?page=1&size=25')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Authors: search and pagination in the URL', () => {
|
||||
it('debounces the search, requests it and keeps it in the URL', async () => {
|
||||
vi.useFakeTimers()
|
||||
const backend = await mountDirectory('authors', '/authors')
|
||||
const input = document.body.querySelector<HTMLInputElement>(
|
||||
'input[placeholder="Search by name or slug"]',
|
||||
)!
|
||||
await typeInto(input, 'cam')
|
||||
|
||||
expect(listCalls(backend)).toHaveLength(1)
|
||||
await vi.advanceTimersByTimeAsync(SEARCH_DEBOUNCE_MS)
|
||||
await flushPromises()
|
||||
|
||||
expect(listCalls(backend).at(-1)!.raw).toBe('/v1/author/?q=cam&page=1&size=25')
|
||||
expect(router.currentRoute.value.query.q).toBe('cam')
|
||||
})
|
||||
|
||||
it('requests the page and size found in the URL', async () => {
|
||||
const backend = createBackend()
|
||||
backend.authors = manyAuthors(130)
|
||||
await mountDirectory('authors', '/authors?q=writer&page=2&size=50', { backend })
|
||||
|
||||
expect(listCalls(backend)[0]!.raw).toBe('/v1/author/?q=writer&page=2&size=50')
|
||||
expect(document.body.textContent).toContain('Showing 51–100 of 130')
|
||||
})
|
||||
|
||||
it('shows the paginator only when the total exceeds 25', async () => {
|
||||
await mountDirectory('authors', '/authors')
|
||||
expect(document.body.textContent).not.toContain('per page')
|
||||
await unmountDirectory()
|
||||
|
||||
const backend = createBackend()
|
||||
backend.authors = manyAuthors(40)
|
||||
await mountDirectory('authors', '/authors', { backend })
|
||||
|
||||
expect(document.body.textContent).toContain('Showing 1–25 of 40')
|
||||
})
|
||||
})
|
||||
|
||||
describe.each([['size=30'], ['page=abc'], ['page=0']])(
|
||||
'Authors: invalid value in the URL (%s)',
|
||||
(query) => {
|
||||
it('replaces the value by its default with no notice and never reaches the API', async () => {
|
||||
const backend = await mountDirectory('authors', `/authors?${query}`)
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.query.size).toBeUndefined()
|
||||
expect(router.currentRoute.value.query.page).toBeUndefined()
|
||||
expect(listCalls(backend).map((call) => call.raw)).toEqual([
|
||||
'/v1/author/?page=1&size=25',
|
||||
])
|
||||
expect(document.body.querySelector('[role="alert"]')).toBeNull()
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
describe('Authors: row menu for an owner or admin', () => {
|
||||
it('offers Edit, Link user and Delete… for a guest and Edit and Delete… for a linked author', async () => {
|
||||
await mountDirectory('authors', '/authors')
|
||||
|
||||
await openRowMenu('Camille Roche')
|
||||
expect(Array.from(document.body.querySelectorAll('[role="menuitem"]')).map(textOf)).toEqual(
|
||||
['Edit', 'Link user', 'Delete…'],
|
||||
)
|
||||
})
|
||||
|
||||
it('lists Edit and Delete… only for a linked author', async () => {
|
||||
await mountDirectory('authors', '/authors')
|
||||
await openRowMenu('Inès Duarte')
|
||||
|
||||
expect(Array.from(document.body.querySelectorAll('[role="menuitem"]')).map(textOf)).toEqual(
|
||||
['Edit', 'Delete…'],
|
||||
)
|
||||
})
|
||||
|
||||
it('opens a bottom sheet below md', async () => {
|
||||
await mountDirectory('authors', '/authors', { width: MOBILE_WIDTH })
|
||||
await openRowMenu('Camille Roche')
|
||||
|
||||
expect(textOf(dialog()!.querySelector('h2'))).toBe('Camille Roche')
|
||||
expect(dialog()!.querySelectorAll('[role="menuitem"]')).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Authors: read-only list for other roles', () => {
|
||||
it('has no Add author button, no pinned button, no row menu and a chevron on each row', async () => {
|
||||
await mountDirectory('authors', '/authors', { role: 'member', width: MOBILE_WIDTH })
|
||||
|
||||
expect(document.body.textContent).toContain(READ_ONLY_DESCRIPTION)
|
||||
expect(button('Add author')).toBeUndefined()
|
||||
expect(
|
||||
Array.from(document.body.querySelectorAll('button[aria-label^="Actions for"]')),
|
||||
).toHaveLength(0)
|
||||
expect(document.body.querySelectorAll('button[aria-label^="View "]')).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('keeps search working and shows the Authors sidebar entry on desktop', async () => {
|
||||
vi.useFakeTimers()
|
||||
const backend = await mountDirectory('authors', '/authors', { role: 'member' })
|
||||
|
||||
expect(
|
||||
document.body.querySelector('nav[aria-label="Main"] a[href="/authors"]'),
|
||||
).not.toBeNull()
|
||||
const input = document.body.querySelector<HTMLInputElement>(
|
||||
'input[placeholder="Search by name or slug"]',
|
||||
)!
|
||||
await typeInto(input, 'lea')
|
||||
await vi.advanceTimersByTimeAsync(SEARCH_DEBOUNCE_MS)
|
||||
await flushPromises()
|
||||
|
||||
expect(listCalls(backend).at(-1)!.raw).toBe('/v1/author/?q=lea&page=1&size=25')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Authors: read-only detail', () => {
|
||||
async function openDetail() {
|
||||
await mountDirectory('authors', '/authors', { role: 'member' })
|
||||
await click('View Inès Duarte')
|
||||
}
|
||||
|
||||
it('shows the author with Close as the only action and no email', async () => {
|
||||
await openDetail()
|
||||
|
||||
const text = textOf(dialog())
|
||||
for (const copy of [
|
||||
'Inès Duarte',
|
||||
'Linked',
|
||||
'9 articles',
|
||||
'Bio',
|
||||
'Website',
|
||||
'Only an owner or admin can change this author.',
|
||||
]) {
|
||||
expect(text).toContain(copy)
|
||||
}
|
||||
expect(text).not.toContain('ines.duarte@renew.example')
|
||||
expect(dialog()!.querySelectorAll('input, textarea')).toHaveLength(0)
|
||||
const actions = Array.from(dialog()!.querySelectorAll('button')).map(textOf)
|
||||
expect(actions.filter((label) => label !== '')).toEqual(['Close'])
|
||||
})
|
||||
|
||||
it('opens the same panel from /authors?edit=ines-duarte', async () => {
|
||||
await mountDirectory('authors', '/authors?edit=ines-duarte', { role: 'member' })
|
||||
|
||||
expect(textOf(dialog())).toContain('Only an owner or admin can change this author.')
|
||||
expect(dialog()!.querySelectorAll('input, textarea')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
void callsTo
|
||||
@@ -0,0 +1,170 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { initializeLocale, setLocale } from '@/i18n'
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
import type { Author } from '@/composables/useAuthors'
|
||||
import {
|
||||
authorsFixture,
|
||||
button,
|
||||
callsTo,
|
||||
chooseMenuItem,
|
||||
click,
|
||||
createBackend,
|
||||
dialog,
|
||||
failWith,
|
||||
field,
|
||||
labelled,
|
||||
mountDirectory,
|
||||
openRowMenu,
|
||||
textOf,
|
||||
typeInto,
|
||||
unmountDirectory,
|
||||
} from './directoryTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
afterEach(unmountDirectory)
|
||||
|
||||
function box() {
|
||||
return dialog()!
|
||||
}
|
||||
|
||||
function manyAuthors(count: number): Author[] {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
...authorsFixture()[1]!,
|
||||
id: 500 + index,
|
||||
name: `Writer ${String(index).padStart(3, '0')}`,
|
||||
slug: `writer-${index}`,
|
||||
article_count: index === 0 ? 3 : 1,
|
||||
}))
|
||||
}
|
||||
|
||||
describe('Author slug suggestions are never taken', () => {
|
||||
it('skips slugs that are already on the loaded page and ones the server just refused', async () => {
|
||||
const backend = createBackend()
|
||||
backend.authors.push({
|
||||
...authorsFixture()[1]!,
|
||||
id: 90,
|
||||
name: 'Other',
|
||||
slug: 'camille-roche-2',
|
||||
})
|
||||
backend.responders['POST /v1/author/'] = failWith(409, 'author_slug_taken')
|
||||
await mountDirectory('authors', '/authors', { backend })
|
||||
await click('Add author')
|
||||
await typeInto(field('e.g. Camille Roche'), 'Camille Roche')
|
||||
await click('Add author', box())
|
||||
|
||||
expect(textOf(box())).toContain('Use camille-roche-3')
|
||||
await click('Use camille-roche-3', box())
|
||||
await click('Add author', box())
|
||||
|
||||
expect(textOf(box())).toContain('Use camille-roche-4')
|
||||
expect((labelled('Slug') as HTMLInputElement).value).toBe('camille-roche-3')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Author reassign options', () => {
|
||||
it('walks every page of authors so nobody past the first hundred is missing', async () => {
|
||||
const backend = createBackend()
|
||||
backend.authors = manyAuthors(130)
|
||||
await mountDirectory('authors', '/authors', { backend })
|
||||
await openRowMenu('Writer 000')
|
||||
await chooseMenuItem('Delete…')
|
||||
|
||||
const paths = backend.calls
|
||||
.filter((call) => call.path === '/v1/author/')
|
||||
.map((call) => call.raw)
|
||||
expect(paths).toContain('/v1/author/?page=1&size=100')
|
||||
expect(paths).toContain('/v1/author/?page=2&size=100')
|
||||
const trigger = box().querySelector<HTMLElement>('button[aria-haspopup="listbox"]')!
|
||||
trigger.click()
|
||||
await flushPromises()
|
||||
const labels = Array.from(box().querySelectorAll('[role="option"]')).map(textOf)
|
||||
expect(labels.some((label) => label.includes('Writer 129'))).toBe(true)
|
||||
expect(labels.some((label) => label.includes('Writer 000'))).toBe(false)
|
||||
})
|
||||
|
||||
it('says so when the options cannot be loaded and still offers Nobody', async () => {
|
||||
const backend = createBackend()
|
||||
await mountDirectory('authors', '/authors', { backend })
|
||||
backend.responders['GET /v1/author/?page=1&size=100'] = failWith(500)
|
||||
await openRowMenu('Camille Roche')
|
||||
await chooseMenuItem('Delete…')
|
||||
|
||||
const alert = textOf(box().querySelector('[role="alert"]'))
|
||||
expect(alert).toContain("Couldn't load authors")
|
||||
expect(alert).toContain('Check your connection and try again.')
|
||||
expect(textOf(box().querySelector('button[aria-haspopup="listbox"]'))).toBe('Nobody')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Making another site the default from the form', () => {
|
||||
async function openProjects(backend = createBackend()) {
|
||||
await mountDirectory('sites', '/settings/sites', { backend })
|
||||
await openRowMenu('Projects')
|
||||
await chooseMenuItem('Edit')
|
||||
return backend
|
||||
}
|
||||
|
||||
it('keeps the list truthful when only the default step fails and retries just that step', async () => {
|
||||
const backend = createBackend()
|
||||
let defaultCalls = 0
|
||||
backend.responders['PATCH /v1/site/3/default'] = () => {
|
||||
defaultCalls += 1
|
||||
if (defaultCalls === 1) {
|
||||
throw new ApiError(500)
|
||||
}
|
||||
backend.sites.forEach((site) => (site.is_default = site.id === 3))
|
||||
return backend.sites.find((site) => site.id === 3)
|
||||
}
|
||||
await openProjects(backend)
|
||||
await typeInto(field('e.g. Journal'), 'Projects 2')
|
||||
box().querySelector<HTMLElement>('[role="switch"]')!.click()
|
||||
await flushPromises()
|
||||
await click('Save changes', box())
|
||||
|
||||
expect(textOf(box().querySelector('[role="alert"]'))).toContain("Couldn't save the site")
|
||||
expect(
|
||||
textOf(document.body.querySelectorAll('[data-testid="directory-row"]')[2]),
|
||||
).toContain('Projects 2')
|
||||
|
||||
await click('Save changes', box())
|
||||
|
||||
expect(callsTo(backend, 'PATCH', '/v1/site/3')).toHaveLength(1)
|
||||
expect(callsTo(backend, 'PATCH', '/v1/site/3/default')).toHaveLength(2)
|
||||
expect(document.body.querySelector('[role="dialog"]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Focus returns to the row menu button', () => {
|
||||
it('puts focus back on the ellipsis button after closing a panel opened from the menu', async () => {
|
||||
await mountDirectory('sites', '/settings/sites')
|
||||
await openRowMenu('Projects')
|
||||
await chooseMenuItem('Edit')
|
||||
await click('Cancel', box())
|
||||
await flushPromises()
|
||||
|
||||
expect(document.activeElement).toBe(button('Actions for Projects'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('Failure copy passes through the dialog in the active language', () => {
|
||||
beforeEach(() => setLocale('fr'))
|
||||
afterEach(() => initializeLocale())
|
||||
|
||||
it('shows the French notice when setting the default fails', async () => {
|
||||
const backend = createBackend()
|
||||
backend.responders['PATCH /v1/site/2/default'] = failWith(500)
|
||||
await mountDirectory('sites', '/settings/sites', { backend })
|
||||
await click('Actions pour Journal')
|
||||
await chooseMenuItem('Définir par défaut')
|
||||
await click('Définir par défaut', box())
|
||||
|
||||
expect(textOf(box().querySelector('[role="alert"]'))).toContain(
|
||||
'Impossible d’enregistrer le site.',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,105 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { initializeLocale, setLocale } from '@/i18n'
|
||||
import { messages } from '@/i18n/messages'
|
||||
import { chooseMenuItem, click, mountDirectory, textOf, unmountDirectory } from './directoryTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
const MIN_LENGTH = 14
|
||||
|
||||
const translatable = Object.entries(messages).filter(
|
||||
([english, french]) =>
|
||||
english !== french && !english.includes('{') && english.length >= MIN_LENGTH,
|
||||
)
|
||||
|
||||
function leftovers() {
|
||||
const shown = textOf(document.body)
|
||||
return translatable.filter(([english]) => shown.includes(english)).map(([english]) => english)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
setLocale('fr')
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await unmountDirectory()
|
||||
localStorage.clear()
|
||||
initializeLocale()
|
||||
})
|
||||
|
||||
describe('Language: every string of the sites screen has a French entry', () => {
|
||||
it('translates the list, the row menu and the add form', async () => {
|
||||
await mountDirectory('sites', '/settings/sites')
|
||||
|
||||
expect(document.body.textContent).toContain('Les frontends qui affichent votre contenu.')
|
||||
expect(leftovers()).toEqual([])
|
||||
|
||||
await click('Actions pour Journal')
|
||||
expect(document.body.textContent).toContain('Définir par défaut')
|
||||
expect(leftovers()).toEqual([])
|
||||
await chooseMenuItem('Définir par défaut')
|
||||
expect(leftovers()).toEqual([])
|
||||
})
|
||||
|
||||
it('translates the add form and the delete dialog', async () => {
|
||||
await mountDirectory('sites', '/settings/sites')
|
||||
await click('Ajouter un site')
|
||||
|
||||
expect(leftovers()).toEqual([])
|
||||
})
|
||||
|
||||
it('translates the delete dialog', async () => {
|
||||
await mountDirectory('sites', '/settings/sites')
|
||||
await click('Actions pour Journal')
|
||||
await chooseMenuItem('Supprimer…')
|
||||
|
||||
expect(document.body.textContent).toContain('Supprimer ce site ?')
|
||||
expect(leftovers()).toEqual([])
|
||||
})
|
||||
|
||||
it('translates the 403 page', async () => {
|
||||
await mountDirectory('sites', '/settings/sites', { role: 'admin' })
|
||||
|
||||
expect(document.body.textContent).toContain('Vous n’avez pas accès aux sites')
|
||||
expect(document.body.textContent).toContain('Retour aux paramètres')
|
||||
expect(leftovers()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Language: every string of the authors screen has a French entry', () => {
|
||||
it('translates the list, the add form and the delete dialog', async () => {
|
||||
await mountDirectory('authors', '/authors')
|
||||
|
||||
expect(document.body.textContent).toContain('Auteurs')
|
||||
expect(
|
||||
document.body.querySelector('input[placeholder="Rechercher par nom ou identifiant"]'),
|
||||
).not.toBeNull()
|
||||
expect(leftovers()).toEqual([])
|
||||
|
||||
await click('Ajouter un auteur')
|
||||
await flushPromises()
|
||||
expect(document.body.textContent).toContain('Aperçu de la signature')
|
||||
expect(leftovers()).toEqual([])
|
||||
})
|
||||
|
||||
it('translates the delete dialog', async () => {
|
||||
await mountDirectory('authors', '/authors')
|
||||
await click('Actions pour Camille Roche')
|
||||
await chooseMenuItem('Supprimer…')
|
||||
|
||||
expect(document.body.textContent).toContain('Supprimer cet auteur ?')
|
||||
expect(leftovers()).toEqual([])
|
||||
})
|
||||
|
||||
it('translates the read-only list', async () => {
|
||||
await mountDirectory('authors', '/authors', { role: 'member' })
|
||||
|
||||
expect(document.body.textContent).toContain('Seul un propriétaire ou un administrateur')
|
||||
expect(leftovers()).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, h } 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 { useSessionStore } from '@/stores/session'
|
||||
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
|
||||
import AuthorsView from '../AuthorsView.vue'
|
||||
import SitesView from '../SitesView.vue'
|
||||
|
||||
let wrapper: VueWrapper | null = null
|
||||
|
||||
function reply(status: number, body = '') {
|
||||
return new Response(body, { status, headers: { 'Content-Type': 'text/plain' } })
|
||||
}
|
||||
|
||||
async function mountWith(
|
||||
view: typeof AuthorsView | typeof SitesView,
|
||||
path: string,
|
||||
verified: boolean,
|
||||
) {
|
||||
setViewportWidth(DESKTOP_WIDTH)
|
||||
setActivePinia(createPinia())
|
||||
const session = useSessionStore()
|
||||
session.session = { verified, username: 'marta', id: 1, displayName: '', role: 'owner' }
|
||||
session.status = 'ready'
|
||||
await router.push(path)
|
||||
wrapper = mount(defineComponent({ render: () => h(UApp, null, { default: () => h(view) }) }), {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [router, uiPlugin] },
|
||||
})
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
vi.unstubAllGlobals()
|
||||
await router.push('/login')
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('Session problems use the app-wide handling', () => {
|
||||
it.each([
|
||||
['/authors', AuthorsView, '/v1/author/'],
|
||||
['/settings/sites', SitesView, '/v1/site/'],
|
||||
])('sends a 401 on %s to the login page with the expired reason', async (path, view, api) => {
|
||||
const calls: string[] = []
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (input: string) => {
|
||||
calls.push(input)
|
||||
return reply(401)
|
||||
}),
|
||||
)
|
||||
await mountWith(view, path, true)
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/login'))
|
||||
|
||||
expect(calls.some((call) => call.startsWith(api))).toBe(true)
|
||||
expect(router.currentRoute.value.path).toBe('/login')
|
||||
expect(router.currentRoute.value.query).toEqual({ redirect: path, reason: 'expired' })
|
||||
expect(document.body.querySelector('[role="alert"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('sends an unverified 403 on /authors to /register/pending', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => reply(403)),
|
||||
)
|
||||
await mountWith(AuthorsView, '/authors', false)
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/register/pending')
|
||||
})
|
||||
})
|
||||
@@ -1505,6 +1505,7 @@ describe('Sidebar', () => {
|
||||
'/home',
|
||||
'/article',
|
||||
'/media',
|
||||
'/authors',
|
||||
'/users',
|
||||
'/activity',
|
||||
'/integration',
|
||||
|
||||
@@ -279,11 +279,11 @@ describe('Default: the usual first visit with no display name', () => {
|
||||
expect(request).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('marks none of the seven navigation items as the current page', async () => {
|
||||
it('marks none of the eight navigation items as the current page', async () => {
|
||||
await mountApp()
|
||||
|
||||
const links = document.body.querySelectorAll('nav[aria-label="Main"] a')
|
||||
expect(links).toHaveLength(7)
|
||||
expect(links).toHaveLength(8)
|
||||
links.forEach((link) => expect(link.getAttribute('aria-current')).toBeNull())
|
||||
})
|
||||
|
||||
|
||||
@@ -30,10 +30,10 @@ function deferred<T>() {
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
async function mountPage(settings: () => Promise<unknown>) {
|
||||
async function mountPage(settings: () => Promise<unknown>, role?: 'owner' | 'admin') {
|
||||
setActivePinia(createPinia())
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true, username: 'marta', id: 1 }
|
||||
session.session = { verified: true, username: 'marta', id: 1, ...(role ? { role } : {}) }
|
||||
session.status = 'ready'
|
||||
requestMock = vi.fn((path: string) => {
|
||||
if (path === SETTINGS_PATH) {
|
||||
@@ -185,3 +185,35 @@ describe('A 401 or an unverified 403 is handled globally, not by the page', () =
|
||||
expect(tryAgainButton()).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Publishing card: the owner manages sites from Settings', () => {
|
||||
it('shows a Publishing card with a Manage sites row linking to /settings/sites', async () => {
|
||||
await mountPage(() => Promise.resolve({ version: 'v2.2.0-beta' }), 'owner')
|
||||
|
||||
const card = document.body.querySelector('[data-testid="publishing-links-card"]')!
|
||||
expect(card.textContent).toContain('Publishing')
|
||||
expect(card.textContent).toContain('Where your content is shown.')
|
||||
const row = card.querySelector('a')!
|
||||
expect(row.getAttribute('href')).toBe('/settings/sites')
|
||||
expect(row.textContent).toContain('Manage sites')
|
||||
expect(row.textContent).toContain('Base URLs, default image and robots policy')
|
||||
})
|
||||
|
||||
it('shows the sidebar Publishing group with a Sites entry linking to /settings/sites', async () => {
|
||||
await mountPage(() => Promise.resolve({ version: 'v2.2.0-beta' }), 'owner')
|
||||
|
||||
const nav = document.body.querySelector('nav[aria-label="Main"]')!
|
||||
expect(nav.textContent).toContain('Publishing')
|
||||
expect(nav.querySelector('a[href="/settings/sites"]')?.textContent).toContain('Sites')
|
||||
})
|
||||
|
||||
it('hides the Publishing card, the Manage sites row and the Sites entry from an admin', async () => {
|
||||
await mountPage(() => Promise.resolve({ version: 'v2.2.0-beta' }), 'admin')
|
||||
|
||||
expect(document.body.querySelector('[data-testid="publishing-links-card"]')).toBeNull()
|
||||
expect(document.body.textContent).not.toContain('Manage sites')
|
||||
const nav = document.body.querySelector('nav[aria-label="Main"]')!
|
||||
expect(nav.textContent).not.toContain('Publishing')
|
||||
expect(nav.querySelector('a[href="/settings/sites"]')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,173 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import {
|
||||
button,
|
||||
callsTo,
|
||||
chooseMenuItem,
|
||||
click,
|
||||
createBackend,
|
||||
dialog,
|
||||
failWith,
|
||||
mountDirectory,
|
||||
networkFailure,
|
||||
openRowMenu,
|
||||
pending,
|
||||
rowTexts,
|
||||
textOf,
|
||||
unmountDirectory,
|
||||
} from './directoryTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
afterEach(unmountDirectory)
|
||||
|
||||
async function openDelete(name: string, backend = createBackend()) {
|
||||
await mountDirectory('sites', '/settings/sites', { backend })
|
||||
await openRowMenu(name)
|
||||
await chooseMenuItem('Delete…')
|
||||
return backend
|
||||
}
|
||||
|
||||
function box() {
|
||||
return dialog()!
|
||||
}
|
||||
|
||||
function trigger() {
|
||||
return box().querySelector<HTMLElement>('button[aria-haspopup="listbox"]')!
|
||||
}
|
||||
|
||||
describe('Sites delete: site with articles', () => {
|
||||
it('explains, defaults the destination to the default site and shows the move summary', async () => {
|
||||
await openDelete('Journal')
|
||||
|
||||
const text = textOf(box())
|
||||
expect(box().getAttribute('role')).toBe('alertdialog')
|
||||
expect(text).toContain('Delete this site?')
|
||||
expect(text).toContain(
|
||||
'"Journal" will be deleted. 38 articles belong to it, so choose a site to move them to. This can\'t be undone.',
|
||||
)
|
||||
expect(text).toContain('Move articles to')
|
||||
expect(textOf(trigger())).toBe('Renew · renew.example')
|
||||
expect(text).toContain(
|
||||
'38 articles will move to Renew. Their canonical host changes to renew.example.',
|
||||
)
|
||||
expect(button('Cancel', box())).toBeTruthy()
|
||||
expect(button('Move 38 articles and delete', box())).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sites delete: choose destination', () => {
|
||||
it('does not offer the deleted site and shows the name and the host on each option', async () => {
|
||||
await openDelete('Journal')
|
||||
trigger().click()
|
||||
await flushPromises()
|
||||
|
||||
const options = Array.from(box().querySelectorAll('[role="option"]')).map((option) =>
|
||||
textOf(option),
|
||||
)
|
||||
expect(options).toEqual([
|
||||
'Renew renew.example',
|
||||
'Projects projects.example',
|
||||
'Archive archive.example',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sites delete: with articles succeeds', () => {
|
||||
it('sends one DELETE with reassign_to, reads Moving articles… and toasts', async () => {
|
||||
const backend = createBackend()
|
||||
const hold = pending<unknown>()
|
||||
backend.responders['DELETE /v1/site/2'] = () => hold.promise
|
||||
await openDelete('Journal', backend)
|
||||
await click('Move 38 articles and delete', box())
|
||||
|
||||
expect(button('Moving articles…', box())).toBeTruthy()
|
||||
hold.resolve(undefined)
|
||||
await flushPromises()
|
||||
|
||||
const deletes = callsTo(backend, 'DELETE', '/v1/site/2')
|
||||
expect(deletes).toHaveLength(1)
|
||||
expect(deletes[0]!.query.get('reassign_to')).toBe('1')
|
||||
expect(dialog()).toBeNull()
|
||||
expect(rowTexts().join(' ')).not.toContain('Journal')
|
||||
expect(document.body.textContent).toContain('Site deleted')
|
||||
expect(document.body.textContent).toContain('38 articles moved to Renew.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sites delete: no articles', () => {
|
||||
it('asks a plain confirmation with no select and sends no reassign_to', async () => {
|
||||
const backend = await openDelete('Archive')
|
||||
|
||||
const text = textOf(box())
|
||||
expect(text).toContain('Delete this site?')
|
||||
expect(text).toContain(
|
||||
'"Archive" will be deleted. No articles belong to it. This can\'t be undone.',
|
||||
)
|
||||
expect(trigger()).toBeNull()
|
||||
expect(button('Delete site', box())).toBeTruthy()
|
||||
|
||||
await click('Delete site', box())
|
||||
|
||||
const deletes = callsTo(backend, 'DELETE', '/v1/site/4')
|
||||
expect(deletes).toHaveLength(1)
|
||||
expect(deletes[0]!.query.has('reassign_to')).toBe(false)
|
||||
expect(rowTexts().join(' ')).not.toContain('Archive')
|
||||
})
|
||||
})
|
||||
|
||||
describe.each([
|
||||
['status 500', failWith(500)],
|
||||
['a network error', networkFailure],
|
||||
])('Sites delete: failure with %s', (_name, responder) => {
|
||||
it('keeps the dialog open with the notice and enables the buttons again', async () => {
|
||||
const backend = createBackend()
|
||||
backend.responders['DELETE /v1/site/2'] = responder
|
||||
await openDelete('Journal', backend)
|
||||
await click('Move 38 articles and delete', box())
|
||||
|
||||
const alert = textOf(box().querySelector('[role="alert"]'))
|
||||
expect(alert).toContain("Couldn't delete the site")
|
||||
expect(alert).toContain('Nothing was changed. Try again in a moment.')
|
||||
expect(button('Cancel', box())!.hasAttribute('disabled')).toBe(false)
|
||||
expect(button('Move 38 articles and delete', box())!.hasAttribute('disabled')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sites delete: server refusals', () => {
|
||||
it('keeps the dialog open and asks for a destination on 409 site_has_articles', async () => {
|
||||
const backend = createBackend()
|
||||
backend.responders['DELETE /v1/site/2'] = failWith(409, 'site_has_articles')
|
||||
await openDelete('Journal', backend)
|
||||
await click('Move 38 articles and delete', box())
|
||||
|
||||
expect(dialog()).not.toBeNull()
|
||||
expect(textOf(box())).toContain('Choose where the articles go. This site has 38 articles.')
|
||||
})
|
||||
|
||||
it('closes and toasts on 403 default_site', async () => {
|
||||
const backend = createBackend()
|
||||
backend.responders['DELETE /v1/site/2'] = failWith(403, 'default_site')
|
||||
await openDelete('Journal', backend)
|
||||
await click('Move 38 articles and delete', box())
|
||||
|
||||
expect(dialog()).toBeNull()
|
||||
expect(document.body.textContent).toContain(
|
||||
"The default site can't be deleted. Make another site the default first.",
|
||||
)
|
||||
})
|
||||
|
||||
it('closes, removes the row and toasts on 404', async () => {
|
||||
const backend = createBackend()
|
||||
backend.responders['DELETE /v1/site/2'] = failWith(404, 'site_not_found')
|
||||
await openDelete('Journal', backend)
|
||||
await click('Move 38 articles and delete', box())
|
||||
|
||||
expect(dialog()).toBeNull()
|
||||
expect(rowTexts().join(' ')).not.toContain('Journal')
|
||||
expect(document.body.textContent).toContain('That site no longer exists')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,417 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import {
|
||||
blur,
|
||||
button,
|
||||
callsTo,
|
||||
chooseMenuItem,
|
||||
click,
|
||||
createBackend,
|
||||
dialog,
|
||||
failWith,
|
||||
field,
|
||||
labelled,
|
||||
mountDirectory,
|
||||
networkFailure,
|
||||
openRowMenu,
|
||||
pending,
|
||||
rowTexts,
|
||||
textOf,
|
||||
typeInto,
|
||||
unmountDirectory,
|
||||
} from './directoryTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
const BASE_URL_MESSAGES = {
|
||||
scheme: 'Use https. For example https://blog.example.com.',
|
||||
path: 'Remove the path. Use only the host, like https://blog.example.com.',
|
||||
invalid: 'Enter a full URL, like https://blog.example.com.',
|
||||
}
|
||||
const SAVE_FAILED = "Couldn't save the site. Your changes are still here. Try again in a moment."
|
||||
|
||||
afterEach(unmountDirectory)
|
||||
|
||||
async function openAdd(options: Parameters<typeof mountDirectory>[2] = {}) {
|
||||
const backend = await mountDirectory('sites', '/settings/sites', options)
|
||||
await click('Add site')
|
||||
return backend
|
||||
}
|
||||
|
||||
async function openEdit(name: string) {
|
||||
const backend = await mountDirectory('sites', '/settings/sites')
|
||||
await openRowMenu(name)
|
||||
await chooseMenuItem('Edit')
|
||||
return backend
|
||||
}
|
||||
|
||||
async function fillValid(name = 'Journal 2', url = 'https://journal2.example') {
|
||||
await typeInto(field('e.g. Journal'), name)
|
||||
await typeInto(field('https://blog.example.com'), url)
|
||||
}
|
||||
|
||||
function box() {
|
||||
return dialog()!
|
||||
}
|
||||
|
||||
function submit(label = 'Add site') {
|
||||
return click(label, box())
|
||||
}
|
||||
|
||||
describe('Sites form: add, empty', () => {
|
||||
it('opens a slide-over with every field, placeholder and help', async () => {
|
||||
await openAdd()
|
||||
|
||||
const text = textOf(box())
|
||||
for (const copy of [
|
||||
'Add site',
|
||||
'Name',
|
||||
'Key',
|
||||
'Base URL',
|
||||
'The public address of the frontend. Use https and no path.',
|
||||
'Default social image (optional)',
|
||||
'Choose image',
|
||||
'Used when an article has no image of its own.',
|
||||
'Default robots policy',
|
||||
'Index',
|
||||
'Noindex',
|
||||
"Search engines may list this site's pages.",
|
||||
'Locale (optional)',
|
||||
'A language tag. Leave empty if the site has no single language.',
|
||||
'Make this the default site',
|
||||
'New articles start here. The current default is Renew.',
|
||||
'Canonical URL preview',
|
||||
'Cancel',
|
||||
]) {
|
||||
expect(text).toContain(copy)
|
||||
}
|
||||
expect(field('e.g. Journal')).toBeTruthy()
|
||||
expect(field('Generated from the name')).toBeTruthy()
|
||||
expect(field('https://blog.example.com')).toBeTruthy()
|
||||
expect(field('e.g. en or fr-CH')).toBeTruthy()
|
||||
expect(button('Add site', box())).toBeTruthy()
|
||||
})
|
||||
|
||||
it('focuses Name', async () => {
|
||||
await openAdd()
|
||||
await flushPromises()
|
||||
|
||||
expect(document.activeElement).toBe(field('e.g. Journal'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sites form: key from the name', () => {
|
||||
it('fills the key with an Auto badge and unlocks it with the pencil', async () => {
|
||||
await openAdd()
|
||||
await typeInto(field('e.g. Journal'), 'Journal Online')
|
||||
|
||||
const key = labelled('Key') as HTMLInputElement
|
||||
expect(key.value).toBe('journal-online')
|
||||
expect(textOf(box())).toContain('Auto')
|
||||
expect(textOf(box())).toContain(
|
||||
"Lowercase letters, numbers and hyphens. Used in API paths. It can't be changed after the site is added.",
|
||||
)
|
||||
|
||||
await click('Edit key', box())
|
||||
await typeInto(labelled('Key') as HTMLInputElement, 'custom-key')
|
||||
await typeInto(field('e.g. Journal'), 'Something else')
|
||||
|
||||
expect((labelled('Key') as HTMLInputElement).value).toBe('custom-key')
|
||||
expect(textOf(box())).not.toContain('Auto')
|
||||
})
|
||||
})
|
||||
|
||||
describe.each([
|
||||
['http://blog.example.com', BASE_URL_MESSAGES.scheme],
|
||||
['https://blog.example.com/en', BASE_URL_MESSAGES.path],
|
||||
['blog', BASE_URL_MESSAGES.invalid],
|
||||
])('Sites form: base URL "%s" is not valid', (value, message) => {
|
||||
it('shows the message on leaving the field and sends nothing', async () => {
|
||||
const backend = await openAdd()
|
||||
await fillValid('Journal 2', value)
|
||||
await blur(field('https://blog.example.com'))
|
||||
|
||||
expect(textOf(box())).toContain(message)
|
||||
await submit()
|
||||
expect(callsTo(backend, 'POST', '/v1/site/')).toHaveLength(0)
|
||||
expect(textOf(box())).toContain(message)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sites form: base URL refused by the server', () => {
|
||||
it('shows the generic URL message on 400 invalid_site_url', async () => {
|
||||
const backend = createBackend()
|
||||
backend.responders['POST /v1/site/'] = failWith(400, 'invalid_site_url')
|
||||
await openAdd({ backend })
|
||||
await fillValid()
|
||||
await submit()
|
||||
|
||||
expect(textOf(box())).toContain(BASE_URL_MESSAGES.invalid)
|
||||
})
|
||||
})
|
||||
|
||||
describe.each(['http://localhost:5173', 'http://127.0.0.1:8081', 'http://[::1]:5173'])(
|
||||
'Sites form: loopback host %s accepts http',
|
||||
(value) => {
|
||||
it('shows no base URL error', async () => {
|
||||
await openAdd()
|
||||
await fillValid('Journal 2', value)
|
||||
await blur(field('https://blog.example.com'))
|
||||
|
||||
const text = textOf(box())
|
||||
expect(text).not.toContain(BASE_URL_MESSAGES.scheme)
|
||||
expect(text).not.toContain(BASE_URL_MESSAGES.invalid)
|
||||
expect(text).not.toContain(BASE_URL_MESSAGES.path)
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
describe('Sites form: add succeeds', () => {
|
||||
it('sends exactly one POST, shows Saving… with disabled fields, then closes and lists the site', async () => {
|
||||
const backend = createBackend()
|
||||
const hold = pending<unknown>()
|
||||
backend.responders['POST /v1/site/'] = () => hold.promise
|
||||
await openAdd({ backend })
|
||||
await typeInto(field('e.g. Journal'), 'Notes')
|
||||
await typeInto(field('https://blog.example.com'), 'https://notes.example')
|
||||
box().querySelector<HTMLElement>('[role="radio"][value="noindex"]')!.click()
|
||||
await flushPromises()
|
||||
await submit()
|
||||
|
||||
expect(button('Saving…', box())).toBeTruthy()
|
||||
expect(field('e.g. Journal').disabled).toBe(true)
|
||||
hold.resolve({
|
||||
id: 5,
|
||||
name: 'Notes',
|
||||
key: 'notes',
|
||||
base_url: 'https://notes.example',
|
||||
default_social_image_id: null,
|
||||
default_robots: 'noindex',
|
||||
locale: null,
|
||||
is_default: false,
|
||||
article_count: 0,
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
const posts = callsTo(backend, 'POST', '/v1/site/')
|
||||
expect(posts).toHaveLength(1)
|
||||
expect(posts[0]!.body).toEqual({
|
||||
name: 'Notes',
|
||||
key: 'notes',
|
||||
base_url: 'https://notes.example',
|
||||
default_robots: 'noindex',
|
||||
is_default: false,
|
||||
})
|
||||
expect(dialog()).toBeNull()
|
||||
expect(rowTexts().at(-1)).toContain('Notes')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sites form: duplicate key', () => {
|
||||
it('flags the key on leaving the field and applies the suggestion', async () => {
|
||||
await openAdd()
|
||||
await typeInto(field('e.g. Journal'), 'Journal')
|
||||
await click('Edit key', box())
|
||||
const key = labelled('Key') as HTMLInputElement
|
||||
await blur(key)
|
||||
|
||||
expect(textOf(box())).toContain(
|
||||
'A site with the key "journal" already exists. Choose another key.',
|
||||
)
|
||||
await click('Use journal-2', box())
|
||||
|
||||
expect((labelled('Key') as HTMLInputElement).value).toBe('journal-2')
|
||||
expect(textOf(box())).not.toContain('already exists')
|
||||
})
|
||||
|
||||
it('flags the key when the server answers 409 site_key_taken', async () => {
|
||||
const backend = createBackend()
|
||||
backend.sites = backend.sites.filter((site) => site.key !== 'journal')
|
||||
backend.responders['POST /v1/site/'] = failWith(409, 'site_key_taken')
|
||||
await openAdd({ backend })
|
||||
await fillValid('Journal', 'https://journal.example')
|
||||
await submit()
|
||||
|
||||
expect(textOf(box())).toContain(
|
||||
'A site with the key "journal" already exists. Choose another key.',
|
||||
)
|
||||
expect(textOf(box())).toContain('Use journal-2')
|
||||
})
|
||||
})
|
||||
|
||||
describe.each([
|
||||
['status 500', failWith(500)],
|
||||
['a network error', networkFailure],
|
||||
])('Sites form: save failed with %s', (_name, responder) => {
|
||||
it('shows the notice, keeps the values and enables the form again', async () => {
|
||||
const backend = createBackend()
|
||||
backend.responders['POST /v1/site/'] = responder
|
||||
await openAdd({ backend })
|
||||
await fillValid()
|
||||
await submit()
|
||||
|
||||
expect(textOf(box().querySelector('[role="alert"]'))).toContain(SAVE_FAILED)
|
||||
expect(field('e.g. Journal').value).toBe('Journal 2')
|
||||
expect(field('https://blog.example.com').value).toBe('https://journal2.example')
|
||||
expect(field('e.g. Journal').disabled).toBe(false)
|
||||
expect(button('Add site', box())).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sites form: invalid locale from the server', () => {
|
||||
it('shows the locale message and keeps the panel open', async () => {
|
||||
const backend = createBackend()
|
||||
backend.responders['POST /v1/site/'] = failWith(400, 'invalid_locale')
|
||||
await openAdd({ backend })
|
||||
await fillValid()
|
||||
await typeInto(field('e.g. en or fr-CH'), 'english')
|
||||
await submit()
|
||||
|
||||
expect(textOf(box())).toContain('Use a language tag like en or fr-CH.')
|
||||
expect(dialog()).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sites form: edit the default site', () => {
|
||||
it('fills the fields, locks the key and keeps the default switch on and locked', async () => {
|
||||
await openEdit('Renew')
|
||||
|
||||
const text = textOf(box())
|
||||
expect(text).toContain('Edit Renew')
|
||||
expect(field('e.g. Journal').value).toBe('Renew')
|
||||
const key = labelled('Key') as HTMLInputElement
|
||||
expect(key.value).toBe('renew')
|
||||
expect(key.readOnly || key.disabled).toBe(true)
|
||||
expect(box().querySelector('[data-testid="slug-lock"]')).not.toBeNull()
|
||||
expect(text).toContain("Used in API paths, so it can't change.")
|
||||
expect(text).toContain('Default site')
|
||||
expect(text).toContain(
|
||||
'New articles start on the default site. To change it, make another site the default.',
|
||||
)
|
||||
const toggle = box().querySelector<HTMLButtonElement>('[role="switch"]')!
|
||||
expect(toggle.getAttribute('aria-checked')).toBe('true')
|
||||
expect(toggle.disabled || toggle.getAttribute('data-disabled') !== null).toBe(true)
|
||||
expect(button('Save changes', box())).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sites form: edit another site, noindex', () => {
|
||||
it('shows Noindex selected with its help and sends a PATCH without the key', async () => {
|
||||
const backend = await openEdit('Projects')
|
||||
|
||||
const text = textOf(box())
|
||||
expect(text).toContain('Edit Projects')
|
||||
expect(text).toContain("Asks search engines not to list this site's pages.")
|
||||
expect(text).toContain('Make this the default site')
|
||||
const noindex = box().querySelector('[role="radio"][value="noindex"]')!
|
||||
expect(noindex.getAttribute('aria-checked')).toBe('true')
|
||||
|
||||
await typeInto(field('e.g. Journal'), 'Projects 2')
|
||||
await submit('Save changes')
|
||||
|
||||
const patches = callsTo(backend, 'PATCH', '/v1/site/3')
|
||||
expect(patches).toHaveLength(1)
|
||||
expect(patches[0]!.body).toEqual({ name: 'Projects 2' })
|
||||
expect(patches[0]!.body).not.toHaveProperty('key')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sites form: make another site the default from the form', () => {
|
||||
it('sends the changed fields, then the default route, and never is_default in the first PATCH', async () => {
|
||||
const backend = await openEdit('Projects')
|
||||
await typeInto(field('e.g. Journal'), 'Projects 2')
|
||||
box().querySelector<HTMLElement>('[role="switch"]')!.click()
|
||||
await flushPromises()
|
||||
await submit('Save changes')
|
||||
|
||||
const order = backend.calls
|
||||
.filter((call) => call.method === 'PATCH')
|
||||
.map((call) => call.path)
|
||||
expect(order).toEqual(['/v1/site/3', '/v1/site/3/default'])
|
||||
expect(backend.calls.find((call) => call.path === '/v1/site/3')!.body).not.toHaveProperty(
|
||||
'is_default',
|
||||
)
|
||||
expect(
|
||||
textOf(document.body.querySelectorAll('[data-testid="directory-row"]')[2]),
|
||||
).toContain('Default')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sites form: discard changes', () => {
|
||||
it('asks before closing a changed form with Escape and keeps it on Keep editing', async () => {
|
||||
await openAdd()
|
||||
await typeInto(field('e.g. Journal'), 'Notes')
|
||||
box().dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||||
await flushPromises()
|
||||
|
||||
const ask = document.body.querySelector('[role="alertdialog"]')!
|
||||
expect(textOf(ask)).toContain('Discard changes?')
|
||||
await click('Keep editing', ask)
|
||||
|
||||
expect(field('e.g. Journal').value).toBe('Notes')
|
||||
})
|
||||
|
||||
it('discards on confirmation and closes an unchanged form with Cancel straight away', async () => {
|
||||
await openAdd()
|
||||
await typeInto(field('e.g. Journal'), 'Notes')
|
||||
await click('Cancel', box())
|
||||
await click('Discard changes', document.body.querySelector('[role="alertdialog"]')!)
|
||||
expect(document.body.querySelector('[role="dialog"]')).toBeNull()
|
||||
|
||||
await click('Add site')
|
||||
await click('Cancel', box())
|
||||
expect(document.body.querySelector('[role="alertdialog"]')).toBeNull()
|
||||
expect(document.body.querySelector('[role="dialog"]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sites form: deep link', () => {
|
||||
it('opens the edit slide-over for a known key', async () => {
|
||||
await mountDirectory('sites', '/settings/sites?edit=projects')
|
||||
|
||||
expect(textOf(box())).toContain('Edit Projects')
|
||||
})
|
||||
|
||||
it('shows an info toast and opens nothing for an unknown key', async () => {
|
||||
await mountDirectory('sites', '/settings/sites?edit=nope')
|
||||
|
||||
expect(dialog()).toBeNull()
|
||||
expect(document.body.textContent).toContain('That site no longer exists')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Slide-over accessibility', () => {
|
||||
it('moves focus into the panel and returns it to the opening control on close', async () => {
|
||||
await mountDirectory('sites', '/settings/sites')
|
||||
const opener = button('Add site')!
|
||||
opener.focus()
|
||||
opener.click()
|
||||
await flushPromises()
|
||||
await flushPromises()
|
||||
|
||||
expect(box().contains(document.activeElement)).toBe(true)
|
||||
await click('Cancel', box())
|
||||
await flushPromises()
|
||||
|
||||
expect(document.activeElement).toBe(opener)
|
||||
})
|
||||
|
||||
it('ignores Escape while a request is running', async () => {
|
||||
const backend = createBackend()
|
||||
backend.responders['POST /v1/site/'] = () => pending<unknown>().promise
|
||||
await openAdd({ backend })
|
||||
await fillValid()
|
||||
await submit()
|
||||
box().dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||||
await flushPromises()
|
||||
|
||||
expect(dialog()).not.toBeNull()
|
||||
expect(document.body.querySelector('[role="alertdialog"]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
void useApi
|
||||
@@ -0,0 +1,323 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import router from '@/router'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { DESKTOP_WIDTH, MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
||||
import {
|
||||
callsTo,
|
||||
chooseMenuItem,
|
||||
click,
|
||||
createBackend,
|
||||
dialog,
|
||||
failWith,
|
||||
mountDirectory,
|
||||
networkFailure,
|
||||
openRowMenu,
|
||||
pending,
|
||||
rowTexts,
|
||||
rows,
|
||||
textOf,
|
||||
unmountDirectory,
|
||||
button,
|
||||
} from './directoryTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
const SKELETON_DELAY_MS = 150
|
||||
const DEFAULT_SITE_TIP = "The default site can't be deleted. Make another site the default first."
|
||||
|
||||
afterEach(unmountDirectory)
|
||||
|
||||
describe('Sites: default (owner)', () => {
|
||||
it('shows the header, the columns, the rows, the counts and the badges', async () => {
|
||||
await mountDirectory('sites', '/settings/sites')
|
||||
|
||||
expect(document.body.querySelector('h1')?.textContent).toBe('Sites')
|
||||
expect(document.body.textContent).toContain('The frontends that display your content.')
|
||||
expect(button('Add site')).toBeTruthy()
|
||||
const header = document.body.querySelector(
|
||||
'[data-testid="directory-row"]',
|
||||
)!.previousElementSibling!
|
||||
expect(textOf(header)).toBe('Site Base URL Articles')
|
||||
expect(rowTexts()).toEqual([
|
||||
'Renew Default renew https://renew.example 268 articles',
|
||||
'Journal journal https://journal.example 38 articles',
|
||||
'Projects Noindex projects https://projects.example 6 articles',
|
||||
'Archive archive https://archive.example No articles',
|
||||
])
|
||||
})
|
||||
|
||||
it('draws cards below md with a full-width Add site button pinned to the bottom', async () => {
|
||||
await mountDirectory('sites', '/settings/sites', { width: MOBILE_WIDTH })
|
||||
|
||||
expect(document.body.querySelectorAll('[data-testid="directory-card"]')).toHaveLength(4)
|
||||
expect(document.body.querySelector('[data-testid="directory-row"]')).toBeNull()
|
||||
const pinned = button('Add site')!
|
||||
expect(pinned.className).toContain('fixed')
|
||||
expect(pinned.className).toContain('inset-x-4')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sites: only the default site', () => {
|
||||
it('lists the row with the hint and draws no empty-state wall', async () => {
|
||||
const backend = createBackend()
|
||||
backend.sites = backend.sites.slice(0, 1)
|
||||
await mountDirectory('sites', '/settings/sites', { backend })
|
||||
|
||||
expect(rows()).toHaveLength(1)
|
||||
expect(document.body.textContent).toContain(
|
||||
'Add another site if your content is shown on more than one frontend. Each article belongs to one site.',
|
||||
)
|
||||
expect(document.body.textContent).not.toContain('No sites yet')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sites: loading', () => {
|
||||
it('draws nothing before 150 ms, then skeleton rows with aria-busy', async () => {
|
||||
vi.useFakeTimers()
|
||||
const backend = createBackend()
|
||||
const hold = pending<unknown>()
|
||||
backend.responders['GET /v1/site/'] = () => hold.promise
|
||||
await mountDirectory('sites', '/settings/sites', { backend })
|
||||
|
||||
expect(document.body.querySelector('[aria-busy="true"]')).toBeNull()
|
||||
expect(
|
||||
document.body.querySelectorAll('[data-testid="directory-skeleton-row"]'),
|
||||
).toHaveLength(0)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(SKELETON_DELAY_MS)
|
||||
await flushPromises()
|
||||
|
||||
expect(document.body.querySelector('[aria-busy="true"]')).not.toBeNull()
|
||||
expect(
|
||||
document.body.querySelectorAll('[data-testid="directory-skeleton-row"]'),
|
||||
).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe.each([
|
||||
['status 500', failWith(500)],
|
||||
['a network error', networkFailure],
|
||||
])('Sites: error on load with %s', (_name, responder) => {
|
||||
it('shows the alert, no rows, and Try again requests the list again', async () => {
|
||||
const backend = createBackend()
|
||||
backend.responders['GET /v1/site/'] = responder
|
||||
await mountDirectory('sites', '/settings/sites', { backend })
|
||||
|
||||
const alert = document.body.querySelector('[role="alert"]')!
|
||||
expect(textOf(alert)).toContain("Couldn't load sites")
|
||||
expect(textOf(alert)).toContain('Check your connection and try again.')
|
||||
expect(rows()).toHaveLength(0)
|
||||
expect(textOf(alert)).not.toContain('500')
|
||||
|
||||
delete backend.responders['GET /v1/site/']
|
||||
await click('Try again')
|
||||
|
||||
expect(callsTo(backend, 'GET', '/v1/site/')).toHaveLength(2)
|
||||
expect(document.body.querySelector('[role="alert"]')).toBeNull()
|
||||
expect(rows()).toHaveLength(4)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sites: row menu', () => {
|
||||
it('offers Edit, Set as default and Delete… for a non-default site', async () => {
|
||||
await mountDirectory('sites', '/settings/sites')
|
||||
await openRowMenu('Journal')
|
||||
|
||||
const labels = Array.from(document.body.querySelectorAll('[role="menuitem"]')).map(textOf)
|
||||
expect(labels).toEqual(['Edit', 'Set as default', 'Delete…'])
|
||||
})
|
||||
|
||||
it('opens a bottom sheet titled with the site name below md', async () => {
|
||||
await mountDirectory('sites', '/settings/sites', { width: MOBILE_WIDTH })
|
||||
await openRowMenu('Journal')
|
||||
|
||||
const sheet = dialog()!
|
||||
expect(textOf(sheet.querySelector('h2'))).toBe('Journal')
|
||||
expect(sheet.querySelectorAll('[role="menuitem"]')).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sites: delete unavailable on the default site', () => {
|
||||
it('keeps Delete… focusable with aria-disabled and sends nothing when activated', async () => {
|
||||
const backend = await mountDirectory('sites', '/settings/sites')
|
||||
await openRowMenu('Renew')
|
||||
|
||||
const item = button(/^Delete…/)!
|
||||
expect(item.getAttribute('aria-disabled')).toBe('true')
|
||||
expect(item.hasAttribute('disabled')).toBe(false)
|
||||
expect(item.getAttribute('data-disabled')).toBeNull()
|
||||
expect(textOf(document.getElementById(item.getAttribute('aria-describedby')!))).toBe(
|
||||
DEFAULT_SITE_TIP,
|
||||
)
|
||||
item.focus()
|
||||
expect(document.activeElement).toBe(item)
|
||||
|
||||
item.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(dialog()?.getAttribute('role')).not.toBe('alertdialog')
|
||||
expect(backend.calls.filter((call) => call.method === 'DELETE')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('writes the explanation in the sheet below md', async () => {
|
||||
await mountDirectory('sites', '/settings/sites', { width: MOBILE_WIDTH })
|
||||
await openRowMenu('Renew')
|
||||
|
||||
expect(textOf(dialog())).toContain(DEFAULT_SITE_TIP)
|
||||
expect(button('Delete…')!.getAttribute('aria-disabled')).toBe('true')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sites: set as default', () => {
|
||||
async function openDialog(backend = createBackend()) {
|
||||
await mountDirectory('sites', '/settings/sites', { backend })
|
||||
await openRowMenu('Journal')
|
||||
await chooseMenuItem('Set as default')
|
||||
return backend
|
||||
}
|
||||
|
||||
it('asks, sends one PATCH without a body, shows Saving… and moves the badge with a toast', async () => {
|
||||
const backend = createBackend()
|
||||
const hold = pending<unknown>()
|
||||
backend.responders['PATCH /v1/site/2/default'] = () => hold.promise
|
||||
await openDialog(backend)
|
||||
|
||||
const box = dialog()!
|
||||
expect(textOf(box)).toContain('Make Journal the default site?')
|
||||
expect(textOf(box)).toContain(
|
||||
'New articles start on this site. Articles that are already on other sites stay where they are.',
|
||||
)
|
||||
expect(button('Cancel', box)).toBeTruthy()
|
||||
|
||||
await click('Make default', box)
|
||||
expect(button('Saving…', box)).toBeTruthy()
|
||||
hold.resolve({})
|
||||
await flushPromises()
|
||||
|
||||
const patches = callsTo(backend, 'PATCH', '/v1/site/2/default')
|
||||
expect(patches).toHaveLength(1)
|
||||
expect(patches[0]!.body).toBeUndefined()
|
||||
expect(textOf(rows()[1])).toContain('Default')
|
||||
expect(textOf(rows()[0])).not.toContain('Default')
|
||||
expect(document.body.textContent).toContain('Default site changed')
|
||||
expect(document.body.textContent).toContain('"Journal" is now the default site.')
|
||||
})
|
||||
|
||||
it('keeps the dialog open with a notice on a 500', async () => {
|
||||
const backend = createBackend()
|
||||
backend.responders['PATCH /v1/site/2/default'] = failWith(500)
|
||||
await openDialog(backend)
|
||||
await click('Make default', dialog()!)
|
||||
|
||||
expect(dialog()).not.toBeNull()
|
||||
expect(textOf(dialog()!.querySelector('[role="alert"]'))).toContain(
|
||||
"Couldn't save the site. Your changes are still here. Try again in a moment.",
|
||||
)
|
||||
})
|
||||
|
||||
it('closes, removes the row and toasts on a 404', async () => {
|
||||
const backend = createBackend()
|
||||
backend.responders['PATCH /v1/site/2/default'] = failWith(404, 'site_not_found')
|
||||
await openDialog(backend)
|
||||
await click('Make default', dialog()!)
|
||||
|
||||
expect(dialog()).toBeNull()
|
||||
expect(rowTexts().join(' ')).not.toContain('Journal')
|
||||
expect(document.body.textContent).toContain('That site no longer exists')
|
||||
expect(document.body.textContent).toContain(
|
||||
'It was deleted somewhere else, so we removed it from the list.',
|
||||
)
|
||||
})
|
||||
|
||||
it('closes and shows the owner-only toast on a 403', async () => {
|
||||
const backend = createBackend()
|
||||
backend.responders['PATCH /v1/site/2/default'] = failWith(
|
||||
403,
|
||||
'Only the owner can do this.',
|
||||
)
|
||||
await openDialog(backend)
|
||||
await click('Make default', dialog()!)
|
||||
|
||||
expect(dialog()).toBeNull()
|
||||
expect(document.body.textContent).toContain('Only the owner can manage sites')
|
||||
expect(document.body.textContent).toContain('Ask the owner of this site.')
|
||||
})
|
||||
})
|
||||
|
||||
describe.each([
|
||||
['admin', '/settings/sites'],
|
||||
['member', '/settings/sites'],
|
||||
['admin', '/settings/sites?edit=journal'],
|
||||
])('Sites: forbidden (403 page) for %s on %s', (role, path) => {
|
||||
it('explains instead of redirecting, opens no panel and Back to Settings goes to /settings', async () => {
|
||||
const backend = await mountDirectory('sites', path, { role })
|
||||
|
||||
const text = document.body.textContent ?? ''
|
||||
expect(text).toContain("You don't have access to Sites")
|
||||
expect(text).toContain(
|
||||
'Only the owner can manage sites. Ask the owner of this site if you need a change.',
|
||||
)
|
||||
expect(router.currentRoute.value.fullPath).toBe(path)
|
||||
expect(dialog()).toBeNull()
|
||||
expect(
|
||||
document.body.querySelector('nav[aria-label="Main"] a[href="/settings/sites"]'),
|
||||
).toBeNull()
|
||||
expect(callsTo(backend, 'GET', '/v1/site/')).toHaveLength(0)
|
||||
|
||||
const push = vi.spyOn(router, 'push').mockResolvedValue(undefined)
|
||||
await click('Back to Settings')
|
||||
|
||||
expect(push).toHaveBeenCalledWith('/settings')
|
||||
push.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sites: the API answers 403 where the SPA expected the owner', () => {
|
||||
it('shows the 403 page when the answer is for the page load', async () => {
|
||||
const backend = createBackend()
|
||||
backend.responders['GET /v1/site/'] = failWith(403, 'Only the owner can do this.')
|
||||
await mountDirectory('sites', '/settings/sites', { backend })
|
||||
|
||||
expect(document.body.textContent).toContain("You don't have access to Sites")
|
||||
expect(rows()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('shows an error toast when the answer is for an action', async () => {
|
||||
const backend = createBackend()
|
||||
backend.responders['DELETE /v1/site/4'] = failWith(403, 'Only the owner can do this.')
|
||||
await mountDirectory('sites', '/settings/sites', { backend })
|
||||
await openRowMenu('Archive')
|
||||
await chooseMenuItem('Delete…')
|
||||
await click('Delete site', dialog()!)
|
||||
|
||||
expect(document.body.textContent).toContain('Only the owner can manage sites')
|
||||
expect(document.body.textContent).toContain('Ask the owner of this site.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sites: the sidebar for the owner and for an admin', () => {
|
||||
it('shows a Publishing group with a Sites entry linking to /settings/sites for the owner', async () => {
|
||||
await mountDirectory('sites', '/settings/sites')
|
||||
|
||||
const nav = document.body.querySelector('nav[aria-label="Main"]')!
|
||||
expect(textOf(nav)).toContain('Publishing')
|
||||
const link = nav.querySelector<HTMLAnchorElement>('a[href="/settings/sites"]')!
|
||||
expect(textOf(link)).toBe('Sites')
|
||||
expect(link.getAttribute('aria-current')).toBe('page')
|
||||
})
|
||||
|
||||
it('has no Publishing group and no Sites entry for an admin', async () => {
|
||||
await mountDirectory('sites', '/settings/sites', { role: 'admin' })
|
||||
|
||||
const nav = document.body.querySelector('nav[aria-label="Main"]')!
|
||||
expect(textOf(nav)).not.toContain('Publishing')
|
||||
expect(nav.querySelector('a[href="/settings/sites"]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
void useApi
|
||||
void DESKTOP_WIDTH
|
||||
@@ -0,0 +1,445 @@
|
||||
import { vi } from 'vitest'
|
||||
import { defineComponent, h } 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 { ApiError, NetworkError, useApi, type MeAuthor } from '@/composables/useApi'
|
||||
import type { Author } from '@/composables/useAuthors'
|
||||
import type { Site } from '@/composables/useSites'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
|
||||
import AuthorsView from '../AuthorsView.vue'
|
||||
import SitesView from '../SitesView.vue'
|
||||
|
||||
export interface Call {
|
||||
method: string
|
||||
path: string
|
||||
raw: string
|
||||
query: URLSearchParams
|
||||
body: Record<string, unknown> | undefined
|
||||
}
|
||||
|
||||
export type Responder = (call: Call) => unknown | Promise<unknown>
|
||||
|
||||
export interface Backend {
|
||||
sites: Site[]
|
||||
authors: Author[]
|
||||
users: unknown[]
|
||||
calls: Call[]
|
||||
responders: Record<string, Responder>
|
||||
}
|
||||
|
||||
export function sitesFixture(): Site[] {
|
||||
const base = {
|
||||
default_social_image_id: null,
|
||||
default_robots: 'index' as const,
|
||||
locale: null,
|
||||
}
|
||||
return [
|
||||
{
|
||||
...base,
|
||||
id: 1,
|
||||
name: 'Renew',
|
||||
key: 'renew',
|
||||
base_url: 'https://renew.example',
|
||||
is_default: true,
|
||||
article_count: 268,
|
||||
},
|
||||
{
|
||||
...base,
|
||||
id: 2,
|
||||
name: 'Journal',
|
||||
key: 'journal',
|
||||
base_url: 'https://journal.example',
|
||||
is_default: false,
|
||||
article_count: 38,
|
||||
},
|
||||
{
|
||||
...base,
|
||||
id: 3,
|
||||
name: 'Projects',
|
||||
key: 'projects',
|
||||
base_url: 'https://projects.example',
|
||||
default_robots: 'noindex',
|
||||
is_default: false,
|
||||
article_count: 6,
|
||||
},
|
||||
{
|
||||
...base,
|
||||
id: 4,
|
||||
name: 'Archive',
|
||||
key: 'archive',
|
||||
base_url: 'https://archive.example',
|
||||
is_default: false,
|
||||
article_count: 0,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function author(overrides: Partial<Author> & Pick<Author, 'id' | 'name' | 'slug'>): Author {
|
||||
return {
|
||||
bio: '',
|
||||
avatar_id: null,
|
||||
website: null,
|
||||
social_links: [],
|
||||
user_id: null,
|
||||
is_linked: false,
|
||||
article_count: 0,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
export function authorsFixture(withEmails = true): Author[] {
|
||||
const list = [
|
||||
author({
|
||||
id: 2,
|
||||
name: 'Inès Duarte',
|
||||
slug: 'ines-duarte',
|
||||
bio: 'Site manager and restoration carpenter.',
|
||||
website: 'https://ines-duarte.example',
|
||||
social_links: [{ type: 'linkedin', url: 'https://www.linkedin.com/in/ines-duarte' }],
|
||||
user_id: 3,
|
||||
is_linked: true,
|
||||
article_count: 9,
|
||||
user_email: 'ines.duarte@renew.example',
|
||||
}),
|
||||
author({
|
||||
id: 7,
|
||||
name: 'Camille Roche',
|
||||
slug: 'camille-roche',
|
||||
bio: 'Freelance writer on lime and stone.',
|
||||
article_count: 3,
|
||||
}),
|
||||
author({
|
||||
id: 9,
|
||||
name: 'Dr. Alexandra Konstantinopoulou-Whitfield',
|
||||
slug: 'dr-alexandra-konstantinopoulou-whitfield',
|
||||
article_count: 1,
|
||||
}),
|
||||
author({ id: 10, name: 'Léa Fontaine', slug: 'lea-fontaine' }),
|
||||
]
|
||||
return withEmails ? list : list.map((item) => ({ ...item, user_email: undefined }))
|
||||
}
|
||||
|
||||
export function usersFixture() {
|
||||
const user = (id: number, username: string, display: string, email: string, role: string) => ({
|
||||
kind: 'user',
|
||||
id,
|
||||
username,
|
||||
display_name: display,
|
||||
email,
|
||||
role,
|
||||
status: 'accepted',
|
||||
created_at: '2026-03-12T09:00:00Z',
|
||||
})
|
||||
return [
|
||||
user(3, 'ines', 'Inès Duarte', 'ines.duarte@renew.example', 'admin'),
|
||||
user(1, 'marta', 'Marta Alves', 'marta@renew.example', 'owner'),
|
||||
user(4, 'rui', 'Rui Santos', 'rui@renew.example', 'admin'),
|
||||
]
|
||||
}
|
||||
|
||||
function fail(status: number, code?: string): never {
|
||||
throw new ApiError(status, undefined, code)
|
||||
}
|
||||
|
||||
function parseBody(options: RequestInit | undefined) {
|
||||
return typeof options?.body === 'string'
|
||||
? (JSON.parse(options.body) as Record<string, unknown>)
|
||||
: undefined
|
||||
}
|
||||
|
||||
function pageOf(backend: Backend, query: URLSearchParams) {
|
||||
const q = (query.get('q') ?? '').toLowerCase()
|
||||
const size = Number(query.get('size') ?? 25)
|
||||
const page = Number(query.get('page') ?? 1)
|
||||
const matching = backend.authors.filter((item) =>
|
||||
`${item.name} ${item.slug}`.toLowerCase().includes(q),
|
||||
)
|
||||
return {
|
||||
items: matching.slice((page - 1) * size, page * size),
|
||||
total: matching.length,
|
||||
page,
|
||||
size,
|
||||
}
|
||||
}
|
||||
|
||||
function defaultRoute(backend: Backend, call: Call): unknown {
|
||||
const { method, path, query, body } = call
|
||||
const siteMatch = /^\/v1\/site\/(\d+)(\/default)?$/.exec(path)
|
||||
const authorMatch = /^\/v1\/author\/(\d+)$/.exec(path)
|
||||
if (method === 'GET' && path === '/v1/site/') {
|
||||
return structuredClone(backend.sites)
|
||||
}
|
||||
if (method === 'POST' && path === '/v1/site/') {
|
||||
if (backend.sites.some((site) => site.key === body?.key)) {
|
||||
fail(409, 'site_key_taken')
|
||||
}
|
||||
const created = {
|
||||
id: 100,
|
||||
default_social_image_id: null,
|
||||
locale: null,
|
||||
article_count: 0,
|
||||
...body,
|
||||
} as unknown as Site
|
||||
backend.sites.push(created)
|
||||
return created
|
||||
}
|
||||
if (siteMatch && method === 'PATCH') {
|
||||
const site = backend.sites.find((candidate) => candidate.id === Number(siteMatch[1]))
|
||||
if (!site) {
|
||||
fail(404, 'site_not_found')
|
||||
}
|
||||
if (siteMatch[2]) {
|
||||
backend.sites.forEach((candidate) => (candidate.is_default = candidate === site))
|
||||
} else {
|
||||
Object.assign(site, body)
|
||||
}
|
||||
return structuredClone(site)
|
||||
}
|
||||
if (siteMatch && method === 'DELETE') {
|
||||
const id = Number(siteMatch[1])
|
||||
const site = backend.sites.find((candidate) => candidate.id === id)
|
||||
if (!site) {
|
||||
fail(404, 'site_not_found')
|
||||
}
|
||||
if (site.is_default) {
|
||||
fail(403, 'default_site')
|
||||
}
|
||||
const reassign = query.get('reassign_to')
|
||||
if (site.article_count > 0 && reassign === null) {
|
||||
fail(409, 'site_has_articles')
|
||||
}
|
||||
backend.sites = backend.sites.filter((candidate) => candidate.id !== id)
|
||||
return undefined
|
||||
}
|
||||
if (method === 'GET' && path === '/v1/author/') {
|
||||
return pageOf(backend, query)
|
||||
}
|
||||
if (method === 'POST' && path === '/v1/author/') {
|
||||
const created = { id: 200, is_linked: body?.user_id != null, article_count: 0, ...body }
|
||||
backend.authors.push(created as unknown as Author)
|
||||
return created
|
||||
}
|
||||
if (authorMatch && method === 'PATCH') {
|
||||
const target = backend.authors.find((candidate) => candidate.id === Number(authorMatch[1]))
|
||||
if (!target) {
|
||||
fail(404, 'author_not_found')
|
||||
}
|
||||
Object.assign(target, body)
|
||||
if (body && 'user_id' in body) {
|
||||
target.is_linked = body.user_id !== null
|
||||
}
|
||||
return structuredClone(target)
|
||||
}
|
||||
if (authorMatch && method === 'DELETE') {
|
||||
const id = Number(authorMatch[1])
|
||||
backend.authors = backend.authors.filter((candidate) => candidate.id !== id)
|
||||
return undefined
|
||||
}
|
||||
if (method === 'GET' && path === '/v1/user/') {
|
||||
return backend.users
|
||||
}
|
||||
if (method === 'GET' && path === '/v1/media/') {
|
||||
return {
|
||||
items: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
size: 25,
|
||||
counts: { all: 0, unused: 0, duplicates: 0 },
|
||||
}
|
||||
}
|
||||
throw new Error(`Unexpected request: ${method} ${path}`)
|
||||
}
|
||||
|
||||
export function createBackend(): Backend {
|
||||
return {
|
||||
sites: sitesFixture(),
|
||||
authors: authorsFixture(),
|
||||
users: usersFixture(),
|
||||
calls: [],
|
||||
responders: {},
|
||||
}
|
||||
}
|
||||
|
||||
export function callsTo(backend: Backend, method: string, path: string) {
|
||||
return backend.calls.filter((call) => call.method === method && call.path === path)
|
||||
}
|
||||
|
||||
export function failWith(status: number, code?: string): Responder {
|
||||
return () => fail(status, code)
|
||||
}
|
||||
|
||||
export const networkFailure: Responder = () => {
|
||||
throw new NetworkError()
|
||||
}
|
||||
|
||||
export function pending<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((done) => {
|
||||
resolve = done
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
export interface MountOptions {
|
||||
role?: string
|
||||
width?: number
|
||||
backend?: Backend
|
||||
author?: MeAuthor
|
||||
email?: string
|
||||
}
|
||||
|
||||
export const kit: { wrapper: VueWrapper | null; backend: Backend } = {
|
||||
wrapper: null,
|
||||
backend: createBackend(),
|
||||
}
|
||||
|
||||
export async function mountDirectory(
|
||||
view: 'sites' | 'authors',
|
||||
url: string,
|
||||
options: MountOptions = {},
|
||||
) {
|
||||
setViewportWidth(options.width ?? DESKTOP_WIDTH)
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
setActivePinia(createPinia())
|
||||
const backend = options.backend ?? createBackend()
|
||||
kit.backend = backend
|
||||
const session = useSessionStore()
|
||||
session.session = {
|
||||
verified: true,
|
||||
username: 'marta',
|
||||
id: 1,
|
||||
email: options.email ?? 'marta@renew.example',
|
||||
displayName: 'Marta Alves',
|
||||
role: (options.role ?? 'owner') as never,
|
||||
...(options.author ? { author: options.author } : {}),
|
||||
}
|
||||
session.status = 'ready'
|
||||
const request = vi.fn(async (path: string, init?: RequestInit) => {
|
||||
const [pathname = '', search = ''] = path.split('?')
|
||||
const call: Call = {
|
||||
method: init?.method ?? 'GET',
|
||||
path: pathname,
|
||||
raw: path,
|
||||
query: new URLSearchParams(search),
|
||||
body: parseBody(init),
|
||||
}
|
||||
backend.calls.push(call)
|
||||
const responder =
|
||||
backend.responders[`${call.method} ${path}`] ??
|
||||
backend.responders[`${call.method} ${pathname}`]
|
||||
return responder ? responder(call) : defaultRoute(backend, call)
|
||||
})
|
||||
vi.mocked(useApi).mockReturnValue({
|
||||
getMe: vi.fn().mockResolvedValue({ verified: true, username: 'marta', id: 1 }),
|
||||
request,
|
||||
} as unknown as ReturnType<typeof useApi>)
|
||||
await router.push(url)
|
||||
const component = view === 'sites' ? SitesView : AuthorsView
|
||||
kit.wrapper = mount(
|
||||
defineComponent({ render: () => h(UApp, null, { default: () => h(component) }) }),
|
||||
{ attachTo: document.body, global: { plugins: [router, uiPlugin] } },
|
||||
)
|
||||
await flushPromises()
|
||||
return backend
|
||||
}
|
||||
|
||||
export async function unmountDirectory() {
|
||||
kit.wrapper?.unmount()
|
||||
kit.wrapper = null
|
||||
vi.useRealTimers()
|
||||
await router.push('/login')
|
||||
document.body.innerHTML = ''
|
||||
}
|
||||
|
||||
export function textOf(element: Element | null | undefined) {
|
||||
if (!element) {
|
||||
return ''
|
||||
}
|
||||
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT)
|
||||
const parts: string[] = []
|
||||
while (walker.nextNode()) {
|
||||
const text = walker.currentNode.textContent?.replace(/\s+/g, ' ').trim()
|
||||
if (text) {
|
||||
parts.push(text)
|
||||
}
|
||||
}
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
export function button(label: string | RegExp, root: ParentNode = document.body) {
|
||||
return Array.from(root.querySelectorAll<HTMLElement>('button, [role="menuitem"], a')).find(
|
||||
(element) => {
|
||||
const text = element.getAttribute('aria-label') || textOf(element)
|
||||
return typeof label === 'string' ? text === label : label.test(text)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export async function click(label: string | RegExp, root: ParentNode = document.body) {
|
||||
const target = button(label, root)
|
||||
if (!target) {
|
||||
throw new Error(`No control named ${String(label)}`)
|
||||
}
|
||||
target.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
export function dialog() {
|
||||
return document.body.querySelector<HTMLElement>('[role="dialog"], [role="alertdialog"]')
|
||||
}
|
||||
|
||||
export function rows() {
|
||||
return Array.from(
|
||||
document.body.querySelectorAll<HTMLElement>(
|
||||
'[data-testid="directory-row"], [data-testid="directory-card"]',
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export function rowTexts() {
|
||||
return rows().map((row) => textOf(row))
|
||||
}
|
||||
|
||||
export async function openRowMenu(name: string) {
|
||||
await click(`Actions for ${name}`)
|
||||
}
|
||||
|
||||
export async function chooseMenuItem(label: string) {
|
||||
await click(label)
|
||||
}
|
||||
|
||||
export async function typeInto(element: HTMLInputElement | HTMLTextAreaElement, value: string) {
|
||||
element.value = value
|
||||
element.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
export async function blur(element: HTMLElement) {
|
||||
element.dispatchEvent(new FocusEvent('focusout', { bubbles: true }))
|
||||
element.dispatchEvent(new Event('blur'))
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
export function field(placeholder: string) {
|
||||
return document.body.querySelector<HTMLInputElement>(`[placeholder="${placeholder}"]`)!
|
||||
}
|
||||
|
||||
export function labelled(label: string) {
|
||||
const labelElement = Array.from(document.body.querySelectorAll('label')).find((element) =>
|
||||
textOf(element).startsWith(label),
|
||||
)
|
||||
const id = labelElement?.getAttribute('for')
|
||||
return id ? document.getElementById(id)! : null
|
||||
}
|
||||
|
||||
export function toasts() {
|
||||
return textOf(
|
||||
document.body.querySelector(
|
||||
'[data-slot="viewport"], [role="region"][aria-label*="otification"]',
|
||||
),
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user