feat: sites and authors integration into existing screens (& formated all frontend files kinda accidentally)

This commit is contained in:
Florian Sylvain committed 2026-10-15 05:25:44 +02:00
1 parent 3d81d2c717
commit 6b8990c55e
239 files changed
+9194 -1354

No files matched your search

+341
View File
@@ -325,6 +325,8 @@ const editorArticle = {
{ id: 3, url: '/images/3.png' },
],
is_online: false,
site_id: 1,
authors: [{ id: 1, name: 'Inès Duarte', slug: 'ines-duarte' }],
created_at: '2026-03-12T09:14:00Z',
updated_at: editorNow,
}
@@ -335,15 +337,155 @@ const emptyLibrary = {
counts: { all: 0, unused: 0, duplicates: 0 },
}
function boardSiteList(count = 1): ApiMock {
return { status: 200, bodyFor: () => directorySites.slice(0, count) }
}
function boardAuthorList(): ApiMock {
return {
status: 200,
bodyFor: (url, call) => authorListMock(directoryAuthors()).bodyFor!(url, call),
}
}
const boardBylineTable: Record<string, { id: number; slug: string }> = {
'Inès Duarte': { id: 1, slug: 'ines-duarte' },
'Jonas Pereira': { id: 2, slug: 'jonas-pereira' },
'Camille Roche': { id: 3, slug: 'camille-roche' },
'Paul Huber': { id: 4, slug: 'paul-huber' },
'Léa Fontaine': { id: 5, slug: 'lea-fontaine' },
'Dr. Alexandra Konstantinopoulou-Whitfield': {
id: 6,
slug: 'dr-alexandra-konstantinopoulou-whitfield',
},
}
function boardBylines(...names: string[]) {
return names.map((name) => ({ name, ...boardBylineTable[name]! }))
}
function editorApi(overrides: Record<string, ApiMock> = {}): Record<string, ApiMock> {
return {
'GET /v1/auth/me': verifiedSession,
'GET /v1/article/12': { status: 200, body: editorArticle },
'GET /v1/media/': { status: 200, body: emptyLibrary },
'GET /v1/site/': boardSiteList(),
'GET /v1/author/': boardAuthorList(),
...overrides,
}
}
const metaToggle = '[data-testid="meta-toggle"]:visible'
const metaBody = '[data-testid="meta-body"]:visible'
const metaTrigger = `${metaBody} button[aria-haspopup="listbox"]`
const marta = { id: 7, name: 'Marta Alves', slug: 'marta-alves' }
function metaSession(role = 'owner', withAuthor = false): ApiMock {
return {
status: 200,
body: {
...(verifiedSession.body as object),
display_name: 'Marta Alves',
role,
...(withAuthor ? { author: marta } : {}),
},
}
}
interface MetaOptions {
authors?: string[]
siteId?: number
sites?: number
role?: string
withAuthor?: boolean
overrides?: Record<string, ApiMock>
}
function metaApi(options: MetaOptions = {}) {
return editorApi({
'GET /v1/auth/me': metaSession(options.role, options.withAuthor),
'GET /v1/article/12': {
status: 200,
body: {
...editorArticle,
site_id: options.siteId ?? 1,
authors: boardBylines(...(options.authors ?? ['Inès Duarte'])),
},
},
'GET /v1/site/': boardSiteList(options.sites ?? 1),
...(options.overrides ?? {}),
})
}
async function showMetaSection(page: Page) {
if (isNarrow(page)) {
await page.getByRole('button', { name: 'Details', exact: true }).click()
await page.getByRole('dialog').waitFor()
}
const toggle = page.locator(metaToggle)
await toggle.waitFor()
if ((await toggle.getAttribute('aria-expanded')) !== 'true') {
await toggle.click()
}
await page.locator(metaBody).waitFor()
await page.mouse.move(0, 0)
}
async function openSiteSelect(page: Page) {
await page.locator(metaTrigger).first().click()
await page.getByRole('option').first().waitFor()
await page.mouse.move(0, 0)
}
async function openAuthorCombobox(page: Page) {
await page.locator(metaTrigger, { hasText: 'Add an author' }).click()
await page.getByPlaceholder('Search authors').waitFor()
}
async function searchAuthors(page: Page, term: string) {
await page.getByPlaceholder('Search authors').fill(term)
await page.waitForTimeout(450)
}
async function openCreateAuthorDialog(page: Page, term: string) {
await openAuthorCombobox(page)
await searchAuthors(page, term)
await page.getByTestId('create-author-row').click()
await page.getByRole('alertdialog').waitFor()
await page.mouse.move(0, 0)
}
function metaState(options: MetaOptions, steps: (page: Page) => Promise<void>): VisualState {
return {
path: '/article/12/edit',
now: editorNow,
api: metaApi(options),
steps: async (page) => {
await showMetaSection(page)
await steps(page)
},
}
}
async function grabAuthor(page: Page, name: string) {
await page.getByRole('button', { name: `Reorder ${name}`, exact: true }).focus()
await page.keyboard.press('Space')
await page.mouse.move(0, 0)
}
async function liftAuthor(page: Page, name: string) {
const handle = await page
.getByRole('button', { name: `Reorder ${name}`, exact: true })
.boundingBox()
const first = await page.locator(`${metaBody} [data-testid="author-row"]`).first().boundingBox()
const x = handle!.x + handle!.width / 2
await page.mouse.move(x, handle!.y + handle!.height / 2)
await page.mouse.down()
await page.mouse.move(x, first!.y + 2, { steps: 6 })
}
const newAuthorSlugTaken: ApiMock = { status: 409, body: 'author_slug_taken' }
async function focusBoldSample(page: Page) {
await page.getByText('write down what stays').click()
}
@@ -678,6 +820,8 @@ function articlesFixture() {
title: 'How we price a bathroom renovation',
body: '',
images: [],
site_id: 2,
authors: boardBylines('Inès Duarte'),
is_online: true,
created_at: '2026-03-12T09:14:00Z',
updated_at: articlesFixtureIso(2 * HOUR_MS),
@@ -687,6 +831,8 @@ function articlesFixture() {
title: 'Choosing lime plaster for old stone walls',
body: '',
images: [],
site_id: 1,
authors: boardBylines('Inès Duarte', 'Camille Roche'),
is_online: false,
created_at: '2026-09-09T09:00:00Z',
updated_at: articlesFixtureIso(1 * DAY_MS),
@@ -696,6 +842,8 @@ function articlesFixture() {
title: 'Before and after: the Rue Verte kitchen',
body: '',
images: [],
site_id: 3,
authors: boardBylines('Jonas Pereira'),
is_online: true,
created_at: '2026-08-28T09:00:00Z',
updated_at: articlesFixtureIso(3 * DAY_MS),
@@ -705,6 +853,14 @@ function articlesFixture() {
title: 'Winter checklist for timber windows',
body: '',
images: [],
site_id: 2,
authors: boardBylines(
'Inès Duarte',
'Jonas Pereira',
'Camille Roche',
'Paul Huber',
'Léa Fontaine',
),
is_online: false,
created_at: '2026-09-02T09:00:00Z',
updated_at: articlesFixtureIso(5 * DAY_MS),
@@ -714,6 +870,8 @@ function articlesFixture() {
title: 'Why we reuse original floorboards',
body: '',
images: [],
site_id: 1,
authors: boardBylines('Paul Huber'),
is_online: true,
created_at: '2026-06-14T09:00:00Z',
updated_at: '2026-08-18T09:00:00Z',
@@ -723,6 +881,8 @@ function articlesFixture() {
title: 'Our first passive-house retrofit',
body: '',
images: [],
site_id: 2,
authors: boardBylines(),
is_online: true,
created_at: '2026-04-03T09:00:00Z',
updated_at: '2026-07-02T09:00:00Z',
@@ -733,6 +893,7 @@ function articlesFixture() {
interface ListedArticle {
title: string
is_online: boolean
site_id?: number
}
function foldTitle(value: string) {
@@ -747,7 +908,10 @@ function articleEnvelopeFor(all: ListedArticle[], url: URL) {
const size = Number(url.searchParams.get('size') ?? 25)
const q = url.searchParams.get('q') ?? ''
const status = url.searchParams.get('status') ?? ''
const siteKey = url.searchParams.get('site') ?? ''
const siteId = directorySites.find((site) => site.key === siteKey)?.id
const matching = all
.filter((article) => !siteKey || article.site_id === siteId)
.filter((article) => !status || article.is_online === (status === 'online'))
.filter((article) => !q || foldTitle(article.title).includes(foldTitle(q)))
const online = all.filter((article) => article.is_online).length
@@ -783,10 +947,21 @@ function articlesApi(overrides: Record<string, ApiMock> = {}): Record<string, Ap
return {
'GET /v1/auth/me': verifiedSession,
'GET /v1/article/': articleListMock(articlesFixture()),
'GET /v1/site/': boardSiteList(),
...overrides,
}
}
function multiSiteArticlesApi(overrides: Record<string, ApiMock> = {}) {
return articlesApi({ 'GET /v1/site/': boardSiteList(4), ...overrides })
}
async function openSiteFilter(page: Page) {
await page.locator('button[aria-label="Site"]:visible').click()
await page.getByRole('option').first().waitFor()
await page.mouse.move(0, 0)
}
const clockSensitiveArticles = (path: string): VisualState => ({
path,
skip: 'clock-sensitive',
@@ -1148,6 +1323,47 @@ const profileSavedBody = {
display_name: profileDisplayName,
}
const profileAuthorRecord = {
id: 7,
name: 'Marta Alves',
slug: 'marta-alves',
bio: 'Project coordinator. Writes about permits and planning.',
avatar_id: null,
website: null,
social_links: [],
user_id: 1,
is_linked: true,
article_count: 0,
user_email: 'marta@renew.example',
}
function profileAuthorSession(role = 'owner', withAuthor = false): ApiMock {
return {
status: 200,
body: {
...(profileSession(profileDisplayName).body as object),
role,
...(withAuthor ? { author: { id: 7, name: 'Marta Alves', slug: 'marta-alves' } } : {}),
},
}
}
function authorProfileState(
overrides: Record<string, ApiMock>,
steps?: (page: Page) => Promise<void>,
session: ApiMock = profileAuthorSession(),
): VisualState {
return {
path: '/profile',
api: { 'GET /v1/auth/me': session, ...overrides },
steps,
}
}
async function createAuthorProfile(page: Page) {
await page.getByRole('button', { name: 'Create my author profile', exact: true }).click()
}
function profileSession(displayName = ''): ApiMock {
return {
status: 200,
@@ -2197,6 +2413,7 @@ function pickerApi(
return {
'GET /v1/auth/me': mediaSession,
'GET /v1/article/12': { status: 200, body: { ...editorArticle, body } },
'GET /v1/site/': boardSiteList(),
'GET /v1/media/': { status: 200, bodyFor: pickerList },
'GET /v1/image-variants': { status: 403 },
...overrides,
@@ -5207,6 +5424,42 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
authors: authorsStates,
activity: activityStates,
profile: {
'No author profile': authorProfileState({}),
Error: authorProfileState({ 'POST /v1/author/me': { status: 500 } }, async (page) => {
await createAuthorProfile(page)
await page.getByText("Couldn't create your author profile").waitFor()
await page.mouse.move(0, 0)
}),
Created: authorProfileState(
{ 'POST /v1/author/me': { status: 201, body: profileAuthorRecord } },
async (page) => {
await createAuthorProfile(page)
await page.getByText('Author profile created').first().waitFor()
await page.mouse.move(0, 0)
},
),
'Created, slug adjusted': authorProfileState(
{
'POST /v1/author/me': {
status: 201,
body: { ...profileAuthorRecord, slug: 'marta-alves-2' },
},
},
async (page) => {
await createAuthorProfile(page)
await page.getByText('Your slug is marta-alves-2').waitFor()
await page.mouse.move(0, 0)
},
),
'Edit own author': authorProfileState(
{ 'GET /v1/author/': authorListMock([profileAuthorRecord]) },
async (page) => {
await page.getByRole('button', { name: 'Edit author', exact: true }).click()
await page.getByRole('dialog').waitFor()
await page.mouse.move(0, 0)
},
profileAuthorSession('user', true),
),
'Account menu': {
path: '/home',
now: boardNow,
@@ -5595,6 +5848,25 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
},
articles: {
...pagination,
'Several sites': {
path: '/article',
now: articlesNow,
api: multiSiteArticlesApi(),
},
'Site filter open': {
path: '/article',
now: articlesNow,
api: multiSiteArticlesApi(),
steps: openSiteFilter,
},
'No articles on a site': {
path: '/article?site=archive',
now: articlesNow,
api: multiSiteArticlesApi(),
steps: async (page) => {
await page.getByText('No articles on "Archive"').waitFor()
},
},
Default: {
path: '/article',
now: articlesNow,
@@ -6001,6 +6273,74 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
},
},
editor: {
'Open: one site, one author': metaState({}, async () => {}),
'Open: several sites': metaState({ sites: 4, siteId: 2 }, async () => {}),
'Site select open': metaState({ sites: 4, siteId: 2 }, openSiteSelect),
'No authors, with hint': metaState({ authors: [] }, async () => {}),
'Many authors': metaState(
{
authors: [
'Inès Duarte',
'Jonas Pereira',
'Dr. Alexandra Konstantinopoulou-Whitfield',
'Paul Huber',
],
},
async () => {},
),
'Add author: open': metaState({}, openAuthorCombobox),
'Add author: no match': metaState({}, async (page) => {
await openAuthorCombobox(page)
await searchAuthors(page, 'Zoe Lambert')
await page.getByText('No author matches "Zoe Lambert".').waitFor()
}),
'Add author: open (no create)': metaState({ role: 'user' }, openAuthorCombobox),
'Add author: no match (no create)': metaState({ role: 'user' }, async (page) => {
await openAuthorCombobox(page)
await searchAuthors(page, 'Zoe Lambert')
await page.getByText('No author matches "Zoe Lambert".').waitFor()
}),
'Add author: error': metaState(
{ overrides: { 'GET /v1/author/': { status: 500 } } },
async (page) => {
await openAuthorCombobox(page)
await page.getByText("Couldn't load authors").waitFor()
},
),
'Reorder: keyboard': metaState(
{ authors: ['Inès Duarte', 'Jonas Pereira', 'Camille Roche'] },
(page) => grabAuthor(page, 'Jonas Pereira'),
),
'Reorder: pointer': metaState(
{ authors: ['Inès Duarte', 'Jonas Pereira', 'Camille Roche'] },
(page) => liftAuthor(page, 'Camille Roche'),
),
'Create author: dialog': metaState({ withAuthor: true }, (page) =>
openCreateAuthorDialog(page, 'Zoe Lambert'),
),
'Create author: for me': metaState({}, async (page) => {
await openCreateAuthorDialog(page, 'Marta Alves')
await page.getByRole('checkbox', { name: /This is me/ }).click()
await page.mouse.move(0, 0)
}),
'Create author: slug taken': metaState(
{ withAuthor: true, overrides: { 'POST /v1/author/': newAuthorSlugTaken } },
async (page) => {
await openCreateAuthorDialog(page, 'Zoe Lambert')
await page.getByRole('button', { name: 'Create author', exact: true }).click()
await page.getByText('is already used by another author').waitFor()
await page.mouse.move(0, 0)
},
),
'Create author: failed': metaState(
{ withAuthor: true, overrides: { 'POST /v1/author/': { status: 500 } } },
async (page) => {
await openCreateAuthorDialog(page, 'Zoe Lambert')
await page.getByRole('button', { name: 'Create author', exact: true }).click()
await page.getByText("Couldn't create the author").waitFor()
await page.mouse.move(0, 0)
},
),
Loading: {
path: '/article/12/edit',
now: editorNow,
@@ -6138,6 +6478,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
api: {
'GET /v1/auth/me': verifiedSession,
'GET /v1/article/9999': { status: 404 },
'GET /v1/site/': boardSiteList(),
},
},
'Link: create from selection': {