feat(SPA): screens for Sites and Authors

This commit is contained in:
Florian Sylvain committed 2026-10-14 23:00:50 +02:00
1 parent b34a173d83
commit 3d81d2c717
82 files changed
+10676 -37

No files matched your search

+720
View File
@@ -4484,7 +4484,727 @@ function withDetailTabsException(states: Record<string, VisualState>): Record<st
}
}
function directorySession(role: string, displayName = 'Marta Alves'): ApiMock {
return {
status: 200,
body: { ...(verifiedSession.body as object), display_name: displayName, role },
}
}
const directorySites = [
{
id: 1,
name: 'Renew',
key: 'renew',
base_url: 'https://renew.example',
default_social_image_id: 11,
default_robots: 'index',
locale: 'en',
is_default: true,
article_count: 268,
},
{
id: 2,
name: 'Journal',
key: 'journal',
base_url: 'https://journal.renew.example',
default_social_image_id: null,
default_robots: 'index',
locale: null,
is_default: false,
article_count: 38,
},
{
id: 3,
name: 'Projects',
key: 'projects',
base_url: 'https://projects.renew.example',
default_social_image_id: 12,
default_robots: 'noindex',
locale: 'fr',
is_default: false,
article_count: 6,
},
{
id: 4,
name: 'Archive',
key: 'archive',
base_url: 'https://archive.renew.example',
default_social_image_id: null,
default_robots: 'noindex',
locale: null,
is_default: false,
article_count: 0,
},
]
function sitesApi(
overrides: Record<string, ApiMock> = {},
role = 'owner',
): Record<string, ApiMock> {
return {
'GET /v1/auth/me': directorySession(role),
'GET /v1/site/': { status: 200, body: directorySites },
...overrides,
}
}
const listboxTrigger = 'button[aria-haspopup="listbox"]'
function isPhone(page: Page) {
return (page.viewportSize()?.width ?? 0) < 768
}
async function openDirectoryMenu(page: Page, name: string) {
await page.getByRole('button', { name: `Actions for ${name}`, exact: true }).click()
await page.getByRole('menuitem').first().waitFor()
}
async function chooseDirectoryMenuItem(page: Page, label: string) {
await page.getByRole('menuitem', { name: label, exact: true }).click()
await page.mouse.move(0, 0)
}
async function openDirectoryAdd(page: Page, label: string) {
await page.getByRole('button', { name: label, exact: true }).first().click()
await page.getByRole('dialog').waitFor()
await page.mouse.move(0, 0)
}
async function openSiteEdit(page: Page, name: string) {
await openDirectoryMenu(page, name)
await chooseDirectoryMenuItem(page, 'Edit')
await page.getByRole('dialog').waitFor()
}
async function openSiteDelete(page: Page, name: string) {
await openDirectoryMenu(page, name)
await chooseDirectoryMenuItem(page, 'Delete…')
await page.getByRole('alertdialog').waitFor()
}
function siteFormField(page: Page, placeholder: string) {
return page.getByRole('dialog').getByPlaceholder(placeholder)
}
async function scrollPanelToTop(page: Page) {
await page.evaluate(() => {
document.querySelectorAll('[role="dialog"] [data-slot="body"]').forEach((body) => {
body.scrollTop = 0
})
})
await page.mouse.move(0, 0)
}
async function submitDirectoryPanel(page: Page, label: string) {
await page.getByRole('dialog').getByRole('button', { name: label, exact: true }).click()
await page.mouse.move(0, 0)
}
const sitesStates: Record<string, VisualState> = {
Default: { path: '/settings/sites', api: sitesApi() },
'Only the default site': {
path: '/settings/sites',
api: sitesApi({
'GET /v1/site/': {
status: 200,
body: [{ ...directorySites[0], article_count: 312 }],
},
}),
},
Loading: {
path: '/settings/sites',
api: sitesApi({ 'GET /v1/site/': { status: 200, hang: true } }),
},
Error: { path: '/settings/sites', api: sitesApi({ 'GET /v1/site/': { status: 500 } }) },
'Row menu': {
path: '/settings/sites',
api: sitesApi(),
steps: async (page) => {
await openDirectoryMenu(page, 'Journal')
await page.mouse.move(0, 0)
},
},
'Default site: delete unavailable': {
path: '/settings/sites',
api: sitesApi(),
steps: async (page) => {
await openDirectoryMenu(page, 'Renew')
if (!isPhone(page)) {
await page.getByRole('menuitem', { name: 'Delete…' }).hover()
await page.waitForTimeout(TOOLTIP_SETTLE_MS)
}
},
},
'Set as default: confirm': {
path: '/settings/sites',
api: sitesApi(),
steps: async (page) => {
await openDirectoryMenu(page, 'Journal')
await chooseDirectoryMenuItem(page, 'Set as default')
await page.getByRole('alertdialog').waitFor()
},
},
'Hidden entry (admin)': { path: '/settings', skip: 'clock-sensitive' },
'Forbidden (403)': {
path: '/settings/sites',
api: sitesApi({}, 'admin'),
},
'Add: empty': {
path: '/settings/sites',
api: sitesApi(),
steps: (page) => openDirectoryAdd(page, 'Add site'),
},
'Add: key from the name': {
path: '/settings/sites',
api: sitesApi(),
steps: async (page) => {
await openDirectoryAdd(page, 'Add site')
await siteFormField(page, 'e.g. Journal').fill('Journal')
await siteFormField(page, 'https://blog.example.com').focus()
},
},
'Add: URL not valid': {
path: '/settings/sites',
api: sitesApi(),
steps: async (page) => {
await openDirectoryAdd(page, 'Add site')
await siteFormField(page, 'e.g. Journal').fill('Journal')
await siteFormField(page, 'https://blog.example.com').fill(
'http://journal.renew.example',
)
await siteFormField(page, 'https://blog.example.com').blur()
await page.getByRole('dialog').getByText('Use https. For example').waitFor()
await page.mouse.move(0, 0)
},
},
'Add: duplicate key': {
path: '/settings/sites',
api: sitesApi(),
steps: async (page) => {
await openDirectoryAdd(page, 'Add site')
await siteFormField(page, 'e.g. Journal').fill('Journal')
await page.getByRole('button', { name: 'Edit key', exact: true }).click()
await page.getByRole('dialog').getByLabel('Key', { exact: true }).focus()
await siteFormField(page, 'https://blog.example.com').fill(
'https://journal2.renew.example',
)
await page.getByRole('dialog').getByText('already exists').waitFor()
await page.mouse.move(0, 0)
},
},
'Add: save failed': {
path: '/settings/sites',
api: sitesApi({ 'POST /v1/site/': { status: 500 } }),
steps: async (page) => {
await openDirectoryAdd(page, 'Add site')
await siteFormField(page, 'e.g. Journal').fill('Journal')
await siteFormField(page, 'https://blog.example.com').fill(
'https://journal.renew.example',
)
await submitDirectoryPanel(page, 'Add site')
await page.getByRole('dialog').getByText("Couldn't save the site").waitFor()
},
},
'Edit: default site': {
path: '/settings/sites',
api: sitesApi(),
steps: async (page) => {
await openSiteEdit(page, 'Renew')
await page.mouse.move(0, 0)
},
},
'Edit: other site, noindex': {
path: '/settings/sites',
api: sitesApi(),
steps: async (page) => {
await openSiteEdit(page, 'Projects')
await page.mouse.move(0, 0)
},
},
'With articles': {
path: '/settings/sites',
api: sitesApi(),
steps: (page) => openSiteDelete(page, 'Journal'),
},
'Choose destination': {
path: '/settings/sites',
api: sitesApi(),
steps: async (page) => {
await openSiteDelete(page, 'Journal')
await page.getByRole('alertdialog').locator(listboxTrigger).click()
await page.getByRole('option').first().waitFor()
},
},
'No articles': {
path: '/settings/sites',
api: sitesApi(),
steps: (page) => openSiteDelete(page, 'Archive'),
},
}
const directoryLinks = {
camille: [
{ type: 'mastodon', url: 'https://mastodon.social/@camille' },
{ type: 'linkedin', url: 'linkedin.com/in/camille' },
],
ines: [{ type: 'linkedin', url: 'https://www.linkedin.com/in/ines-duarte' }],
}
interface DirectoryAuthor {
id: number
name: string
slug: string
bio: string
avatar_id: number | null
website: string | null
social_links: { type: string; url: string }[]
user_id: number | null
is_linked: boolean
article_count: number
user_email?: string
}
function directoryAuthors(
overrides: Record<string, Partial<DirectoryAuthor>> = {},
owner = true,
): DirectoryAuthor[] {
const base: DirectoryAuthor[] = [
{
id: 1,
name: 'Inès Duarte',
slug: 'ines-duarte',
bio: 'Site manager and restoration carpenter. Writes about timber and joinery.',
avatar_id: 12,
website: 'https://ines-duarte.example',
social_links: directoryLinks.ines,
user_id: 3,
is_linked: true,
article_count: 9,
user_email: 'ines.duarte@renew.example',
},
{
id: 2,
name: 'Jonas Pereira',
slug: 'jonas-pereira',
bio: 'Heat pumps, insulation and the numbers behind them.',
avatar_id: 13,
website: null,
social_links: [],
user_id: 2,
is_linked: true,
article_count: 6,
user_email: 'jonas@renew.example',
},
{
id: 3,
name: 'Camille Roche',
slug: 'camille-roche',
bio: 'Freelance writer on lime and stone.',
avatar_id: null,
website: null,
social_links: [],
user_id: null,
is_linked: false,
article_count: 3,
},
{
id: 4,
name: 'Paul Huber',
slug: 'paul-huber',
bio: '',
avatar_id: 14,
website: null,
social_links: [],
user_id: null,
is_linked: false,
article_count: 2,
},
{
id: 5,
name: 'Léa Fontaine',
slug: 'lea-fontaine',
bio: '',
avatar_id: null,
website: null,
social_links: [],
user_id: null,
is_linked: false,
article_count: 0,
},
{
id: 6,
name: 'Dr. Alexandra Konstantinopoulou-Whitfield',
slug: 'dr-alexandra-konstantinopoulou-whitfield',
bio: 'Conservation architect.',
avatar_id: null,
website: null,
social_links: [],
user_id: null,
is_linked: false,
article_count: 1,
},
]
return base.map((author) => {
const merged = { ...author, ...(overrides[author.slug] ?? {}) }
if (!owner) {
delete merged.user_email
}
return merged
})
}
function authorListMock(authors: DirectoryAuthor[]): ApiMock {
return {
status: 200,
bodyFor: (url) => {
const q = (url.searchParams.get('q') ?? '').toLowerCase()
const matching = authors.filter((author) =>
`${author.name} ${author.slug}`.toLowerCase().includes(q),
)
const size = Number(url.searchParams.get('size') ?? 25)
const page = Number(url.searchParams.get('page') ?? 1)
return {
items: matching.slice((page - 1) * size, page * size),
total: matching.length,
page,
size,
}
},
}
}
const directoryUsers = [
{
kind: 'user',
id: 3,
username: 'ines',
display_name: 'Inès Duarte',
email: 'ines.duarte@renew.example',
role: 'admin',
status: 'accepted',
created_at: '2026-06-18T09:00:00Z',
},
{
kind: 'user',
id: 2,
username: 'jonas',
display_name: 'Jonas Pereira',
email: 'jonas@renew.example',
role: 'admin',
status: 'accepted',
created_at: '2026-04-02T09:00:00Z',
},
{
kind: 'user',
id: 1,
username: 'marta',
display_name: 'Marta Alves',
email: 'marta@renew.example',
role: 'owner',
status: 'accepted',
created_at: '2026-03-12T09:00:00Z',
},
{
kind: 'user',
id: 4,
username: 'rui',
display_name: 'Rui Santos',
email: 'rui@renew.example',
role: 'admin',
status: 'accepted',
created_at: '2026-05-02T09:00:00Z',
},
]
function authorsApi(
overrides: Record<string, ApiMock> = {},
options: { role?: string; authors?: DirectoryAuthor[] } = {},
): Record<string, ApiMock> {
const role = options.role ?? 'owner'
return {
'GET /v1/auth/me': directorySession(role),
'GET /v1/author/': authorListMock(options.authors ?? directoryAuthors({}, role !== 'user')),
'GET /v1/user/': { status: 200, body: directoryUsers },
...overrides,
}
}
async function openAuthorEdit(page: Page, name: string) {
await openDirectoryMenu(page, name)
await chooseDirectoryMenuItem(page, 'Edit')
await page.getByRole('dialog').waitFor()
await page.mouse.move(0, 0)
}
async function openAuthorDelete(page: Page, name: string) {
await openDirectoryMenu(page, name)
await chooseDirectoryMenuItem(page, 'Delete…')
await page.getByRole('alertdialog').waitFor()
await page.mouse.move(0, 0)
}
const avatarPickerNames = [
'north-wall.jpg',
'kitchen-after.webp',
'kitchen-before.webp',
'logo-renew.svg',
'window-frame.jpg',
'lime-plaster.jpg',
'floorboards.jpg',
'bathroom-tiles.png',
]
const avatarPickerTones = ['0', '1', '3']
function avatarPickerList() {
const library = mediaItems()
const items = avatarPickerNames
.flatMap((name) => library.filter((item) => item.filename === name))
.map((item, index) => ({
...item,
content_type: 'image/jpeg',
sha256: `${avatarPickerTones[index % avatarPickerTones.length]}${item.sha256.slice(1)}`,
}))
return mediaList(items, pickerCounts)
}
const authorsStates: Record<string, VisualState> = {
Default: { path: '/authors', api: authorsApi() },
Loading: {
path: '/authors',
api: authorsApi({ 'GET /v1/author/': { status: 200, hang: true } }),
},
Error: { path: '/authors', api: authorsApi({ 'GET /v1/author/': { status: 500 } }) },
'Empty, first run': {
path: '/authors',
api: authorsApi({}, { authors: [] }),
},
'No results': { path: '/authors?q=zoe', api: authorsApi() },
'Row menu': {
path: '/authors',
api: authorsApi(),
steps: async (page) => {
await openDirectoryMenu(page, 'Camille Roche')
await page.mouse.move(0, 0)
},
},
'Read-only list (other roles)': {
path: '/authors',
api: authorsApi({}, { role: 'user' }),
},
'Read-only detail': {
path: '/authors',
api: authorsApi({}, { role: 'user' }),
steps: async (page) => {
await page.getByRole('button', { name: 'View Inès Duarte', exact: true }).click()
await page.getByRole('dialog').waitFor()
await page.mouse.move(0, 0)
},
},
'Add: empty': {
path: '/authors',
api: authorsApi(),
steps: (page) => openDirectoryAdd(page, 'Add author'),
},
'Slug from the name': {
path: '/authors',
api: authorsApi(),
steps: async (page) => {
await openDirectoryAdd(page, 'Add author')
await page
.getByRole('dialog')
.getByPlaceholder('e.g. Camille Roche')
.fill('Camille Roche')
await page
.getByRole('dialog')
.getByPlaceholder('A sentence or two about the author.')
.fill('Freelance writer on lime and stone.')
},
},
'Slug collision': {
path: '/authors',
api: authorsApi({ 'POST /v1/author/': { status: 409, body: 'author_slug_taken' } }),
steps: async (page) => {
await openDirectoryAdd(page, 'Add author')
await page
.getByRole('dialog')
.getByPlaceholder('e.g. Camille Roche')
.fill('Camille Roche')
await page.getByRole('button', { name: 'Edit slug', exact: true }).click()
await submitDirectoryPanel(page, 'Add author')
await page.getByRole('dialog').getByText('already used').waitFor()
},
},
Validation: {
path: '/authors',
api: authorsApi(),
steps: async (page) => {
await openDirectoryAdd(page, 'Add author')
await page
.getByRole('dialog')
.getByPlaceholder('A sentence or two about the author.')
.fill('x'.repeat(291))
await page.getByRole('dialog').getByPlaceholder('https://example.com').fill('example')
await submitDirectoryPanel(page, 'Add author')
await page.getByRole('dialog').getByText('Enter a name.').waitFor()
await scrollPanelToTop(page)
},
},
'Social links': {
path: '/authors',
api: authorsApi(
{},
{
authors: directoryAuthors({
'camille-roche': { social_links: directoryLinks.camille },
}),
},
),
steps: async (page) => {
await openAuthorEdit(page, 'Camille Roche')
await page.getByRole('button', { name: 'Add link', exact: true }).click()
await page.getByRole('dialog').getByLabel('Link URL 3').focus()
await scrollPanelToTop(page)
},
},
'Linked user: choose': {
path: '/authors',
api: authorsApi({}, { authors: directoryAuthors({ 'camille-roche': { bio: '' } }) }),
steps: async (page) => {
await openAuthorEdit(page, 'Camille Roche')
await page.getByRole('dialog').locator(listboxTrigger).click()
await page.getByRole('option').first().waitFor()
await scrollPanelToTop(page)
},
},
'Edit: linked author': {
path: '/authors',
api: authorsApi(),
steps: (page) => openAuthorEdit(page, 'Inès Duarte'),
},
'Avatar: picker': {
path: '/authors',
api: authorsApi(
{
'GET /v1/media/': { status: 200, bodyFor: avatarPickerList },
'GET /v1/image-variants': { status: 403 },
},
{ authors: directoryAuthors({ 'camille-roche': { bio: '' } }) },
),
steps: async (page) => {
await openAuthorEdit(page, 'Camille Roche')
await page.getByRole('button', { name: 'Choose image', exact: true }).click()
await waitForPickerTiles(page)
await page.locator('[data-picker-tile][aria-label^="kitchen-after.webp,"]').click()
await scrollPanelToTop(page)
await page.mouse.move(0, 0)
},
},
'Save failed': {
path: '/authors',
api: authorsApi(
{ 'PATCH /v1/author/3': { status: 500 } },
{ authors: directoryAuthors({ 'camille-roche': { bio: '' } }) },
),
steps: async (page) => {
await openAuthorEdit(page, 'Camille Roche')
await page
.getByRole('dialog')
.getByPlaceholder('A sentence or two about the author.')
.fill('Freelance writer on lime and stone.')
await submitDirectoryPanel(page, 'Save changes')
await page.getByRole('dialog').getByText("Couldn't save the author").waitFor()
},
},
'Unlink: confirm': {
path: '/authors',
api: authorsApi(
{},
{
authors: directoryAuthors({
'ines-duarte': { social_links: [], bio: '', website: null },
}),
},
),
steps: async (page) => {
await openAuthorEdit(page, 'Inès Duarte')
await page.getByRole('button', { name: 'Unlink', exact: true }).click()
await page.getByRole('alertdialog').waitFor()
await scrollPanelToTop(page)
},
},
'Re-link': {
path: '/authors',
api: authorsApi(),
steps: async (page) => {
await openDirectoryMenu(page, 'Camille Roche')
await chooseDirectoryMenuItem(page, 'Link user')
await page.getByRole('alertdialog').waitFor()
await page.getByRole('alertdialog').locator(listboxTrigger).click()
await page.getByRole('option').first().waitFor()
},
},
'Reassign (recommended)': {
path: '/authors',
api: authorsApi(),
steps: (page) => openAuthorDelete(page, 'Camille Roche'),
},
'Choose who': {
path: '/authors',
api: authorsApi(),
steps: async (page) => {
await openAuthorDelete(page, 'Camille Roche')
await page.getByRole('alertdialog').locator(listboxTrigger).click()
await page.getByRole('option').first().waitFor()
},
},
Nobody: {
path: '/authors',
api: authorsApi(),
steps: async (page) => {
await openAuthorDelete(page, 'Camille Roche')
await page.getByRole('alertdialog').locator(listboxTrigger).click()
await page.getByRole('option', { name: /Nobody/ }).click()
await page.mouse.move(0, 0)
},
},
'Failed (shared by site and author delete)': {
path: '/authors',
api: authorsApi({ 'DELETE /v1/author/3': { status: 500 } }),
steps: async (page) => {
await openAuthorDelete(page, 'Camille Roche')
await page
.getByRole('alertdialog')
.getByRole('button', { name: 'Delete and reassign 3 articles', exact: true })
.click()
await page.getByRole('alertdialog').getByText("Couldn't delete the author").waitFor()
await page.mouse.move(0, 0)
},
},
'Server fallback (409)': {
path: '/authors',
api: authorsApi({ 'DELETE /v1/author/3': { status: 409, body: 'author_has_articles' } }),
steps: async (page) => {
await openAuthorDelete(page, 'Camille Roche')
await page
.getByRole('alertdialog')
.getByRole('button', { name: 'Delete and reassign 3 articles', exact: true })
.click()
await page.getByRole('alertdialog').getByText("Can't delete this author").waitFor()
await page.mouse.move(0, 0)
},
},
'No articles': {
path: '/authors',
api: authorsApi(),
steps: (page) => openAuthorDelete(page, 'Léa Fontaine'),
},
}
export const visualStates: Record<string, Record<string, VisualState>> = {
sites: sitesStates,
authors: authorsStates,
activity: activityStates,
profile: {
'Account menu': {