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