feat: activity screen in the SPA, route /activity
This commit is contained in:
1 parent
e37a46a027
commit
35db442cd3
47 files changed
+7582
-11
No files matched your search
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user