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