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