feat: sites and authors integration into existing screens (& formated all frontend files kinda accidentally)
This commit is contained in:
1 parent
3d81d2c717
commit
6b8990c55e
239 files changed
+9194
-1354
No files matched your search
@@ -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': {
|
||||
|
||||
Reference in new issue
Block a user