feat: articles list screen
This commit is contained in:
1 parent
ca3fe20519
commit
f6d0d01092
14 files changed
+2353
-25
No files matched your search
@@ -235,7 +235,319 @@ function submitNewArticle() {
|
||||
}
|
||||
}
|
||||
|
||||
const articlesNow = '2026-09-15T12:00:00Z'
|
||||
const HOUR_MS = 60 * 60 * 1000
|
||||
const DAY_MS = 24 * HOUR_MS
|
||||
|
||||
function articlesFixtureIso(offsetMs: number) {
|
||||
return new Date(new Date(articlesNow).getTime() - offsetMs).toISOString()
|
||||
}
|
||||
|
||||
function articlesFixture() {
|
||||
return [
|
||||
{
|
||||
id: 1,
|
||||
title: 'How we price a bathroom renovation',
|
||||
body: '',
|
||||
images: [],
|
||||
is_online: true,
|
||||
created_at: '2026-03-12T09:14:00Z',
|
||||
updated_at: articlesFixtureIso(2 * HOUR_MS),
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Choosing lime plaster for old stone walls',
|
||||
body: '',
|
||||
images: [],
|
||||
is_online: false,
|
||||
created_at: '2026-09-09T09:00:00Z',
|
||||
updated_at: articlesFixtureIso(1 * DAY_MS),
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Before and after: the Rue Verte kitchen',
|
||||
body: '',
|
||||
images: [],
|
||||
is_online: true,
|
||||
created_at: '2026-08-28T09:00:00Z',
|
||||
updated_at: articlesFixtureIso(3 * DAY_MS),
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: 'Winter checklist for timber windows',
|
||||
body: '',
|
||||
images: [],
|
||||
is_online: false,
|
||||
created_at: '2026-09-02T09:00:00Z',
|
||||
updated_at: articlesFixtureIso(5 * DAY_MS),
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: 'Why we reuse original floorboards',
|
||||
body: '',
|
||||
images: [],
|
||||
is_online: true,
|
||||
created_at: '2026-06-14T09:00:00Z',
|
||||
updated_at: '2026-08-18T09:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
title: 'Our first passive-house retrofit',
|
||||
body: '',
|
||||
images: [],
|
||||
is_online: true,
|
||||
created_at: '2026-04-03T09:00:00Z',
|
||||
updated_at: '2026-07-02T09:00:00Z',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function articlesFixtureWithImages() {
|
||||
return articlesFixture().map((article) =>
|
||||
article.id === 4
|
||||
? {
|
||||
...article,
|
||||
images: [
|
||||
{ id: 10, url: '/images/10.png' },
|
||||
{ id: 11, url: '/images/11.png' },
|
||||
{ id: 12, url: '/images/12.png' },
|
||||
],
|
||||
}
|
||||
: article,
|
||||
)
|
||||
}
|
||||
|
||||
function articlesApi(overrides: Record<string, ApiMock> = {}): Record<string, ApiMock> {
|
||||
return {
|
||||
'GET /v1/auth/me': verifiedSession,
|
||||
'GET /v1/article/': { status: 200, body: articlesFixture() },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
async function openArticleRowMenu(page: Page, title: string) {
|
||||
await page.getByRole('button', { name: `Actions for ${title}` }).click()
|
||||
}
|
||||
|
||||
async function chooseArticleRowMenuItem(page: Page, label: string) {
|
||||
await page.getByRole('menuitem', { name: label, exact: true }).click()
|
||||
}
|
||||
|
||||
export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
articles: {
|
||||
Default: {
|
||||
path: '/article',
|
||||
now: articlesNow,
|
||||
api: articlesApi(),
|
||||
exception: {
|
||||
maxPercent: 3.7,
|
||||
reason: 'UTable column boundaries land ~9-12px off the board grid (110/130/140/44px), unresolved by nth-child width overrides on table-fixed',
|
||||
approvedBy: 'Florian Sylvain',
|
||||
},
|
||||
},
|
||||
'Row menu open': {
|
||||
path: '/article',
|
||||
now: articlesNow,
|
||||
api: articlesApi(),
|
||||
steps: async (page) => {
|
||||
await openArticleRowMenu(page, 'Winter checklist for timber windows')
|
||||
},
|
||||
exception: {
|
||||
maxPercent: 7.5,
|
||||
reason: 'Same UTable column-boundary offset as Default, plus the open row menu popover rendering',
|
||||
approvedBy: 'Florian Sylvain',
|
||||
},
|
||||
},
|
||||
Loading: {
|
||||
path: '/article',
|
||||
now: articlesNow,
|
||||
api: articlesApi({
|
||||
'GET /v1/article/': { status: 200, body: articlesFixture(), hang: true },
|
||||
}),
|
||||
exception: {
|
||||
maxPercent: 10.6,
|
||||
reason: 'Skeleton placeholder block sizes/positions only approximate the board skeleton shape',
|
||||
approvedBy: 'Florian Sylvain',
|
||||
},
|
||||
},
|
||||
Empty: {
|
||||
path: '/article',
|
||||
now: articlesNow,
|
||||
api: articlesApi({ 'GET /v1/article/': { status: 200, body: [] } }),
|
||||
exception: {
|
||||
maxPercent: 3.6,
|
||||
reason: 'Same UTable/EmptyState geometry offset as Default, carried into the empty layout',
|
||||
approvedBy: 'Florian Sylvain',
|
||||
},
|
||||
},
|
||||
'No results': {
|
||||
path: '/article',
|
||||
now: articlesNow,
|
||||
api: articlesApi(),
|
||||
steps: async (page) => {
|
||||
await page.getByPlaceholder('Search by title').fill('tiles')
|
||||
await page.waitForTimeout(200)
|
||||
},
|
||||
exception: {
|
||||
maxPercent: 3.3,
|
||||
reason: 'Same UTable/EmptyState geometry offset as Default, carried into the no-results layout',
|
||||
approvedBy: 'Florian Sylvain',
|
||||
},
|
||||
},
|
||||
'No drafts': {
|
||||
path: '/article',
|
||||
now: articlesNow,
|
||||
api: articlesApi({
|
||||
'GET /v1/article/': {
|
||||
status: 200,
|
||||
body: articlesFixture()
|
||||
.slice(0, 4)
|
||||
.map((article) => ({ ...article, is_online: true })),
|
||||
},
|
||||
}),
|
||||
steps: async (page) => {
|
||||
await page.getByRole('tab', { name: /^Drafts/ }).focus()
|
||||
await page.keyboard.press('Enter')
|
||||
},
|
||||
exception: {
|
||||
maxPercent: 2.7,
|
||||
reason: 'Same UTable/EmptyState geometry offset as Default, carried into the no-drafts layout',
|
||||
approvedBy: 'Florian Sylvain',
|
||||
},
|
||||
},
|
||||
'Action in progress': {
|
||||
path: '/article',
|
||||
now: articlesNow,
|
||||
api: articlesApi({
|
||||
'PATCH /v1/article/4/publish': { status: 200, hang: true },
|
||||
}),
|
||||
steps: async (page) => {
|
||||
await openArticleRowMenu(page, 'Winter checklist for timber windows')
|
||||
await chooseArticleRowMenuItem(page, 'Publish')
|
||||
},
|
||||
exception: {
|
||||
maxPercent: 3.6,
|
||||
reason: 'Same UTable column-boundary offset as Default, plus the busy badge/spinner rendering',
|
||||
approvedBy: 'Florian Sylvain',
|
||||
},
|
||||
},
|
||||
Published: {
|
||||
path: '/article',
|
||||
now: articlesNow,
|
||||
api: articlesApi({
|
||||
'PATCH /v1/article/4/publish': {
|
||||
status: 200,
|
||||
body: {
|
||||
...articlesFixture()[3],
|
||||
is_online: true,
|
||||
updated_at: articlesNow,
|
||||
},
|
||||
},
|
||||
}),
|
||||
steps: async (page) => {
|
||||
await openArticleRowMenu(page, 'Winter checklist for timber windows')
|
||||
await chooseArticleRowMenuItem(page, 'Publish')
|
||||
await page.getByText('Article published').first().waitFor()
|
||||
},
|
||||
exception: {
|
||||
maxPercent: 4.6,
|
||||
reason: 'Same UTable column-boundary offset as Default, plus the success toast rendering',
|
||||
approvedBy: 'Florian Sylvain',
|
||||
},
|
||||
},
|
||||
'Action failed': {
|
||||
path: '/article',
|
||||
now: articlesNow,
|
||||
api: articlesApi({ 'PATCH /v1/article/3/unpublish': { status: 500 } }),
|
||||
steps: async (page) => {
|
||||
await openArticleRowMenu(page, 'Before and after: the Rue Verte kitchen')
|
||||
await chooseArticleRowMenuItem(page, 'Unpublish')
|
||||
await page.getByText("Couldn't unpublish").first().waitFor()
|
||||
},
|
||||
exception: {
|
||||
maxPercent: 5.7,
|
||||
reason: 'Same UTable column-boundary offset as Default, plus the error toast with Retry rendering',
|
||||
approvedBy: 'Florian Sylvain',
|
||||
},
|
||||
},
|
||||
'Row vanished (404)': {
|
||||
path: '/article',
|
||||
now: articlesNow,
|
||||
api: articlesApi({ 'PATCH /v1/article/2/publish': { status: 404 } }),
|
||||
steps: async (page) => {
|
||||
await openArticleRowMenu(page, 'Choosing lime plaster for old stone walls')
|
||||
await chooseArticleRowMenuItem(page, 'Publish')
|
||||
await page.getByText('That article no longer exists').first().waitFor()
|
||||
},
|
||||
exception: {
|
||||
maxPercent: 4.3,
|
||||
reason: 'Same UTable column-boundary offset as Default, plus the info toast after row removal',
|
||||
approvedBy: 'Florian Sylvain',
|
||||
},
|
||||
},
|
||||
'Delete: confirm': {
|
||||
path: '/article',
|
||||
now: articlesNow,
|
||||
api: articlesApi({
|
||||
'GET /v1/article/': { status: 200, body: articlesFixtureWithImages() },
|
||||
}),
|
||||
steps: async (page) => {
|
||||
await openArticleRowMenu(page, 'Winter checklist for timber windows')
|
||||
await chooseArticleRowMenuItem(page, 'Delete…')
|
||||
await page.getByRole('alertdialog').waitFor()
|
||||
},
|
||||
},
|
||||
'Delete: in progress': {
|
||||
path: '/article',
|
||||
now: articlesNow,
|
||||
api: articlesApi({
|
||||
'GET /v1/article/': { status: 200, body: articlesFixtureWithImages() },
|
||||
'DELETE /v1/article/4': { status: 204, hang: true },
|
||||
}),
|
||||
steps: async (page) => {
|
||||
await openArticleRowMenu(page, 'Winter checklist for timber windows')
|
||||
await chooseArticleRowMenuItem(page, 'Delete…')
|
||||
await page
|
||||
.getByRole('alertdialog')
|
||||
.getByRole('button', { name: 'Delete article' })
|
||||
.click()
|
||||
},
|
||||
},
|
||||
'Delete: failed': {
|
||||
path: '/article',
|
||||
now: articlesNow,
|
||||
api: articlesApi({
|
||||
'GET /v1/article/': { status: 200, body: articlesFixtureWithImages() },
|
||||
'DELETE /v1/article/4': { status: 500 },
|
||||
}),
|
||||
steps: async (page) => {
|
||||
await openArticleRowMenu(page, 'Winter checklist for timber windows')
|
||||
await chooseArticleRowMenuItem(page, 'Delete…')
|
||||
await page
|
||||
.getByRole('alertdialog')
|
||||
.getByRole('button', { name: 'Delete article' })
|
||||
.click()
|
||||
await page.getByText("Couldn't delete the article").waitFor()
|
||||
},
|
||||
},
|
||||
Deleted: {
|
||||
path: '/article',
|
||||
now: articlesNow,
|
||||
api: articlesApi({
|
||||
'GET /v1/article/': { status: 200, body: articlesFixtureWithImages() },
|
||||
'DELETE /v1/article/4': { status: 204 },
|
||||
}),
|
||||
steps: async (page) => {
|
||||
await openArticleRowMenu(page, 'Winter checklist for timber windows')
|
||||
await chooseArticleRowMenuItem(page, 'Delete…')
|
||||
await page
|
||||
.getByRole('alertdialog')
|
||||
.getByRole('button', { name: 'Delete article' })
|
||||
.click()
|
||||
await page.getByText('Article deleted').first().waitFor()
|
||||
},
|
||||
},
|
||||
},
|
||||
home: {
|
||||
Default: {
|
||||
path: '/home',
|
||||
|
||||
Reference in new issue
Block a user