feat: activity screen in the SPA, route /activity

This commit is contained in:
fsylvain committed 2026-10-07 01:48:21 +02:00
1 parent e37a46a027
commit 35db442cd3
47 files changed
+7582 -11

No files matched your search

+377
View File
@@ -2576,7 +2576,384 @@ const mediaPurgeStates: Record<string, VisualState> = {
'Offline: button disabled': mediaState(mediaDuplicatesApi(), goOffline, mediaDuplicatesPath),
}
const activityOwnerSession: ApiMock = {
status: 200,
body: {
id: 2,
is_verified: true,
username: 'ines',
email: 'ines.duarte@renew.example',
display_name: 'Inès Duarte',
role: 'owner',
},
}
const activityEditorSession: ApiMock = {
status: 200,
body: {
id: 3,
is_verified: true,
username: 'marta',
email: 'marta@renew.example',
display_name: 'Marta Alves',
role: 'editor',
},
}
const activitySummaryDefault = {
total: 1284,
previous_total: 1146,
active_people: { users: 7, api_keys: 2 },
failed: 6,
security: 11,
series: {
total: [4, 6, 5, 8, 7, 9, 6],
active: [5, 6, 6, 7, 7, 8, 9],
failed: [0, 1, 0, 2, 1, 0, 2],
security: [1, 0, 2, 1, 3, 2, 2],
},
}
const activitySummaryNone = {
total: 0,
previous_total: 0,
active_people: { users: 0, api_keys: 0 },
failed: 0,
security: 0,
series: {
total: [0, 0, 0, 0, 0, 0, 0],
active: [0, 0, 0, 0, 0, 0, 0],
failed: [0, 0, 0, 0, 0, 0, 0],
security: [0, 0, 0, 0, 0, 0, 0],
},
}
const activityLongTitle =
'The complete guide to restoring a nineteenth-century timber-framed farmhouse: structural surveys, lime mortar, breathable insulation and what it all costs'
function activityEvent(
id: number,
createdAt: string,
action: string,
category: string,
actor: { type: string; id: number | null; name: string },
extra: Record<string, unknown> = {},
) {
return {
id,
created_at: createdAt,
action,
category,
actor,
target: null,
outcome: 'success',
failure_reason: null,
security: false,
source: 'web',
ip: '203.0.113.24',
user_agent: 'Firefox 131 on macOS',
params: {},
changes: [],
...extra,
}
}
const activityTarget = (type: string, id: string, name: string, exists = true) => ({
type,
id,
name,
exists,
})
const activityBoardEvents = [
activityEvent(
1,
'2026-10-06T09:42:18Z',
'article.publish',
'articles',
{ type: 'user', id: 3, name: 'Marta Alves' },
{
target: activityTarget('article', '8', 'Spring release'),
changes: [{ field: 'online', kind: 'text', before: 'false', after: 'true' }],
},
),
activityEvent(
2,
'2026-10-06T09:15:04Z',
'article.edit',
'articles',
{ type: 'user', id: 3, name: 'Marta Alves' },
{
target: activityTarget('article', '5', 'Why we reuse original floorboards'),
changes: [
{
field: 'title',
kind: 'text',
before: 'Why we reuse floorboards',
after: 'Why we reuse original floorboards',
},
{ field: 'body', kind: 'body', delta: 312 },
],
},
),
activityEvent(
3,
'2026-10-06T08:31:47Z',
'auth.login_failed',
'auth',
{ type: 'anonymous', id: null, name: 'alex@example.com' },
{
outcome: 'failed',
security: true,
failure_reason: 'The password did not match. 3 attempts in 10 minutes.',
ip: '198.51.100.77',
user_agent: 'Chrome 129 on Windows',
},
),
activityEvent(
4,
'2026-10-06T07:50:09Z',
'api_key.revoke',
'api_keys',
{ type: 'user', id: 2, name: 'Jonas Weber' },
{
target: activityTarget('api_key', '71', 'Mobile app'),
security: true,
ip: '203.0.113.90',
user_agent: 'Safari 18 on iOS',
},
),
activityEvent(
5,
'2026-10-06T02:00:02Z',
'system.media_purge',
'system',
{ type: 'system', id: null, name: 'System' },
{
source: 'system',
ip: null,
user_agent: null,
params: { merged: 3, articles_updated: 5 },
},
),
activityEvent(
6,
'2026-10-05T17:20:36Z',
'invite.accept',
'users',
{ type: 'user', id: 4, name: 'Inès Duarte' },
{ target: activityTarget('invite', '2', 'jonas@renew.example'), security: true },
),
activityEvent(
7,
'2026-10-05T15:05:51Z',
'media.upload',
'media',
{ type: 'api_key', id: 7, name: 'Website sync' },
{
target: activityTarget('image', '4', 'north-wall.jpg'),
source: 'api_key',
ip: '192.0.2.18',
user_agent: 'node-fetch 3.3',
},
),
activityEvent(
8,
'2026-10-05T13:40:13Z',
'media.delete',
'media',
{ type: 'user', id: 3, name: 'Marta Alves' },
{ target: activityTarget('image', '9', 'old-banner.png', false) },
),
activityEvent(
9,
'2026-10-05T10:12:27Z',
'article.publish',
'articles',
{ type: 'user', id: 4, name: 'Inès Duarte' },
{
target: activityTarget('article', '6', activityLongTitle),
outcome: 'failed',
failure_reason: 'The article has no cover image. Add one, then publish again.',
},
),
activityEvent(
10,
'2026-10-05T02:00:01Z',
'system.activity_cleanup',
'system',
{ type: 'system', id: null, name: 'System' },
{ source: 'system', ip: null, user_agent: null, params: { removed: 142 } },
),
activityEvent(
11,
'2026-10-03T15:48:22Z',
'article.edit',
'articles',
{ type: 'user', id: 4, name: 'Inès Duarte' },
{
target: activityTarget('article', '2', 'Choosing lime plaster for old stone walls'),
changes: [
{
field: 'title',
kind: 'text',
before: 'Renovation notes, project write-ups and practical guides from the Renew studio team in Lisbon, covering timber, lime plaster and stone restoration for older homes.',
after: 'Renovation notes, project write-ups and practical guides from the Renew studio team in Lisbon, covering timber, lime plaster and stone restoration for older homes, plus a seasonal maintenance checklist.',
},
],
},
),
activityEvent(
12,
'2026-10-03T14:02:40Z',
'api_key.create',
'api_keys',
{ type: 'user', id: 2, name: 'Jonas Weber' },
{
target: activityTarget('api_key', '71', 'Mobile app', false),
security: true,
ip: '203.0.113.90',
},
),
activityEvent(
13,
'2026-10-03T13:20:05Z',
'user.update_profile',
'users',
{ type: 'user', id: 4, name: 'Inès Duarte' },
{
target: activityTarget('user', '8', 'Sam Rivera'),
changes: [
{
field: 'display_name',
kind: 'text',
before: 'Sam Rivera',
after: 'Samuel Rivera',
},
],
},
),
activityEvent(
14,
'2026-10-03T10:36:58Z',
'media.upload',
'media',
{ type: 'user', id: 3, name: 'Marta Alves' },
{ target: activityTarget('image', '5', 'floorboards-copy.jpg') },
),
activityEvent(
15,
'2026-10-03T08:05:12Z',
'auth.password_reset_completed',
'auth',
{ type: 'user', id: 4, name: 'Inès Duarte' },
{ target: activityTarget('user', '8', 'Sam Rivera'), security: true },
),
]
function activityPage(items: unknown[], total: number) {
return { items, total, page: 1, size: 25 }
}
function activityApi(
overrides: Record<string, ApiMock> = {},
session: ApiMock = activityOwnerSession,
): Record<string, ApiMock> {
return {
'GET /v1/auth/me': session,
'GET /v1/activity': { status: 200, body: activityPage(activityBoardEvents, 1284) },
'GET /v1/activity/summary': { status: 200, body: activitySummaryDefault },
...overrides,
}
}
const activityClockSensitive: VisualState = { path: '/activity', skip: 'clock-sensitive' }
async function filterActivityToNothing(page: Page) {
const mobile = (page.viewportSize()?.width ?? 0) < 768
const search = page.getByPlaceholder('Search activity')
await search.fill('sitemap')
await search.press('Enter')
if (mobile) {
await page.getByRole('button', { name: /^Filters/ }).click()
}
await page.getByRole('button', { name: 'Category', exact: true }).click()
await page.getByRole('option', { name: 'Media', exact: true }).click()
await page
.getByRole('group', { name: 'Outcome' })
.getByRole('button', { name: 'Failed', exact: true })
.click()
if (mobile) {
await page.getByRole('button', { name: /^Show \d+ results?$/ }).click()
}
await page.getByText('No activity matches these filters').waitFor()
await page.mouse.move(0, 0)
}
const activityStates: Record<string, VisualState> = {
'Default, large dataset': activityClockSensitive,
Loading: {
path: '/activity',
api: activityApi({
'GET /v1/activity': { status: 200, body: activityPage([], 0), hang: true },
'GET /v1/activity/summary': { status: 200, body: activitySummaryDefault, hang: true },
}),
steps: async (page) => {
await page.waitForTimeout(400)
},
},
'Zero-value summary strip': activityClockSensitive,
'Filtered, card applied': activityClockSensitive,
'Mobile filters sheet': activityClockSensitive,
'Empty, no activity yet': {
path: '/activity',
api: activityApi({
'GET /v1/activity': { status: 200, body: activityPage([], 0) },
'GET /v1/activity/summary': { status: 200, body: activitySummaryNone },
}),
},
'Empty, filters match nothing': {
path: '/activity',
api: activityApi({
'GET /v1/activity': {
status: 200,
bodyFor: (url) =>
url.searchParams.has('q') ||
url.searchParams.has('category') ||
url.searchParams.has('outcome')
? activityPage([], 0)
: activityPage(activityBoardEvents, 1284),
},
}),
steps: filterActivityToNothing,
},
'Error with retry': {
path: '/activity',
api: activityApi({ 'GET /v1/activity': { status: 500 } }),
},
'Forbidden (403)': {
path: '/activity',
api: activityApi({ 'GET /v1/activity': { status: 403 } }, activityEditorSession),
},
'Edit with a diff': activityClockSensitive,
'Settings diff, long text': activityClockSensitive,
'Failed action, very long target title': activityClockSensitive,
'Deleted target, no diff': activityClockSensitive,
'System-initiated, no human actor': activityClockSensitive,
'Security event, failed login': activityClockSensitive,
Default: activityClockSensitive,
Empty: {
path: '/activity',
api: activityApi(
{ 'GET /v1/activity': { status: 200, body: activityPage([], 0) } },
activityEditorSession,
),
},
Details: activityClockSensitive,
}
export const visualStates: Record<string, Record<string, VisualState>> = {
activity: activityStates,
profile: {
'Account menu': {
path: '/home',