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
@@ -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')
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user