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
@@ -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
|
||||
Reference in new issue
Block a user