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

+305
View File
@@ -0,0 +1,305 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { initializeLocale, setLocale } from '@/i18n'
import {
activeChips,
bodyDeltaLabel,
changeValue,
clearChip,
defaultFilters,
exportQuery,
fieldLabel,
initials,
listQuery,
summaryQuery,
targetLink,
type ActivityFilters,
} from '../activity'
import { dayLabel, formatDateRange, groupByDay, whenLabel } from '../activityTime'
import { barHeights, stripCards } from '../activitySummary'
import { summaryDefault, summaryNone, summaryZero } from '@/views/__tests__/activityFixtures'
beforeEach(() => {
localStorage.clear()
initializeLocale()
})
afterEach(() => {
localStorage.clear()
initializeLocale()
})
function filters(overrides: Partial<ActivityFilters> = {}): ActivityFilters {
return { ...defaultFilters(), ...overrides }
}
function entries(query: string) {
return Object.fromEntries(new URLSearchParams(query))
}
describe('Queries', () => {
it('always sends range, page and size and nothing else by default', () => {
expect(entries(listQuery(filters(), 1, 25))).toEqual({ range: '7d', page: '1', size: '25' })
})
it('adds q, actor, category, outcome and security only when set', () => {
const query = listQuery(
filters({
q: 'spring',
actor: 'user:3',
category: 'auth',
outcome: 'failed',
security: true,
}),
2,
50,
)
expect(entries(query)).toEqual({
q: 'spring',
actor: 'user:3',
category: 'auth',
outcome: 'failed',
security: 'true',
range: '7d',
page: '2',
size: '50',
})
})
it('sends from and to only with the custom range', () => {
expect(
entries(
listQuery(filters({ range: '30d', from: '2026-09-01', to: '2026-09-30' }), 1, 25),
),
).toEqual({ range: '30d', page: '1', size: '25' })
expect(
entries(
listQuery(
filters({ range: 'custom', from: '2026-09-01', to: '2026-09-30' }),
1,
25,
),
),
).toEqual({ range: 'custom', from: '2026-09-01', to: '2026-09-30', page: '1', size: '25' })
})
it('never sends an actor for a regular user', () => {
expect(entries(listQuery(filters({ actor: 'system' }), 1, 25, false)).actor).toBeUndefined()
})
it('keeps the summary to the date range and the export to the list filters', () => {
const all = filters({
q: 'x',
category: 'media',
range: 'custom',
from: '2026-09-01',
to: '2026-09-30',
})
expect(entries(summaryQuery(all))).toEqual({
range: 'custom',
from: '2026-09-01',
to: '2026-09-30',
})
expect(entries(exportQuery(all))).toEqual({
q: 'x',
category: 'media',
range: 'custom',
from: '2026-09-01',
to: '2026-09-30',
})
})
})
describe('Chips', () => {
it('lists the active filters in order and never the default range', () => {
expect(activeChips(filters())).toEqual([])
expect(
activeChips(
filters({
q: 'spring',
actor: 'user:3',
actorLabel: 'Marta Alves',
category: 'auth',
outcome: 'failed',
security: true,
range: '30d',
}),
).map((chip) => chip.label),
).toEqual([
'Search: spring',
'Person: Marta Alves',
'Category: Authentication',
'Outcome: Failed',
'Security events',
'Dates: 30 days',
])
})
it('words custom ranges', () => {
const range = (from: string, to: string) =>
activeChips(filters({ range: 'custom', from, to }))[0]!.label
expect(range('2026-09-01', '2026-09-30')).toBe('Dates: 1 – 30 Sep 2026')
expect(range('2026-09-28', '2026-10-03')).toBe('Dates: 28 Sep – 3 Oct 2026')
expect(range('2025-12-28', '2026-01-03')).toBe('Dates: 28 Dec 2025 – 3 Jan 2026')
expect(range('2026-09-05', '2026-09-05')).toBe('Dates: 5 Sep 2026')
})
it('words custom ranges in French', () => {
setLocale('fr')
expect(formatDateRange('2026-10-01', '2026-10-30')).toBe('1 – 30 oct. 2026')
})
it('resets one filter at a time', () => {
const all = filters({ q: 'a', actor: 'system', actorLabel: 'System', range: '30d' })
expect(clearChip(all, 'q').q).toBe('')
expect(clearChip(all, 'actor')).toMatchObject({ actor: '', actorLabel: '' })
expect(clearChip(all, 'range').range).toBe('7d')
})
})
describe('Times', () => {
const now = new Date(2026, 9, 6, 15, 0, 0)
it('labels days as Today, Yesterday or the date', () => {
expect(dayLabel(new Date(2026, 9, 6, 9, 0).toISOString(), now)).toBe('Today')
expect(dayLabel(new Date(2026, 9, 5, 23, 59).toISOString(), now)).toBe('Yesterday')
expect(dayLabel(new Date(2026, 9, 3, 14, 20).toISOString(), now)).toBe('3 Oct 2026')
setLocale('fr')
expect(dayLabel(new Date(2026, 9, 6, 9, 0).toISOString(), now)).toBe('Aujourd’hui')
expect(dayLabel(new Date(2026, 9, 3, 14, 20).toISOString(), now)).toBe('3 oct. 2026')
})
it('reads relative within today and as a clock time before', () => {
expect(whenLabel(new Date(2026, 9, 6, 14, 58).toISOString(), now)).toBe('2 min ago')
expect(whenLabel(new Date(2026, 9, 6, 14, 31).toISOString(), now)).toBe('29 min ago')
expect(whenLabel(new Date(2026, 9, 6, 14, 0).toISOString(), now)).toBe('1 h ago')
expect(whenLabel(new Date(2026, 9, 5, 18, 20).toISOString(), now)).toBe('18:20')
expect(whenLabel(new Date(2026, 9, 6, 14, 59, 50).toISOString(), now)).toBe('1 min ago')
})
it('groups consecutive items of the same local day in API order', () => {
const groups = groupByDay(
[
{ created_at: new Date(2026, 9, 6, 12).toISOString(), id: 1 },
{ created_at: new Date(2026, 9, 6, 9).toISOString(), id: 2 },
{ created_at: new Date(2026, 9, 5, 20).toISOString(), id: 3 },
],
now,
)
expect(groups.map((group) => [group.label, group.items.map((entry) => entry.id)])).toEqual([
['Today', [1, 2]],
['Yesterday', [3]],
])
})
})
describe('Labels', () => {
it('takes the first letters of the first two words', () => {
expect(initials('Marta Alves')).toBe('MA')
expect(initials('inès duarte jr')).toBe('ID')
expect(initials('Madonna')).toBe('M')
})
it('links each target type, and nothing for a deleted or unknown one', () => {
const link = (type: string, exists = true) =>
targetLink({ type, id: '8', name: 'x', exists })
expect(link('article')).toBe('/article/8/edit')
expect(link('image')).toBe('/media')
expect(link('user')).toBe('/users')
expect(link('invite')).toBe('/users')
expect(link('api_key')).toBe('/integration/api-keys')
expect(link('release')).toBeNull()
expect(link('article', false)).toBeNull()
expect(targetLink(null)).toBeNull()
})
it('labels fields and values, keeping unknown codes', () => {
expect(fieldLabel('title')).toBe('Title')
expect(fieldLabel('online')).toBe('Publish state')
expect(fieldLabel('mystery')).toBe('mystery')
expect(changeValue('online', 'false')).toBe('Draft')
expect(changeValue('online', 'true')).toBe('Published')
expect(changeValue('title', 'true')).toBe('true')
setLocale('fr')
expect(fieldLabel('filename')).toBe('Nom du fichier')
expect(changeValue('online', 'true')).toBe('Publié')
})
it('words body deltas with sign and singular', () => {
expect(bodyDeltaLabel(312)).toBe('Body edited (+312 characters)')
expect(bodyDeltaLabel(-48)).toBe('Body edited (-48 characters)')
expect(bodyDeltaLabel(1)).toBe('Body edited (+1 character)')
})
})
describe('Summary strip', () => {
it('derives the four cards from the summary', () => {
const cards = stripCards(summaryDefault, '7d')
expect(cards.map((card) => [card.label, card.value, card.subline])).toEqual([
['Total actions', '1,284', '12% vs previous 7 days'],
['Active people', '9', '7 people, 2 API keys'],
['Failed actions', '6', '0.5% of all actions'],
['Security events', '11', '11 flagged'],
])
expect(cards.map((card) => card.tone)).toEqual(['neutral', 'neutral', 'error', 'warning'])
expect(cards[0]!.icon).toBe('i-lucide-trending-up')
})
it('words the comparison for each range', () => {
const subline = (range: 'today' | '7d' | '30d' | 'custom') =>
stripCards(summaryDefault, range)[0]!.subline
expect(subline('today')).toBe('12% vs previous day')
expect(subline('30d')).toBe('12% vs previous 30 days')
expect(subline('custom')).toBe('12% vs previous period')
})
it('goes calm on zero and reads No data yet on an empty site', () => {
const quiet = stripCards(summaryZero, '7d')
expect(quiet[0]!.icon).toBe('i-lucide-trending-down')
expect(quiet.slice(2).map((card) => [card.tone, card.subline, card.icon])).toEqual([
['calm', 'Nothing failed', 'i-lucide-circle-check'],
['calm', 'Nothing to flag', 'i-lucide-circle-check'],
])
const none = stripCards(summaryNone, '7d')
expect(none.map((card) => card.subline)).toEqual([
'No data yet',
'No data yet',
'Nothing failed',
'Nothing to flag',
])
})
it('words singulars, the French forms and the flag count', () => {
const single = stripCards(
{ ...summaryDefault, active_people: { users: 1, api_keys: 1 }, security: 1 },
'7d',
)
expect(single[1]!.subline).toBe('1 person, 1 API key')
expect(single[3]!.subline).toBe('1 flagged')
setLocale('fr')
expect(stripCards(summaryDefault, '7d')[3]!.subline).toBe('11 signalés')
expect(stripCards({ ...summaryDefault, security: 1 }, '7d')[3]!.subline).toBe('1 signalé')
expect(
stripCards({ ...summaryDefault, total: 40, previous_total: 0 }, '7d')[0]!.subline,
).toBe('Aucune donnée antérieure')
expect(
stripCards({ ...summaryDefault, total: 40, previous_total: 40 }, '7d')[0]!.subline,
).toBe('Aucun changement')
})
it('sizes bars from the peak with a 2 px minimum', () => {
expect(barHeights([4, 6, 5, 8, 7, 9, 6], 20)).toEqual([9, 13, 11, 18, 16, 20, 13])
expect(barHeights([0, 0, 0, 0, 0, 0, 0], 20)).toEqual([2, 2, 2, 2, 2, 2, 2])
expect(barHeights([0, 1, 0, 0, 0, 0, 100], 16)).toEqual([2, 2, 2, 2, 2, 2, 16])
})
})
@@ -0,0 +1,240 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { initializeLocale, setLocale, t } from '@/i18n'
import { messages, type MessageKey } from '@/i18n/messages'
import type { ActivityItem } from '@/lib/activity'
import {
SENTENCE_ACTIONS,
composeSentence,
sentenceTemplates,
sentenceText,
} from '../activitySentence'
function item(overrides: Partial<ActivityItem>): ActivityItem {
return {
id: 1,
created_at: '2026-10-03T12:00:00Z',
action: 'article.publish',
category: 'articles',
actor: { type: 'user', id: 3, name: 'Marta Alves' },
target: { type: 'article', id: '8', name: 'Spring release', exists: true },
outcome: 'success',
failure_reason: null,
security: false,
source: 'web',
ip: null,
user_agent: null,
params: {},
changes: [],
...overrides,
}
}
beforeEach(() => {
localStorage.clear()
initializeLocale()
})
afterEach(() => {
localStorage.clear()
initializeLocale()
})
describe('Sentences: English and French come from the same code and params', () => {
const cases: [string, Partial<ActivityItem>, string, string][] = [
[
'article.publish',
{},
'Published the article Spring release',
'Article publié : Spring release',
],
[
'article.publish failed',
{ outcome: 'failed' },
'Failed to publish the article Spring release',
"Échec de la publication de l'article Spring release",
],
[
'media.purge',
{ action: 'media.purge', target: null, params: { merged: 3, articles_updated: 5 } },
'Purged duplicate images: 3 merged, 5 articles updated',
'Doublons purgés : 3 fusionnées, 5 articles mis à jour',
],
[
'media.purge with failed groups',
{
action: 'media.purge',
target: null,
params: { merged: 3, articles_updated: 1, failed_groups: 1 },
},
'Purged duplicate images: 3 merged, 1 article updated, 1 group failed',
'Doublons purgés : 3 fusionnées, 1 article mis à jour, 1 groupe en échec',
],
[
'system.media_purge with several failed groups',
{
action: 'system.media_purge',
target: null,
params: { merged: 10, articles_updated: 12, failed_groups: 3 },
},
'Ran the scheduled duplicate purge: 10 merged, 12 articles updated, 3 groups failed',
'Purge automatique des doublons : 10 fusionnées, 12 articles mis à jour, 3 groupes en échec',
],
[
'system.activity_cleanup singular',
{ action: 'system.activity_cleanup', target: null, params: { removed: 1 } },
'Removed 1 old event from the activity log',
"1 ancien événement supprimé du journal d'activité",
],
[
'system.activity_cleanup plural',
{ action: 'system.activity_cleanup', target: null, params: { removed: 1284 } },
'Removed 1,284 old events from the activity log',
"1 284 anciens événements supprimés du journal d'activité",
],
[
'system.activity_retention_changed',
{
action: 'system.activity_retention_changed',
target: null,
params: { from: 90, to: 0 },
},
'Changed the activity retention from 90 days to Forever',
"Durée de conservation de l'activité modifiée : 90 jours → Toujours",
],
[
'system.restart_requested update',
{
action: 'system.restart_requested',
target: { type: 'release', id: 'v1.4.0', name: 'v1.4.0', exists: true },
params: { kind: 'update', tag: 'v1.4.0' },
},
'Restarted to apply the update v1.4.0',
'Redémarrage pour appliquer la mise à jour v1.4.0',
],
[
'system.restart_requested restart',
{ action: 'system.restart_requested', target: null, params: { kind: 'restart' } },
'Requested a restart',
'Redémarrage demandé',
],
[
'media.upload failed without a target',
{ action: 'media.upload', target: null, outcome: 'failed' },
'Failed to upload the image',
"Échec du téléversement de l'image",
],
[
'article.publish without a target',
{ target: null },
'Published the article',
'Article publié',
],
[
'auth.login_failed uses the actor name',
{
action: 'auth.login_failed',
target: null,
outcome: 'failed',
actor: { type: 'anonymous', id: null, name: 'alex@example.com' },
},
'Failed login attempt for alex@example.com',
'Échec de connexion pour alex@example.com',
],
[
'auth.login failed renders its success sentence',
{ action: 'auth.login', target: null, outcome: 'failed' },
'Logged in',
'Connexion',
],
[
'unknown action code',
{ action: 'article.archive' },
'article.archive',
'article.archive',
],
]
it.each(cases)('%s', (_, overrides, english, french) => {
const subject = item(overrides)
expect(sentenceText(subject)).toBe(english)
setLocale('fr')
expect(sentenceText(subject)).toBe(french)
})
it('inserts the target name untranslated as the semibold last segment', () => {
const segments = composeSentence(item({}))
expect(segments).toEqual([
{ text: 'Published the article ', strong: false },
{ text: 'Spring release', strong: true },
])
setLocale('fr')
expect(composeSentence(item({}))).toEqual([
{ text: 'Article publié : ', strong: false },
{ text: 'Spring release', strong: true },
])
})
it('keeps a target name that looks like a template slot', () => {
const subject = item({
target: { type: 'article', id: '1', name: '{0} {1}', exists: true },
})
expect(sentenceText(subject)).toBe('Published the article {0} {1}')
})
})
describe('The catalogue is complete in both languages', () => {
const templateKeys = SENTENCE_ACTIONS.flatMap((action) => {
const template = sentenceTemplates(action)!
return [template.success, ...(template.failed ? [template.failed] : [])]
})
it.each(Array.from(new Set(templateKeys)))('has a French entry for %s', (key) => {
expect(messages).toHaveProperty([key as MessageKey])
expect(messages[key as MessageKey]).not.toBe('')
expect(messages[key as MessageKey]).not.toBe(key)
})
it('covers every action the producers record', () => {
expect(SENTENCE_ACTIONS.sort()).toEqual(
[
'article.create',
'article.edit',
'article.publish',
'article.unpublish',
'article.delete',
'media.upload',
'media.rename',
'media.delete',
'media.merge',
'media.purge',
'system.media_purge',
'user.register',
'user.verify',
'user.update_profile',
'invite.create',
'invite.cancel',
'invite.renew',
'invite.accept',
'api_key.create',
'api_key.revoke',
'auth.login',
'auth.login_failed',
'auth.logout',
'auth.password_reset_requested',
'auth.password_reset_completed',
'system.update_checked',
'system.update_downloaded',
'system.restart_requested',
'system.activity_retention_changed',
'system.activity_cleanup',
].sort(),
)
})
it('formats a template with placeholders through t', () => {
expect(t('Revoked API key {0}', ['Mobile app'])).toBe('Revoked API key Mobile app')
})
})