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

@@ -0,0 +1,238 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import router from '@/router'
import { ApiError, NetworkError, useApi } from '@/composables/useApi'
import { initializeLocale, setLocale } from '@/i18n'
import {
EXPORT_PATH,
LIST_PATH,
SUMMARY_PATH,
callsTo,
click,
days,
findButton,
harness,
mountActivity,
queryObject,
rows,
stripCards,
textOf,
unmountActivity,
} from './activityHarness'
import ActivityCategorySelect from '@/components/ActivityCategorySelect.vue'
import { activityItems, makeItem, pageOf, summaryDefault } from './activityFixtures'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
let toastAdd: ReturnType<typeof vi.fn>
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return { ...actual, useToast: () => ({ add: toastAdd }) }
})
const clicked: HTMLAnchorElement[] = []
beforeEach(() => {
toastAdd = vi.fn()
clicked.length = 0
localStorage.clear()
initializeLocale()
URL.createObjectURL = vi.fn(() => 'blob:activity')
URL.revokeObjectURL = vi.fn()
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (
this: HTMLAnchorElement,
) {
clicked.push(this)
})
})
afterEach(async () => {
vi.restoreAllMocks()
unmountActivity()
vi.mocked(useApi).mockReset()
localStorage.clear()
initializeLocale()
await router.push('/login')
})
function toastCall(index = 0) {
const options = toastAdd.mock.calls[index]![0] as {
color: string
icon: string
title: string
}
return { color: options.color, icon: options.icon, title: options.title }
}
describe('Export', () => {
it('prepares the file with the list filters, then downloads it and toasts', async () => {
let release: (blob: Blob) => void = () => undefined
const pending = new Promise<Blob>((resolve) => (release = resolve))
await mountActivity({ exportFile: () => pending })
harness.wrapper!.findComponent(ActivityCategorySelect).vm.$emit('select', 'auth')
await flushPromises()
await click(
Array.from(
document.body.querySelectorAll<HTMLButtonElement>(
'[role="group"][aria-label="Dates"] button',
),
).find((button) => textOf(button) === '30 days'),
)
await click(findButton('Export CSV'))
expect(callsTo(EXPORT_PATH)).toHaveLength(1)
expect(queryObject(callsTo(EXPORT_PATH)[0])).toEqual({ category: 'auth', range: '30d' })
const busy = findButton('Preparing file…')!
expect(busy.disabled).toBe(true)
expect(clicked).toHaveLength(0)
release(new Blob(['when,actor'], { type: 'text/csv' }))
await flushPromises()
expect(clicked).toHaveLength(1)
expect(clicked[0]!.download).toBe('activity.csv')
expect(URL.createObjectURL).toHaveBeenCalledTimes(1)
expect(toastCall()).toEqual({
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Export started. Your file will download shortly.',
})
expect(findButton('Export CSV')?.disabled).toBe(false)
})
it.each([
['a 500 response', () => new ApiError(500)],
['a 403 response', () => new ApiError(403)],
['a network failure', () => new NetworkError()],
])('raises an error toast after %s and re-enables the button', async (_, failure) => {
await mountActivity({
exportFile: () => {
throw failure()
},
})
await click(findButton('Export CSV'))
expect(toastCall()).toEqual({
color: 'error',
icon: 'i-lucide-circle-x',
title: "Couldn't export activity. Try again.",
})
expect(clicked).toHaveLength(0)
expect(findButton('Export CSV')?.disabled).toBe(false)
})
})
describe('My activity: a role that is not owner or admin sees its own events', () => {
it.each([['editor'], [undefined]])('role %s', async (role) => {
await mountActivity({
role,
list: () => pageOf(activityItems, { total: 41 }),
})
expect(textOf(document.body.querySelector('h1'))).toBe('My activity')
expect(textOf(document.body.querySelector('nav[aria-label="breadcrumb"]'))).toBe(
'My activity',
)
expect(textOf(document.body)).toContain("Everything you've done on this site.")
expect(stripCards()).toHaveLength(0)
expect(textOf(document.body)).not.toContain('All people')
expect(findButton('Export CSV')).toBeUndefined()
const headers = Array.from(document.body.querySelectorAll('[role="columnheader"]')).map(
textOf,
)
expect(headers).toEqual(['When', 'Action', 'Target', 'Outcome'])
expect(document.body.querySelector('[data-testid="activity-actor"]')).toBeNull()
expect(callsTo(SUMMARY_PATH)).toHaveLength(0)
expect(callsTo(EXPORT_PATH)).toHaveLength(0)
expect(queryObject(callsTo(LIST_PATH)[0])).toEqual({ range: '7d', page: '1', size: '25' })
expect(textOf(document.body.querySelector('[data-testid="activity-pager"]'))).toContain(
'Showing 1–25 of 41',
)
})
it('shows no actor line on the cards below md', async () => {
await mountActivity({ role: 'editor', desktop: false })
expect(rows()).toHaveLength(activityItems.length)
expect(document.body.querySelector('[data-testid="activity-actor"]')).toBeNull()
})
it('shows its own empty copy', async () => {
await mountActivity({ role: 'editor', list: () => pageOf([], { total: 0 }) })
expect(textOf(document.body)).toContain('No activity yet')
expect(textOf(document.body)).toContain(
'Your actions will appear here once you start working on the site.',
)
})
it('shows the same details panel with the user position', async () => {
await mountActivity({ role: 'editor', list: () => pageOf(activityItems, { total: 41 }) })
rows()[0]!.click()
await flushPromises()
await vi.waitFor(() => expect(document.body.querySelector('[role="dialog"]')).toBeTruthy())
expect(textOf(document.body.querySelector('[role="dialog"]'))).toContain('Event 1 of 41')
expect((findButton('Previous event') as HTMLButtonElement).disabled).toBe(true)
expect(textOf(document.body.querySelector('[data-testid="activity-actor"]'))).toBe(
'MAMarta Alves',
)
})
})
describe('The French interface', () => {
it('composes the chrome, the strip, the sentences and the day separators in French', async () => {
setLocale('fr')
await mountActivity({
summary: () => summaryDefault,
list: () =>
pageOf([
makeItem({ id: 1, created_at: '2026-10-03T12:30:00Z' }),
makeItem({
id: 2,
created_at: '2026-10-03T12:20:00Z',
outcome: 'failed',
target: { type: 'article', id: '8', name: 'Spring release', exists: true },
}),
]),
})
expect(textOf(document.body.querySelector('h1'))).toBe('Activité')
expect(textOf(findButton('Exporter en CSV'))).toBe('Exporter en CSV')
const strip = stripCards().map(textOf)
expect(strip[0]).toContain('Actions au total')
expect(strip[1]).toContain('Personnes actives')
expect(strip[1]).toContain('7 personnes')
expect(strip[2]).toContain('Actions en échec')
expect(strip[3]).toContain('Événements de sécurité')
expect(strip[3]).toContain('11 signalés')
expect(days()).toEqual(['3 oct. 2026'])
const sentences = Array.from(
document.body.querySelectorAll('[data-testid="activity-sentence"]'),
).map(textOf)
expect(sentences).toEqual([
'Article publié : Spring release',
"Échec de la publication de l'article Spring release",
])
const search = document.body.querySelector<HTMLInputElement>('input[type="text"]')!
expect(search.placeholder).toBe("Rechercher dans l'activité")
expect(textOf(document.body.querySelector('[data-testid="activity-pager"]'))).toContain(
'Lignes par page',
)
expect(textOf(rows()[0]!)).toContain('Réussie')
expect(textOf(rows()[1]!)).toContain('Échec')
})
it('shows the French My activity title', async () => {
setLocale('fr')
await mountActivity({ role: 'editor' })
expect(textOf(document.body.querySelector('h1'))).toBe('Mon activité')
})
})