feat(activity): update summary cards to toggles and introduce export menu

This commit is contained in:
Florian Sylvain committed 2026-10-12 05:13:25 +02:00
1 parent 73f4b539fe
commit 03b39e64f2
30 files changed
+2376 -238

No files matched your search

+2 -2
View File
@@ -36,7 +36,7 @@ const emit = defineEmits<{
variant="ghost"
icon="i-lucide-x"
:aria-label="t('Remove filter {0}', [chip.label])"
class="size-5 justify-center p-0 text-muted"
class="size-5 justify-center p-0 text-muted hover:bg-accented hover:text-highlighted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
:ui="{ leadingIcon: 'size-3' }"
@click="emit('remove', chip.key)"
/>
@@ -45,7 +45,7 @@ const emit = defineEmits<{
<UButton
color="neutral"
variant="link"
class="h-11 px-1.5 text-xs font-medium text-highlighted underline underline-offset-3 md:h-6"
class="h-11 px-1.5 text-xs font-medium text-highlighted underline underline-offset-3 hover:bg-elevated focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary md:h-6"
@click="emit('clear')"
>
{{ t('Clear all') }}
+248
View File
@@ -0,0 +1,248 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import type { ExportStage } from '@/composables/useActivityExport'
import { EXPORT_RANGE_KEYS, exportRangeLabel, type ExportRange } from '@/lib/activity'
const props = defineProps<{
stage: ExportStage
canSubmit: boolean
fileName: string
}>()
const emit = defineEmits<{
submit: []
close: []
}>()
const open = defineModel<boolean>('open', { default: false })
const range = defineModel<ExportRange>('range', { default: 'all' })
const from = defineModel<string>('from', { default: '' })
const to = defineModel<string>('to', { default: '' })
const MODAL_CONTENT =
'divide-y-0 shadow-(--shadow-modal) ring-0 sm:max-w-md md:top-35 md:translate-y-0 max-md:!inset-x-0 max-md:!top-auto max-md:!bottom-0 max-md:!left-0 max-md:!w-auto max-md:!max-w-none max-md:!translate-x-0 max-md:!translate-y-0 max-md:before:absolute max-md:before:top-2 max-md:before:left-1/2 max-md:before:h-1 max-md:before:w-9 max-md:before:-translate-x-1/2 max-md:before:bg-(--ui-border-accented) max-md:pt-3'
const BUTTON_CLASS = 'h-11 justify-center px-3 text-sm md:h-8'
const OUTLINE_CLASS = `${BUTTON_CLASS} border border-accented bg-default text-highlighted ring-0`
const FIELD_LABEL_CLASS = 'font-medium text-highlighted'
const CLOSE_BUTTON = {
color: 'neutral',
variant: 'ghost',
class: 'size-11 justify-center p-0 text-muted md:size-7',
} as const
const preparing = computed(() => props.stage === 'preparing')
const rangeItems = computed(() =>
EXPORT_RANGE_KEYS.map((key) => ({ value: key, label: exportRangeLabel(key) })),
)
const showCustomFields = computed(() => range.value === 'custom')
function onOpenChange(value: boolean) {
if (!value) emit('close')
}
</script>
<template>
<UModal
:open="open"
:title="t('Export all activity')"
:description="t('Downloads every event as a CSV file. Filters on the page are ignored.')"
:dismissible="!preparing"
:close="preparing ? false : CLOSE_BUTTON"
:ui="{
content: MODAL_CONTENT,
close: 'end-6 top-8 md:top-5',
header: 'min-h-0 items-start px-6 pt-5 pb-0 sm:px-6',
title: 'pe-14 text-lg/6 font-semibold md:pe-10',
description: 'mt-1 pe-14 text-sm text-muted text-pretty md:pe-10',
body: 'px-6 pt-5 pb-0 sm:px-6 sm:pt-5 sm:pb-0',
footer: 'px-6 py-5 sm:px-6',
}"
@update:open="onOpenChange"
>
<template #body>
<div class="flex flex-col gap-4">
<template v-if="stage === 'configure'">
<UFormField :label="t('Format')" :ui="{ label: FIELD_LABEL_CLASS }">
<UInput
model-value="CSV"
readonly
:aria-label="t('Format')"
class="w-full"
:ui="{
base: 'h-8 rounded-none border border-accented bg-default text-sm text-highlighted ring-0',
}"
/>
</UFormField>
<UFormField
:label="t('Date range')"
:help="t('Default is all time. Filters on the page are ignored.')"
:ui="{ label: FIELD_LABEL_CLASS, help: 'mt-1.5 text-xs text-muted' }"
>
<USelect
v-model="range"
:items="rangeItems"
value-key="value"
icon="i-lucide-calendar"
color="neutral"
variant="outline"
:aria-label="t('Date range')"
class="w-full"
:ui="{
base: 'h-11 rounded-none border border-accented bg-default ps-8 pe-8 text-sm ring-0 md:h-8',
leadingIcon: 'size-3.5 text-muted',
trailingIcon: 'size-3.5 text-muted',
}"
/>
</UFormField>
<div v-if="showCustomFields" class="flex items-center gap-2">
<UInput
v-model="from"
type="date"
icon="i-lucide-calendar"
:aria-label="t('Start date')"
class="min-w-0 flex-1"
:ui="{ base: 'h-11 w-full min-w-0 text-lg md:h-8 md:text-sm' }"
/>
<UInput
v-model="to"
type="date"
icon="i-lucide-calendar"
:aria-label="t('End date')"
class="min-w-0 flex-1"
:ui="{ base: 'h-11 w-full min-w-0 text-lg md:h-8 md:text-sm' }"
/>
</div>
</template>
<div v-else-if="preparing" class="flex flex-col gap-3">
<span class="flex items-center gap-2 font-medium text-highlighted">
<UIcon name="i-lucide-loader-circle" class="size-4 animate-spin" />
{{ t('Preparing download…') }}
</span>
<UProgress size="xs" color="primary" />
<span class="text-muted text-pretty">
{{
t(
'The file starts downloading as soon as it is ready. Keep this window open.',
)
}}
</span>
</div>
<div v-else-if="stage === 'started'" class="flex items-start gap-3">
<UIcon
name="i-lucide-circle-check"
class="mt-0.5 size-5 shrink-0 text-success"
/>
<div class="flex flex-col gap-0.5">
<span class="text-md font-semibold text-highlighted">{{
t('Download started')
}}</span>
<span class="text-muted">{{
t(
"{0} is downloading. Check your browser's downloads if nothing appears.",
[fileName],
)
}}</span>
</div>
</div>
<UAlert
v-else-if="stage === 'failed'"
role="alert"
color="error"
variant="soft"
icon="i-lucide-circle-x"
:close="false"
:title="t('Couldn\'t prepare the download')"
:description="t('Nothing was downloaded. Try again in a moment.')"
/>
<UAlert
v-else-if="stage === 'denied'"
role="alert"
color="error"
variant="soft"
icon="i-lucide-lock"
:close="false"
:title="t('You can\'t export all activity')"
:description="
t(
'Only owners and admins can export all activity. Ask an owner if you need a copy.',
)
"
/>
<div
v-else-if="stage === 'empty'"
class="flex flex-col items-center gap-2.5 py-3 text-center"
>
<span class="flex size-11 items-center justify-center bg-elevated text-toned">
<UIcon name="i-lucide-file-x" class="size-5" />
</span>
<span class="text-md font-semibold text-highlighted">{{
t('Nothing to export')
}}</span>
<span class="max-w-75 text-muted text-pretty">{{
t('There is no activity in this date range. Choose a wider range.')
}}</span>
</div>
</div>
</template>
<template #footer>
<div class="flex w-full flex-col-reverse gap-2 md:flex-row md:justify-end">
<template v-if="stage === 'configure' || preparing || stage === 'failed'">
<UButton
color="neutral"
variant="outline"
block
:class="[OUTLINE_CLASS, 'md:w-auto']"
@click="emit('close')"
>
{{ t('Cancel') }}
</UButton>
<UButton
v-if="stage === 'configure'"
icon="i-lucide-download"
block
:class="[BUTTON_CLASS, 'md:w-auto']"
:disabled="!canSubmit"
@click="emit('submit')"
>
{{ t('Export CSV') }}
</UButton>
<UButton
v-else-if="preparing"
block
disabled
:class="[BUTTON_CLASS, 'md:w-auto']"
>
{{ t('Preparing…') }}
</UButton>
<UButton
v-else
icon="i-lucide-rotate-cw"
block
:class="[BUTTON_CLASS, 'md:w-auto']"
@click="emit('submit')"
>
{{ t('Retry') }}
</UButton>
</template>
<UButton
v-else
color="neutral"
variant="outline"
block
:class="[OUTLINE_CLASS, 'md:w-auto']"
@click="emit('close')"
>
{{ t('Close') }}
</UButton>
</div>
</template>
</UModal>
</template>
+157
View File
@@ -0,0 +1,157 @@
<script setup lang="ts">
import { formatNumber, t } from '@/i18n'
import { NOTHING_TO_EXPORT_COPY, OWNERS_ONLY_COPY } from '@/lib/activity'
import { computed, ref } from 'vue'
const props = defineProps<{
total: number
isDesktop: boolean
disabled: boolean
nothingToExport: boolean
forbidden: boolean
}>()
const emit = defineEmits<{
current: []
all: []
}>()
const open = ref(false)
const BUTTON_CLASS =
'h-11 border border-accented bg-default px-3 text-sm text-highlighted ring-0 data-[state=open]:bg-elevated md:h-8'
const ITEM_HINT_CLASS = 'text-xs/4.5 text-muted text-pretty'
const currentDisabled = computed(() => props.forbidden || props.nothingToExport)
const currentHint = computed(() => {
if (props.forbidden) return t(OWNERS_ONLY_COPY)
if (props.nothingToExport) return t(NOTHING_TO_EXPORT_COPY)
return props.total === 1
? t('CSV · 1 event matching your filters and sort, across all pages')
: t('CSV · {0} events matching your filters and sort, across all pages', [
formatNumber(props.total),
])
})
const allHint = computed(() =>
props.forbidden
? t(OWNERS_ONLY_COPY)
: t('CSV · every event, ignoring filters. Owners and admins only.'),
)
const trailingIcon = computed(() => (open.value ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'))
const entries = computed(() => [
{
action: 'current' as const,
label: t('Export current view'),
description: currentHint.value,
icon: 'i-lucide-download',
disabled: currentDisabled.value,
},
{
action: 'all' as const,
label: t('Export all activity…'),
description: allHint.value,
icon: props.forbidden ? 'i-lucide-lock' : 'i-lucide-archive',
disabled: props.forbidden,
},
])
const items = computed(() =>
entries.value.map(({ action, label, description, icon, disabled }) => [
{ label, description, icon, disabled, onSelect: () => choose(action) },
]),
)
function choose(action: 'current' | 'all') {
open.value = false
if (action === 'current') emit('current')
else emit('all')
}
</script>
<template>
<UDropdownMenu
v-if="isDesktop"
v-model:open="open"
:items="items"
:content="{ align: 'end', sideOffset: 4 }"
:ui="{
content: 'w-90 rounded-none p-1 shadow-(--rc-shadow-pop)',
group: 'p-0',
separator: 'my-0',
item: 'min-h-14 items-start gap-3 rounded-none px-3 py-2.5 text-sm data-disabled:opacity-100 data-disabled:text-muted',
itemLeadingIcon: 'mt-0.5 size-4 text-highlighted group-data-disabled:text-muted',
itemLabel: 'font-medium',
itemDescription: 'text-xs/4.5 text-muted text-pretty whitespace-normal',
}"
>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-download"
:trailing-icon="trailingIcon"
:disabled="disabled"
:class="BUTTON_CLASS"
:ui="{ trailingIcon: 'size-3.5 text-muted' }"
>
{{ t('Export') }}
</UButton>
</UDropdownMenu>
<template v-else>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-download"
:trailing-icon="trailingIcon"
:disabled="disabled"
:class="BUTTON_CLASS"
:ui="{ trailingIcon: 'size-3.5 text-muted' }"
@click="open = true"
>
{{ t('Export') }}
</UButton>
<USlideover
v-model:open="open"
side="bottom"
:title="t('Export')"
:description="t('Export')"
:ui="{
content:
'max-h-none before:absolute before:top-2 before:left-1/2 before:h-1 before:w-9 before:-translate-x-1/2 before:bg-(--ui-border-accented) pt-3',
header: 'h-14 min-h-14 border-b border-default px-4 py-0 sm:px-4',
title: 'text-lg font-semibold text-highlighted',
description: 'sr-only',
close: 'top-4.5 end-2 size-11 justify-center p-0',
body: 'p-0 pb-4 sm:p-0 sm:pb-4',
}"
>
<template #body>
<div role="menu" :aria-label="t('Export')" class="flex flex-col">
<template v-for="(entry, index) in entries" :key="entry.action">
<USeparator v-if="index > 0" />
<UButton
role="menuitem"
color="neutral"
variant="ghost"
block
:icon="entry.icon"
:disabled="entry.disabled"
class="min-h-16 items-start justify-start gap-3 rounded-none px-4 py-3 text-start text-sm text-highlighted disabled:text-muted disabled:opacity-100"
:ui="{ leadingIcon: 'mt-0.5 size-4' }"
@click="choose(entry.action)"
>
<span class="flex min-w-0 flex-col gap-0.5">
<span class="font-medium">{{ entry.label }}</span>
<span :class="[ITEM_HINT_CLASS, 'font-normal']">{{
entry.description
}}</span>
</span>
</UButton>
</template>
</div>
</template>
</USlideover>
</template>
</template>
+73 -11
View File
@@ -1,5 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { t } from '@/i18n'
import { computed, ref } from 'vue'
import type { ActivityFilters, ActivitySummary } from '@/lib/activity'
import {
barHeights,
@@ -41,6 +43,14 @@ const VALUE_TONES: Record<StripCard['tone'], string> = {
warning: 'text-warning',
calm: 'text-highlighted',
}
const PRESSED_TEXT = 'text-(--rc-on-sand)'
const PRESSED_BAR = 'bg-(--rc-on-sand)'
const hovered = ref<StripKey | null>(null)
function isToggle(key: StripKey) {
return key === 'failed' || key === 'security'
}
function pressed(key: StripKey) {
if (key === 'failed') return props.outcome === 'failed'
@@ -49,21 +59,63 @@ function pressed(key: StripKey) {
}
function rootClass(card: StripCard) {
const fill =
card.tone === 'error'
const isPressed = pressed(card.key)
const fill = isPressed
? 'bg-(--rc-sand)'
: card.tone === 'error'
? 'bg-(--rc-error-soft)'
: card.tone === 'warning'
? 'bg-(--rc-warning-soft)'
: 'bg-default'
const edge = pressed(card.key)
const edge = isPressed
? 'border-primary ring-1 ring-primary ring-inset'
: 'border-default ring-0'
: isToggle(card.key)
? 'border-default ring-0 hover:border-accented hover:bg-elevated'
: 'border-default ring-0'
return [
'w-full cursor-pointer border text-left max-md:w-auto max-md:shrink-0 max-md:basis-39 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary',
fill,
edge,
]
}
function labelClass(card: StripCard) {
return pressed(card.key) ? PRESSED_TEXT : 'text-toned'
}
function valueClass(card: StripCard) {
return pressed(card.key) ? PRESSED_TEXT : VALUE_TONES[card.tone]
}
function barClass(card: StripCard) {
return pressed(card.key) ? PRESSED_BAR : BAR_TONES[card.tone]
}
function sublineClass(card: StripCard) {
if (pressed(card.key)) return PRESSED_TEXT
return card.tone === 'calm' ? 'text-success' : 'text-muted'
}
function hint(card: StripCard) {
if (!isToggle(card.key)) return null
if (pressed(card.key)) {
return hovered.value === card.key
? { text: t('Click to clear this filter'), icon: 'i-lucide-x' }
: { text: t('Filtering. Click to clear'), icon: 'i-lucide-filter' }
}
return hovered.value === card.key
? { text: t('Click to filter the list'), icon: 'i-lucide-filter' }
: null
}
function sublineText(card: StripCard) {
return hint(card)?.text ?? card.subline
}
function sublineIcon(card: StripCard) {
const shown = hint(card)
return shown ? shown.icon : card.icon
}
</script>
<template>
@@ -102,11 +154,15 @@ function rootClass(card: StripCard) {
root: 'overflow-visible rounded-none shadow-none md:min-w-0',
body: 'p-0 sm:p-0',
}"
@mouseenter="hovered = card.key"
@mouseleave="hovered = null"
@click="emit('apply-filter', card.key)"
>
<span class="flex flex-col gap-0.5" :class="CARD_PADDING">
<span class="flex items-center justify-between gap-2">
<span class="truncate font-medium text-toned">{{ card.label }}</span>
<span class="truncate font-medium" :class="labelClass(card)">{{
card.label
}}</span>
<span
aria-hidden="true"
data-testid="activity-bars"
@@ -117,22 +173,28 @@ function rootClass(card: StripCard) {
v-for="(height, index) in barHeights(card.series, barHeight)"
:key="index"
class="block w-1"
:class="BAR_TONES[card.tone]"
:class="barClass(card)"
:style="{ height: `${height}px` }"
/>
</span>
</span>
<span
class="text-stat-sm font-semibold md:text-stat"
:class="VALUE_TONES[card.tone]"
:class="valueClass(card)"
>{{ card.value }}</span
>
<span
class="flex items-center gap-1 truncate text-xs/5"
:class="card.tone === 'calm' ? 'text-success' : 'text-muted'"
:class="sublineClass(card)"
>
<UIcon v-if="card.icon" :name="card.icon" class="size-3 shrink-0" />
<span class="truncate">{{ card.subline }}</span>
<UIcon
v-if="sublineIcon(card)"
:name="sublineIcon(card)!"
class="size-3 shrink-0"
/>
<span class="truncate" data-testid="activity-strip-subline">{{
sublineText(card)
}}</span>
</span>
</span>
</UCard>
@@ -239,3 +239,30 @@ describe('getMe maps the account fields', () => {
expect(me.displayName).toBe('')
})
})
describe('useApi abort and blob handling', () => {
it('rethrows an AbortError without flagging the app offline', async () => {
fetchMock.mockRejectedValue(new DOMException('aborted', 'AbortError'))
await expect(useApi().request('/v1/x')).rejects.toMatchObject({ name: 'AbortError' })
expect(isOffline.value).toBe(false)
})
it('requestBlob returns the response blob', async () => {
const blob = new Blob(['a,b'])
fetchMock.mockResolvedValue({ ...mockResponse(200), blob: async () => blob })
await expect(useApi().requestBlob('/v1/activity/export')).resolves.toBe(blob)
})
it('requestBlob reuses the 401 handling and redirects to login', async () => {
fetchMock.mockResolvedValue(mockResponse(401))
await expectApiError(useApi().requestBlob('/v1/activity/export'), { status: 401 })
await settle()
expect(router.currentRoute.value.path).toBe('/login')
expect(router.currentRoute.value.query.reason).toBe('expired')
})
})
+19 -2
View File
@@ -1,6 +1,8 @@
import { useApi } from '@/composables/useApi'
import {
exportAllQuery,
exportQuery,
type ExportRange,
listQuery,
summaryQuery,
type ActivityFilters,
@@ -17,7 +19,6 @@ export interface PersonOptions {
export const ACTIVITY_SKELETON_ROWS = 9
export const ACTIVITY_SKELETON_CARDS = 5
export const EXPORT_FILE_NAME = 'activity.csv'
export function useActivity() {
const { request, requestBlob } = useApi()
@@ -43,6 +44,15 @@ export function useActivity() {
return requestBlob(`/v1/activity/export?${exportQuery(filters)}`)
}
function exportAllActivity(
range: ExportRange,
from: string,
to: string,
signal: AbortSignal,
): Promise<Blob> {
return requestBlob(`/v1/activity/export?${exportAllQuery(range, from, to)}`, { signal })
}
async function listPeople(): Promise<PersonOptions> {
const [users, keys] = await Promise.all([
request<UserListItem[]>('/v1/user/'),
@@ -58,7 +68,14 @@ export function useActivity() {
}
}
return { listActivity, listRecentActivity, getSummary, exportActivity, listPeople }
return {
listActivity,
listRecentActivity,
getSummary,
exportActivity,
exportAllActivity,
listPeople,
}
}
export function downloadBlob(blob: Blob, fileName: string) {
+201
View File
@@ -0,0 +1,201 @@
import { computed, onBeforeUnmount, ref, toValue, type MaybeRefOrGetter } from 'vue'
import { t } from '@/i18n'
import { useLocalizedToast } from '@/i18n/presentation'
import { ApiError, isAbort } from '@/composables/useApi'
import { downloadBlob, useActivity } from '@/composables/useActivity'
import { useSessionStore } from '@/stores/session'
import {
allActivityFileName,
currentViewFileName,
isExportRangeValid,
isHeaderOnlyCsv,
readBlobText,
type ActivityFilters,
NOTHING_TO_EXPORT_COPY,
OWNERS_ONLY_COPY,
type ExportRange,
} from '@/lib/activity'
export type ExportStage = 'configure' | 'preparing' | 'started' | 'failed' | 'empty' | 'denied'
const HTTP_UNAUTHORIZED = 401
const HTTP_FORBIDDEN = 403
const HTTP_BAD_REQUEST = 400
const SERVER_ERROR_COPY =
'Something went wrong on our side. Nothing was changed. Try again in a moment.'
const BAD_REQUEST_COPY = "That request wasn't valid. Check the fields and try again."
export function useActivityExport(filters: MaybeRefOrGetter<ActivityFilters>) {
const session = useSessionStore()
const toast = useLocalizedToast()
const { exportActivity, exportAllActivity } = useActivity()
const forbidden = ref(false)
const currentBusy = ref(false)
const dialogOpen = ref(false)
const stage = ref<ExportStage>('configure')
const range = ref<ExportRange>('all')
const from = ref('')
const to = ref('')
const startedFileName = ref('')
let controller: AbortController | null = null
const canSubmit = computed(
() => stage.value === 'configure' && isExportRangeValid(range.value, from.value, to.value),
)
function notify(
color: 'success' | 'error' | 'neutral',
icon: string,
title: () => string,
description?: () => string,
) {
toast.add(() => ({
color,
icon,
get title() {
return title()
},
get description() {
return description?.()
},
}))
}
function notifyStarted(fileName: string) {
notify(
'success',
'i-lucide-circle-check',
() => t('Download started'),
() => t('{0} is downloading.', [fileName]),
)
}
function isGlobalFailure(error: unknown) {
if (!(error instanceof ApiError)) return false
if (error.status === HTTP_UNAUTHORIZED) return true
return error.status === HTTP_FORBIDDEN && session.session?.verified === false
}
async function hasRows(blob: Blob) {
return !isHeaderOnlyCsv(await readBlobText(blob))
}
async function exportCurrentView() {
if (currentBusy.value) return
currentBusy.value = true
try {
const blob = await exportActivity(toValue(filters))
if (!(await hasRows(blob))) {
notify(
'neutral',
'i-lucide-file-x',
() => t('Nothing to export'),
() => t(NOTHING_TO_EXPORT_COPY),
)
return
}
const fileName = currentViewFileName()
downloadBlob(blob, fileName)
notifyStarted(fileName)
} catch (error) {
failCurrentView(error)
} finally {
currentBusy.value = false
}
}
function failCurrentView(error: unknown) {
if (isGlobalFailure(error)) return
if (error instanceof ApiError && error.status === HTTP_FORBIDDEN) {
forbidden.value = true
notify(
'error',
'i-lucide-lock',
() => t("You can't export all activity"),
() => t(OWNERS_ONLY_COPY),
)
return
}
const copy =
error instanceof ApiError && error.status === HTTP_BAD_REQUEST
? BAD_REQUEST_COPY
: SERVER_ERROR_COPY
notify(
'error',
'i-lucide-circle-x',
() => t("Couldn't prepare the download"),
() => t(copy),
)
}
function openDialog() {
stage.value = 'configure'
range.value = 'all'
from.value = ''
to.value = ''
dialogOpen.value = true
}
async function submit() {
if (stage.value !== 'configure' && stage.value !== 'failed') return
if (!isExportRangeValid(range.value, from.value, to.value)) return
stage.value = 'preparing'
const current = new AbortController()
controller = current
try {
const blob = await exportAllActivity(range.value, from.value, to.value, current.signal)
if (current.signal.aborted) return
if (!(await hasRows(blob))) {
stage.value = 'empty'
return
}
startedFileName.value = allActivityFileName()
downloadBlob(blob, startedFileName.value)
stage.value = 'started'
} catch (error) {
if (current.signal.aborted || isAbort(error)) return
failDialog(error)
} finally {
if (controller === current) controller = null
}
}
function failDialog(error: unknown) {
if (isGlobalFailure(error)) {
dialogOpen.value = false
return
}
if (error instanceof ApiError && error.status === HTTP_FORBIDDEN) {
forbidden.value = true
stage.value = 'denied'
return
}
stage.value = 'failed'
}
function closeDialog() {
controller?.abort()
controller = null
if (stage.value === 'started') notifyStarted(startedFileName.value)
dialogOpen.value = false
}
onBeforeUnmount(() => controller?.abort())
return {
forbidden,
currentBusy,
dialogOpen,
stage,
range,
from,
to,
startedFileName,
canSubmit,
exportCurrentView,
openDialog,
submit,
closeDialog,
}
}
+14 -45
View File
@@ -1,13 +1,6 @@
import { computed, onBeforeUnmount, ref } from 'vue'
import { t } from '@/i18n'
import { useLocalizedToast } from '@/i18n/presentation'
import { ApiError } from '@/composables/useApi'
import {
downloadBlob,
EXPORT_FILE_NAME,
useActivity,
type PersonOptions,
} from '@/composables/useActivity'
import { useActivity, type PersonOptions } from '@/composables/useActivity'
import { useSessionStore } from '@/stores/session'
import {
activeChips,
@@ -52,8 +45,7 @@ function delayedFlag() {
export function useActivityPage() {
const session = useSessionStore()
const toast = useLocalizedToast()
const { listActivity, getSummary, exportActivity, listPeople } = useActivity()
const { listActivity, getSummary, listPeople } = useActivity()
const filters = ref<ActivityFilters>(defaultFilters())
const page = ref(1)
@@ -64,7 +56,6 @@ export function useActivityPage() {
const summary = ref<ActivitySummary | null>(null)
const summaryState = ref<SummaryState>('idle')
const people = ref<PersonOptions | null>(null)
const exporting = ref(false)
const selectedId = ref<number | null>(null)
const navigating = ref(false)
const listSkeleton = delayedFlag()
@@ -85,10 +76,7 @@ export function useActivityPage() {
)
const isEmpty = computed(() => listState.value === 'ready' && total.value === 0)
const exportDisabled = computed(
() =>
exporting.value ||
listState.value === 'error' ||
(isEmpty.value && chips.value.length === 0),
() => listState.value === 'error' || (isEmpty.value && chips.value.length === 0),
)
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / size.value)))
const rangeStart = computed(() => (total.value === 0 ? 0 : (page.value - 1) * size.value + 1))
@@ -190,6 +178,15 @@ export function useActivityPage() {
applyFilters(defaultFilters(), true)
}
function toggleFailed() {
const outcome = filters.value.outcome === 'failed' ? 'all' : 'failed'
applyFilters({ ...filters.value, outcome })
}
function toggleSecurity() {
applyFilters({ ...filters.value, security: !filters.value.security })
}
function goToPage(next: number) {
if (next === page.value || next < 1 || next > pageCount.value) return
fetchPage(next)
@@ -215,34 +212,6 @@ export function useActivityPage() {
}
}
async function runExport() {
if (exportDisabled.value) return
exporting.value = true
try {
const blob = await exportActivity(filters.value)
downloadBlob(blob, EXPORT_FILE_NAME)
toast.add(() => ({
color: 'success',
icon: 'i-lucide-circle-check',
get title() {
return t('Export started. Your file will download shortly.')
},
}))
} catch (error) {
if (!(error instanceof ApiError && error.status === 401)) {
toast.add(() => ({
color: 'error',
icon: 'i-lucide-circle-x',
get title() {
return t("Couldn't export activity. Try again.")
},
}))
}
} finally {
exporting.value = false
}
}
function openDetails(id: number) {
selectedId.value = id
}
@@ -284,7 +253,6 @@ export function useActivityPage() {
summary,
summaryState,
people,
exporting,
chips,
isFullView,
isEmpty,
@@ -303,11 +271,12 @@ export function useActivityPage() {
applyFilters,
removeChip,
clearAll,
toggleFailed,
toggleSecurity,
goToPage,
setSize,
retry,
ensurePeople,
runExport,
openDetails,
closeDetails,
previous: () => step(-1),
+8 -1
View File
@@ -40,6 +40,10 @@ export interface RequestOptions extends RequestInit {
onForbidden?: OnForbidden
}
export function isAbort(error: unknown) {
return error instanceof DOMException && error.name === 'AbortError'
}
export const isOffline = ref(false)
export const authRedirectPending = ref(false)
@@ -107,7 +111,10 @@ export function useApi() {
let response: Response
try {
response = await fetch(path, { credentials: 'include', ...init })
} catch {
} catch (error) {
if (isAbort(error)) {
throw error
}
isOffline.value = true
throw new NetworkError()
}
+46 -4
View File
@@ -1016,11 +1016,53 @@ export const messages = {
'My activity': 'Mon activité',
'Who did what, and when, across your site.': 'Qui a fait quoi, et quand, sur votre site.',
"Everything you've done on this site.": 'Tout ce que vous avez fait sur ce site.',
Export: 'Exporter',
'Export CSV': 'Exporter en CSV',
'Preparing file…': 'Préparation du fichier…',
'Export started. Your file will download shortly.':
'Export lancé. Votre fichier va se télécharger sous peu.',
"Couldn't export activity. Try again.": "Impossible d'exporter l'activité. Réessayez.",
'Export current view': 'Exporter la vue actuelle',
'Export all activity…': 'Exporter toute l’activité…',
'CSV · {0} events matching your filters and sort, across all pages':
'CSV · {0} événements correspondant à vos filtres et à votre tri, sur toutes les pages',
'CSV · 1 event matching your filters and sort, across all pages':
'CSV · 1 événement correspondant à vos filtres et à votre tri, sur toutes les pages',
'CSV · every event, ignoring filters. Owners and admins only.':
'CSV · tous les événements, sans tenir compte des filtres. Réservé aux propriétaires et aux admins.',
'Nothing to export. No events match these filters.':
'Rien à exporter. Aucun événement ne correspond à ces filtres.',
'Only owners and admins can export all activity.':
'Seuls les propriétaires et les admins peuvent exporter toute l’activité.',
'Export all activity': 'Exporter toute l’activité',
'Downloads every event as a CSV file. Filters on the page are ignored.':
'Télécharge tous les événements dans un fichier CSV. Les filtres de la page sont ignorés.',
CSV: 'CSV',
'Date range': 'Période',
'All time': 'Toute la période',
'Default is all time. Filters on the page are ignored.':
'Par défaut, toute la période. Les filtres de la page sont ignorés.',
'Preparing download…': 'Préparation du téléchargement…',
'The file starts downloading as soon as it is ready. Keep this window open.':
'Le fichier se télécharge dès qu’il est prêt. Gardez cette fenêtre ouverte.',
'Preparing…': 'Préparation…',
'Download started': 'Téléchargement lancé',
"{0} is downloading. Check your browser's downloads if nothing appears.":
'{0} est en cours de téléchargement. Consultez les téléchargements de votre navigateur si rien n’apparaît.',
'{0} is downloading.': '{0} est en cours de téléchargement.',
"Couldn't prepare the download": 'Impossible de préparer le téléchargement',
'Nothing was downloaded. Try again in a moment.':
'Rien n’a été téléchargé. Réessayez dans un instant.',
'Nothing to export': 'Rien à exporter',
'There is no activity in this date range. Choose a wider range.':
'Il n’y a aucune activité sur cette période. Choisissez une période plus large.',
"You can't export all activity": 'Vous ne pouvez pas exporter toute l’activité',
'Only owners and admins can export all activity. Ask an owner if you need a copy.':
'Seuls les propriétaires et les admins peuvent exporter toute l’activité. Demandez à un propriétaire si vous avez besoin d’une copie.',
'Something went wrong on our side. Nothing was changed. Try again in a moment.':
'Un problème est survenu de notre côté. Rien n’a été modifié. Réessayez dans un instant.',
"That request wasn't valid. Check the fields and try again.":
'Cette requête n’était pas valide. Vérifiez les champs et réessayez.',
'Type: {0}': 'Type : {0}',
'Click to filter the list': 'Cliquez pour filtrer la liste',
'Filtering. Click to clear': 'Filtre actif. Cliquez pour l’effacer',
'Click to clear this filter': 'Cliquez pour effacer ce filtre',
'Total actions': 'Actions au total',
'Active people': 'Personnes actives',
'Failed actions': 'Actions en échec',
+1 -1
View File
@@ -131,7 +131,7 @@ describe('Chips', () => {
'Person: Marta Alves',
'Category: Authentication',
'Outcome: Failed',
'Security events',
'Type: Security events',
'Dates: 30 days',
])
})
+62 -12
View File
@@ -110,6 +110,15 @@ export function summaryRangeChanged(a: ActivityFilters, b: ActivityFilters) {
return a.range !== b.range || a.from !== b.from || a.to !== b.to
}
function rangeParams(range: ExportRange, from: string, to: string) {
const params = new URLSearchParams({ range })
if (range === 'custom') {
params.set('from', from)
params.set('to', to)
}
return params
}
function filterParams(filters: ActivityFilters) {
const params = new URLSearchParams()
if (filters.q) params.set('q', filters.q)
@@ -117,11 +126,9 @@ function filterParams(filters: ActivityFilters) {
if (filters.category) params.set('category', filters.category)
if (filters.outcome !== 'all') params.set('outcome', filters.outcome)
if (filters.security) params.set('security', 'true')
params.set('range', filters.range)
if (filters.range === 'custom') {
params.set('from', filters.from)
params.set('to', filters.to)
}
rangeParams(filters.range, filters.from, filters.to).forEach((value, key) =>
params.set(key, value),
)
return params
}
@@ -136,13 +143,55 @@ export function exportQuery(filters: ActivityFilters) {
return filterParams(filters).toString()
}
export const OWNERS_ONLY_COPY = 'Only owners and admins can export all activity.'
export const NOTHING_TO_EXPORT_COPY = 'Nothing to export. No events match these filters.'
export type ExportRange = 'all' | RangeKey
export const EXPORT_RANGE_KEYS: ExportRange[] = ['all', 'today', '7d', '30d', 'custom']
export function exportRangeLabel(range: ExportRange) {
return range === 'all' ? t('All time') : rangeLabel(range)
}
export function exportAllQuery(range: ExportRange, from: string, to: string) {
return rangeParams(range, from, to).toString()
}
export function isExportRangeValid(range: ExportRange, from: string, to: string) {
return range !== 'custom' || (from !== '' && to !== '' && from <= to)
}
function localDateStamp() {
const now = new Date()
const month = String(now.getMonth() + 1).padStart(2, '0')
const day = String(now.getDate()).padStart(2, '0')
return `${now.getFullYear()}-${month}-${day}`
}
export function currentViewFileName() {
return `activity-${localDateStamp()}.csv`
}
export function allActivityFileName() {
return `activity-all-${localDateStamp()}.csv`
}
export function readBlobText(blob: Blob) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result))
reader.onerror = () => reject(reader.error)
reader.readAsText(blob)
})
}
export function isHeaderOnlyCsv(text: string) {
return text.trim().split(/\r?\n/).length <= 1
}
export function summaryQuery(filters: ActivityFilters) {
const params = new URLSearchParams({ range: filters.range })
if (filters.range === 'custom') {
params.set('from', filters.from)
params.set('to', filters.to)
}
return params.toString()
return rangeParams(filters.range, filters.from, filters.to).toString()
}
export function categoryLabel(category: ActivityCategory) {
@@ -231,7 +280,8 @@ export function activeChips(filters: ActivityFilters): ActivityChip[] {
key: 'outcome',
label: t('Outcome: {0}', [outcomeFilterLabel(filters.outcome)]),
})
if (filters.security) chips.push({ key: 'security', label: t('Security events') })
if (filters.security)
chips.push({ key: 'security', label: t('Type: {0}', [t('Security events')]) })
if (filters.range !== DEFAULT_RANGE) {
const value =
filters.range === 'custom'
+30 -15
View File
@@ -8,15 +8,18 @@ import ActivityCardList from '@/components/ActivityCardList.vue'
import ActivityChips from '@/components/ActivityChips.vue'
import ActivityDetailsPanel from '@/components/ActivityDetailsPanel.vue'
import ActivityEmptyPanel from '@/components/ActivityEmptyPanel.vue'
import ActivityExportDialog from '@/components/ActivityExportDialog.vue'
import ActivityExportMenu from '@/components/ActivityExportMenu.vue'
import ActivityFilterBar from '@/components/ActivityFilterBar.vue'
import ActivityFiltersSheet from '@/components/ActivityFiltersSheet.vue'
import ActivityPager from '@/components/ActivityPager.vue'
import ActivitySummaryStrip from '@/components/ActivitySummaryStrip.vue'
import ActivityTable from '@/components/ActivityTable.vue'
import DashboardShell from '@/components/DashboardShell.vue'
import { useActivityExport } from '@/composables/useActivityExport'
import { useActivityPage } from '@/composables/useActivityPage'
import { useSessionStore } from '@/stores/session'
import { defaultFilters, type ActivityFilters } from '@/lib/activity'
import type { ActivityFilters } from '@/lib/activity'
import type { StripKey } from '@/lib/activitySummary'
const DESKTOP_QUERY = '(min-width: 48rem)'
@@ -38,7 +41,6 @@ const {
chips,
isFullView,
isEmpty,
exporting,
exportDisabled,
showListSkeleton,
showSummarySkeleton,
@@ -49,6 +51,7 @@ const {
canNext,
} = activity
const exportFlow = useActivityExport(filters)
const sheetOpen = ref(false)
let started = false
let opener: HTMLElement | null = null
@@ -95,9 +98,9 @@ function onFilters(next: ActivityFilters) {
}
function onStripCard(key: StripKey) {
if (key === 'total') activity.applyFilters(defaultFilters(), true)
else if (key === 'failed') activity.applyFilters({ ...filters.value, outcome: 'failed' })
else if (key === 'security') activity.applyFilters({ ...filters.value, security: true })
if (key === 'total') activity.clearAll()
else if (key === 'failed') activity.toggleFailed()
else if (key === 'security') activity.toggleSecurity()
}
function onOpen(item: { id: number }, element: HTMLElement) {
@@ -135,17 +138,16 @@ function goHome() {
<h1 class="text-xl font-semibold text-highlighted">{{ title }}</h1>
<p class="text-muted">{{ subtitle }}</p>
</div>
<UButton
<ActivityExportMenu
v-if="isFullView && !forbidden"
color="neutral"
variant="outline"
icon="i-lucide-download"
:disabled="exportDisabled"
class="h-11 border border-accented bg-default px-3 text-sm text-highlighted ring-0 md:h-8"
@click="activity.runExport"
>
{{ exporting ? t('Preparing file…') : t('Export CSV') }}
</UButton>
:total="total"
:is-desktop="isDesktop"
:disabled="exportDisabled || exportFlow.currentBusy.value"
:nothing-to-export="isEmpty"
:forbidden="exportFlow.forbidden.value"
@current="exportFlow.exportCurrentView"
@all="exportFlow.openDialog"
/>
</div>
<ActivityEmptyPanel
@@ -268,6 +270,19 @@ function goHome() {
</template>
</div>
<ActivityExportDialog
v-if="isFullView && !forbidden"
v-model:open="exportFlow.dialogOpen.value"
v-model:range="exportFlow.range.value"
v-model:from="exportFlow.from.value"
v-model:to="exportFlow.to.value"
:stage="exportFlow.stage.value"
:can-submit="exportFlow.canSubmit.value"
:file-name="exportFlow.startedFileName.value"
@submit="exportFlow.submit"
@close="exportFlow.closeDialog"
/>
<ActivityDetailsPanel
:item="selectedItem"
:position="position"
@@ -0,0 +1,324 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import router from '@/router'
import { useApi } from '@/composables/useApi'
import { initializeLocale, setLocale } from '@/i18n'
import ActivityCategorySelect from '@/components/ActivityCategorySelect.vue'
import {
LIST_PATH,
SUMMARY_PATH,
callsTo,
chipTexts,
click,
clickButton,
findButton,
harness,
lastListQuery,
mountActivity,
queryObject,
stripCards,
textOf,
unmountActivity,
} from './activityHarness'
import { makeItem, pageOf } from './activityFixtures'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return { ...actual, useToast: () => ({ add: vi.fn() }) }
})
const DEFAULT_QUERY = { range: '7d', page: '1', size: '25' }
const FAILED = 2
const SECURITY = 3
const FAILED_CHIP = 'Outcome: Failed'
const SECURITY_CHIP = 'Type: Security events'
beforeEach(() => {
localStorage.clear()
initializeLocale()
})
afterEach(async () => {
unmountActivity()
vi.mocked(useApi).mockReset()
await router.push('/login')
})
function card(index: number) {
return stripCards()[index]!
}
function pressed(index: number) {
return card(index).getAttribute('aria-pressed')
}
function segmentSelection() {
const group = document.body.querySelector('[role="group"][aria-label="Outcome"]')!
return textOf(group.querySelector('[aria-pressed="true"]'))
}
async function clickSegment(text: string) {
const group = document.body.querySelector('[role="group"][aria-label="Outcome"]')!
await click(
Array.from(group.querySelectorAll('button')).find((button) => textOf(button) === text),
)
}
async function hover(element: Element) {
element.dispatchEvent(new MouseEvent('mouseenter', { bubbles: false }))
await flushPromises()
}
async function leave(element: Element) {
element.dispatchEvent(new MouseEvent('mouseleave', { bubbles: false }))
await flushPromises()
}
function hintOf(index: number) {
return textOf(card(index).querySelector('[data-testid="activity-strip-subline"]'))
}
describe('Cards: the toggle cycle keeps card, chip, segment and request in sync', () => {
it.each([
['Failed actions', FAILED, FAILED_CHIP, 'Failed', { outcome: 'failed' }],
['Security events', SECURITY, SECURITY_CHIP, 'All', { security: 'true' }],
])('%s', async (_, index, chip, segment, query) => {
await mountActivity()
const listCalls = callsTo(LIST_PATH).length
await click(card(index))
expect(pressed(index)).toBe('true')
expect(chipTexts()).toEqual([chip])
expect(findButton('Clear all')).toBeDefined()
expect(segmentSelection()).toBe(segment)
expect(callsTo(LIST_PATH)).toHaveLength(listCalls + 1)
expect(queryObject(lastListQuery())).toEqual({ ...DEFAULT_QUERY, ...query })
expect(callsTo(SUMMARY_PATH)).toHaveLength(1)
await click(card(index))
expect(pressed(index)).toBe('false')
expect(chipTexts()).toEqual([])
expect(findButton('Clear all')).toBeUndefined()
expect(segmentSelection()).toBe('All')
expect(queryObject(lastListQuery())).toEqual(DEFAULT_QUERY)
expect(callsTo(SUMMARY_PATH)).toHaveLength(1)
})
})
describe('Cards: every way of removing the filter returns all three controls to default', () => {
async function removeChip(label: string) {
await click(document.body.querySelector(`button[aria-label="Remove filter ${label}"]`))
}
it.each([
['Failed actions', FAILED, FAILED_CHIP, 'the x of the chip', () => removeChip(FAILED_CHIP)],
['Failed actions', FAILED, FAILED_CHIP, 'Clear all', () => clickButton('Clear all')],
['Failed actions', FAILED, FAILED_CHIP, 'All in the segment', () => clickSegment('All')],
[
'Failed actions',
FAILED,
FAILED_CHIP,
'Succeeded in the segment',
() => clickSegment('Succeeded'),
],
[
'Security events',
SECURITY,
SECURITY_CHIP,
'the x of the chip',
() => removeChip(SECURITY_CHIP),
],
['Security events', SECURITY, SECURITY_CHIP, 'Clear all', () => clickButton('Clear all')],
])('%s card removed with %s', async (_, index, chip, __, remove) => {
await mountActivity()
await click(card(index))
expect(chipTexts()).toEqual([chip])
await remove()
expect(pressed(index)).toBe('false')
expect(chipTexts().filter((text) => text === chip)).toEqual([])
if (chip === FAILED_CHIP && __ === 'Succeeded in the segment') {
expect(segmentSelection()).toBe('Succeeded')
expect(chipTexts()).toEqual(['Outcome: Succeeded'])
} else {
expect(segmentSelection()).toBe('All')
expect(findButton('Clear all')).toBeUndefined()
}
})
})
describe('Cards: shared state with the other controls', () => {
it('presses the Failed card when Failed is chosen in the segment', async () => {
await mountActivity()
await clickSegment('Failed')
expect(pressed(FAILED)).toBe('true')
expect(chipTexts()).toEqual([FAILED_CHIP])
})
it('presses both cards together', async () => {
await mountActivity()
await click(card(FAILED))
await click(card(SECURITY))
expect(pressed(FAILED)).toBe('true')
expect(pressed(SECURITY)).toBe('true')
expect(chipTexts()).toEqual([FAILED_CHIP, SECURITY_CHIP])
expect(queryObject(lastListQuery())).toEqual({
...DEFAULT_QUERY,
outcome: 'failed',
security: 'true',
})
})
it('removes only its own chip when the card is unpressed among other filters', async () => {
await mountActivity()
harness.wrapper!.findComponent(ActivityCategorySelect).vm.$emit('select', 'auth')
await flushPromises()
const group = document.body.querySelector('[role="group"][aria-label="Dates"]')!
await click(
Array.from(group.querySelectorAll('button')).find(
(button) => textOf(button) === '30 days',
),
)
await click(card(FAILED))
expect(chipTexts()).toEqual(['Category: Authentication', FAILED_CHIP, 'Dates: 30 days'])
expect(findButton('Clear all')).toBeDefined()
await click(card(FAILED))
expect(chipTexts()).toEqual(['Category: Authentication', 'Dates: 30 days'])
expect(queryObject(lastListQuery())).toEqual({
category: 'auth',
range: '30d',
page: '1',
size: '25',
})
})
it('unpresses the cards from Clear filters in the empty panel', async () => {
await mountActivity({
list: (query) =>
query.get('security') ? pageOf([], { total: 0 }) : pageOf([makeItem({ id: 1 })]),
})
await click(card(SECURITY))
expect(textOf(document.body)).toContain('No activity matches these filters')
await clickButton('Clear filters')
expect(pressed(SECURITY)).toBe('false')
expect(chipTexts()).toEqual([])
})
it('shares the state with the mobile Filters sheet', async () => {
await mountActivity({ desktop: false })
await click(card(FAILED))
expect(textOf(document.body.querySelector('[data-testid="activity-filter-count"]'))).toBe(
'1',
)
await click(
Array.from(document.body.querySelectorAll('button')).find((button) =>
textOf(button).startsWith('Filters'),
),
)
const group = document.body.querySelector('[role="group"][aria-label="Outcome"]')!
expect(textOf(group.querySelector('[aria-pressed="true"]'))).toBe('Failed')
await click(
Array.from(group.querySelectorAll('button')).find((button) => textOf(button) === 'All'),
)
expect(pressed(FAILED)).toBe('false')
})
})
describe('Cards: hint lines, hover and keyboard', () => {
it('words the hint for unpressed, hovered, pressed and pressed-hovered', async () => {
await mountActivity()
const failed = card(FAILED)
expect(hintOf(FAILED)).toBe('0.5% of all actions')
await hover(failed)
expect(hintOf(FAILED)).toBe('Click to filter the list')
expect(failed.className).toContain('hover:bg-elevated')
await leave(failed)
await click(failed)
expect(hintOf(FAILED)).toBe('Filtering. Click to clear')
await hover(failed)
expect(hintOf(FAILED)).toBe('Click to clear this filter')
expect(failed.className).toContain('bg-(--rc-sand)')
expect(failed.className).not.toContain('hover:bg-elevated')
expect(textOf(failed.querySelector('span:last-child'))).toContain(
'Click to clear this filter',
)
})
it('uses the sand tokens for a pressed card and the focus-visible outline', async () => {
await mountActivity()
await click(card(SECURITY))
expect(card(SECURITY).className).toContain('bg-(--rc-sand)')
expect(card(SECURITY).className).toContain('border-primary')
expect(card(SECURITY).querySelector('.text-\\(--rc-on-sand\\)')).not.toBeNull()
expect(card(SECURITY).className).toContain('focus-visible:outline-2')
expect(card(SECURITY).className).toContain('focus-visible:outline-offset-2')
expect(card(SECURITY).className).toContain('focus-visible:outline-primary')
})
it.each([['Enter'], [' ']])('toggles with the %j key on the focused card', async (key) => {
await mountActivity()
const failed = card(FAILED)
failed.focus()
expect(document.activeElement).toBe(failed)
failed.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }))
failed.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }))
await click(failed)
expect(pressed(FAILED)).toBe('true')
})
})
describe('Cards: Total actions and Active people are not toggles', () => {
it('clears everything from Total and sends nothing from Active people', async () => {
await mountActivity()
await click(card(FAILED))
await click(card(0))
expect(pressed(FAILED)).toBe('false')
expect(chipTexts()).toEqual([])
const before = harness.calls.length
await click(card(1))
expect(harness.calls).toHaveLength(before)
expect(card(0).hasAttribute('aria-pressed')).toBe(false)
expect(card(1).hasAttribute('aria-pressed')).toBe(false)
})
})
describe('Cards: French', () => {
it('words the chip and the hints in French', async () => {
setLocale('fr')
await mountActivity()
await click(card(SECURITY))
expect(chipTexts()).toEqual(['Type : Événements de sécurité'])
expect(hintOf(SECURITY)).toBe('Filtre actif. Cliquez pour l’effacer')
await hover(card(SECURITY))
expect(hintOf(SECURITY)).toBe('Cliquez pour effacer ce filtre')
})
})
@@ -0,0 +1,623 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import USelect from '@nuxt/ui/components/Select.vue'
import router from '@/router'
import { ApiError, NetworkError, useApi } from '@/composables/useApi'
import { initializeLocale, setLocale } from '@/i18n'
import { messages } from '@/i18n/messages'
import ActivityCategorySelect from '@/components/ActivityCategorySelect.vue'
import {
CSV_HEADER_ONLY,
EXPORT_PATH,
callsTo,
click,
csvBlob,
dialogButton,
dialogElement,
exportButton,
findButton,
harness,
mountActivity,
menuItem,
menuItems,
openExportMenu,
queryObject,
stripCards,
textOf,
unmountActivity,
} from './activityHarness'
import { makeItem, pageOf } 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[] = []
const CURRENT_FILE = /^activity-\d{4}-\d{2}-\d{2}\.csv$/
const ALL_FILE = /^activity-all-\d{4}-\d{2}-\d{2}\.csv$/
const SERVER_COPY = 'Something went wrong on our side. Nothing was changed. Try again in a moment.'
const BAD_REQUEST_COPY = "That request wasn't valid. Check the fields and try again."
const OWNERS_ONLY = 'Only owners and admins can export all activity.'
const NOTHING_FOR_FILTERS = 'Nothing to export. No events match these filters.'
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
description?: string
}
return {
color: options.color,
icon: options.icon,
title: options.title,
description: options.description,
}
}
function deferred<T>() {
let resolve: (value: T) => void = () => undefined
let reject: (reason: unknown) => void = () => undefined
const promise = new Promise<T>((done, fail) => {
resolve = done
reject = fail
})
return { promise, resolve, reject }
}
async function openDialog() {
await openExportMenu()
await click(menuItem('Export all activity…'))
await vi.waitFor(() => expect(dialogElement()).not.toBeNull())
}
async function submitDialog() {
await click(dialogButton('Export CSV'))
}
const RANGE_VALUES: Record<string, string> = {
'All time': 'all',
Today: 'today',
'7 days': '7d',
'30 days': '30d',
Custom: 'custom',
}
async function chooseRange(label: string) {
harness.wrapper!.findComponent(USelect).vm.$emit('update:modelValue', RANGE_VALUES[label])
await flushPromises()
}
function setDate(index: number, value: string) {
const field = document.body.querySelectorAll<HTMLInputElement>(
'[role="dialog"] input[type="date"]',
)[index]!
field.value = value
field.dispatchEvent(new Event('input', { bubbles: true }))
return flushPromises()
}
function footerLabels() {
return Array.from(
dialogElement()!.querySelectorAll<HTMLButtonElement>('[data-slot="footer"] button'),
).map(textOf)
}
describe('Export menu: structure', () => {
it('renders one outline button with the download icon, a label and a chevron', async () => {
await mountActivity()
const button = exportButton()!
expect(textOf(button)).toBe('Export')
expect(button.disabled).toBe(false)
expect(
button.querySelectorAll('svg').length +
button.querySelectorAll('span[class*="i-"]').length,
).toBeGreaterThanOrEqual(2)
})
it('lists both items with their sub-lines and total 1,284, and Escape closes it', async () => {
await mountActivity({ list: () => pageOf([makeItem({ id: 1 })], { total: 1284 }) })
await openExportMenu()
expect(menuItems().map(textOf)).toEqual([
'Export current view' +
'CSV · 1,284 events matching your filters and sort, across all pages',
'Export all activity…CSV · every event, ignoring filters. Owners and admins only.',
])
expect(menuItems().every((item) => item.getAttribute('aria-disabled') !== 'true')).toBe(
true,
)
document.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', bubbles: true }),
)
await flushPromises()
await vi.waitFor(() => expect(menuItems()).toHaveLength(0))
await vi.waitFor(() => expect(document.activeElement).toBe(exportButton()))
})
it('states the filtered count and the singular form', async () => {
await mountActivity({
list: (query) =>
pageOf([makeItem({ id: 1 })], { total: query.get('outcome') ? 6 : 1284 }),
})
await click(stripCards()[2])
await openExportMenu()
expect(textOf(menuItem('Export current view'))).toContain(
'CSV · 6 events matching your filters and sort, across all pages',
)
})
it('uses the singular for one event', async () => {
await mountActivity({ list: () => pageOf([makeItem({ id: 1 })], { total: 1 }) })
await openExportMenu()
expect(textOf(menuItem('Export current view'))).toContain(
'CSV · 1 event matching your filters and sort, across all pages',
)
})
it('opens from the keyboard with Enter or ArrowDown on the button', async () => {
await mountActivity()
const button = exportButton()!
button.focus()
button.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }),
)
await flushPromises()
await vi.waitFor(() => expect(menuItems()).toHaveLength(2))
})
it('renders a bottom sheet below md', async () => {
await mountActivity({ desktop: false })
await click(exportButton())
await vi.waitFor(() => expect(menuItems()).toHaveLength(2))
const sheet = document.body.querySelector('[role="dialog"]')!
expect(textOf(sheet)).toContain('Export current view')
expect(textOf(sheet)).toContain('Export all activity…')
})
})
describe('Export menu: disabled states', () => {
it('disables the current view with its reason when nothing matches', async () => {
await mountActivity({
list: (query) =>
query.get('security') ? pageOf([], { total: 0 }) : pageOf([makeItem({ id: 1 })]),
})
await click(stripCards()[3])
await openExportMenu()
expect(exportButton()!.disabled).toBe(false)
expect(menuItem('Export current view')!.getAttribute('aria-disabled')).toBe('true')
expect(textOf(menuItem('Export current view'))).toContain(NOTHING_FOR_FILTERS)
expect(menuItem('Export all activity…')!.getAttribute('aria-disabled')).not.toBe('true')
})
it('disables the button for No activity yet and for the Error state', async () => {
await mountActivity({ list: () => pageOf([], { total: 0 }) })
expect(exportButton()!.disabled).toBe(true)
unmountActivity()
await mountActivity({
list: () => {
throw new ApiError(500)
},
})
expect(exportButton()!.disabled).toBe(true)
})
it('shows no Export control for other roles or the Forbidden panel', async () => {
await mountActivity({ role: 'editor' })
expect(exportButton()).toBeUndefined()
expect(callsTo(EXPORT_PATH)).toHaveLength(0)
unmountActivity()
await mountActivity({
list: () => {
throw new ApiError(403)
},
})
expect(exportButton()).toBeUndefined()
expect(dialogElement()).toBeNull()
})
})
describe('Export current view', () => {
it('sends the list filters without page, size or format, downloads and toasts', async () => {
await mountActivity()
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 openExportMenu()
await click(menuItem('Export current view'))
await vi.waitFor(() => expect(clicked).toHaveLength(1))
expect(callsTo(EXPORT_PATH)).toHaveLength(1)
expect(queryObject(callsTo(EXPORT_PATH)[0])).toEqual({ category: 'auth', range: '30d' })
expect(clicked[0]!.download).toMatch(CURRENT_FILE)
expect(URL.createObjectURL).toHaveBeenCalledTimes(1)
expect(toastCall()).toEqual({
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Download started',
description: `${clicked[0]!.download} is downloading.`,
})
expect(menuItems()).toHaveLength(0)
expect(dialogElement()).toBeNull()
})
it.each([
['a 500 response', () => new ApiError(500), SERVER_COPY],
['a network failure', () => new NetworkError(), SERVER_COPY],
['a 400 response', () => new ApiError(400), BAD_REQUEST_COPY],
])('raises an error toast after %s', async (_, failure, body) => {
await mountActivity({
exportFile: () => {
throw failure()
},
})
await openExportMenu()
await click(menuItem('Export current view'))
expect(toastCall()).toEqual({
color: 'error',
icon: 'i-lucide-circle-x',
title: "Couldn't prepare the download",
description: body,
})
expect(clicked).toHaveLength(0)
expect(exportButton()!.disabled).toBe(false)
})
it('reports a lowered role on 403 and disables both items afterwards', async () => {
await mountActivity({
exportFile: () => {
throw new ApiError(403)
},
})
await openExportMenu()
await click(menuItem('Export current view'))
expect(toastCall().title).toBe("You can't export all activity")
expect(toastCall().color).toBe('error')
expect(clicked).toHaveLength(0)
await openExportMenu()
for (const item of menuItems()) {
expect(item.getAttribute('aria-disabled')).toBe('true')
expect(textOf(item)).toContain(OWNERS_ONLY)
}
})
it('downloads nothing and says so when the CSV has only the header row', async () => {
await mountActivity({ exportFile: () => csvBlob(CSV_HEADER_ONLY) })
await openExportMenu()
await click(menuItem('Export current view'))
await vi.waitFor(() => expect(toastAdd).toHaveBeenCalledTimes(1))
expect(clicked).toHaveLength(0)
expect(toastCall().title).toBe('Nothing to export')
})
})
describe('Export all: the dialog', () => {
it('opens configured with a read-only CSV format and no request', async () => {
await mountActivity()
await openDialog()
const dialog = dialogElement()!
expect(textOf(dialog)).toContain('Export all activity')
expect(textOf(dialog)).toContain('Format')
const format = dialog.querySelector<HTMLInputElement>('input[readonly]')!
expect(format.value).toBe('CSV')
expect(textOf(dialog)).toContain('Date range')
expect(textOf(dialog)).toContain('All time')
expect(textOf(dialog)).toContain('Default is all time. Filters on the page are ignored.')
expect(textOf(dialog)).not.toContain('JSON')
expect(footerLabels()).toEqual(['Cancel', 'Export CSV'])
expect(callsTo(EXPORT_PATH)).toHaveLength(0)
})
it.each([
['All time', { range: 'all' }],
['Today', { range: 'today' }],
['7 days', { range: '7d' }],
['30 days', { range: '30d' }],
])('sends only the dialog range for %s and ignores the page filters', async (label, query) => {
await mountActivity()
harness.wrapper!.findComponent(ActivityCategorySelect).vm.$emit('select', 'media')
await flushPromises()
await click(stripCards()[2])
await openDialog()
if (label !== 'All time') await chooseRange(label)
await submitDialog()
expect(callsTo(EXPORT_PATH)).toHaveLength(1)
expect(queryObject(callsTo(EXPORT_PATH)[0])).toEqual(query)
expect(callsTo(EXPORT_PATH)[0]!.has('format')).toBe(false)
await vi.waitFor(() => expect(clicked).toHaveLength(1))
})
it('sends the custom pair and keeps Export CSV disabled until it is valid', async () => {
await mountActivity()
await openDialog()
await chooseRange('Custom')
expect(dialogButton('Export CSV')!.disabled).toBe(true)
await setDate(0, '2026-09-30')
expect(dialogButton('Export CSV')!.disabled).toBe(true)
await setDate(1, '2026-09-01')
expect(dialogButton('Export CSV')!.disabled).toBe(true)
await submitDialog()
expect(callsTo(EXPORT_PATH)).toHaveLength(0)
await setDate(0, '2026-09-01')
await setDate(1, '2026-09-30')
expect(dialogButton('Export CSV')!.disabled).toBe(false)
await submitDialog()
expect(queryObject(callsTo(EXPORT_PATH)[0])).toEqual({
range: 'custom',
from: '2026-09-01',
to: '2026-09-30',
})
await vi.waitFor(() => expect(clicked).toHaveLength(1))
})
it('shows Preparing with an indeterminate bar, then Download started and a toast on Close', async () => {
const pending = deferred<Blob>()
await mountActivity({ exportFile: () => pending.promise })
await openDialog()
await submitDialog()
const dialog = dialogElement()!
expect(textOf(dialog)).toContain('Preparing download…')
expect(textOf(dialog)).toContain('Keep this window open.')
const bar = dialog.querySelector('[role="progressbar"]')!
expect(bar.hasAttribute('aria-valuenow')).toBe(false)
expect(dialogButton('Cancel')!.disabled).toBe(false)
expect(dialogButton('Preparing…')!.disabled).toBe(true)
expect(clicked).toHaveLength(0)
pending.resolve(csvBlob('when,actor\nrow,row\n'))
await vi.waitFor(() => expect(clicked).toHaveLength(1))
expect(clicked).toHaveLength(1)
expect(clicked[0]!.download).toMatch(ALL_FILE)
expect(textOf(dialogElement())).toContain('Download started')
expect(textOf(dialogElement())).toContain(
`${clicked[0]!.download} is downloading. Check your browser's downloads if nothing appears.`,
)
expect(footerLabels()).toEqual(['Close'])
expect(toastAdd).not.toHaveBeenCalled()
await click(dialogButton('Close'))
await vi.waitFor(() => expect(dialogElement()).toBeNull())
expect(toastCall()).toMatchObject({
color: 'success',
title: 'Download started',
description: `${clicked[0]!.download} is downloading.`,
})
})
it('aborts the request on Cancel while preparing, with no download and no toast', async () => {
await mountActivity({ exportFile: () => new Promise<Blob>(() => undefined) })
await openDialog()
await submitDialog()
const signal = harness.signals[0]!
expect(signal.aborted).toBe(false)
await click(dialogButton('Cancel'))
expect(signal.aborted).toBe(true)
await vi.waitFor(() => expect(dialogElement()).toBeNull())
expect(clicked).toHaveLength(0)
expect(toastAdd).not.toHaveBeenCalled()
})
it.each([
['a 500 response', () => new ApiError(500)],
['a network failure', () => new NetworkError()],
])('shows Retry after %s and resends the same request', async (_, failure) => {
let fail = true
await mountActivity({
exportFile: () => {
if (fail) throw failure()
return new Promise<Blob>(() => undefined)
},
})
await openDialog()
await chooseRange('30 days')
await submitDialog()
expect(textOf(dialogElement())).toContain("Couldn't prepare the download")
expect(textOf(dialogElement())).toContain('Nothing was downloaded. Try again in a moment.')
expect(footerLabels()).toEqual(['Cancel', 'Retry'])
fail = false
await click(dialogButton('Retry'))
expect(callsTo(EXPORT_PATH)).toHaveLength(2)
expect(queryObject(callsTo(EXPORT_PATH)[1])).toEqual(queryObject(callsTo(EXPORT_PATH)[0]))
expect(textOf(dialogElement())).toContain('Preparing download…')
})
it('says Nothing to export for a header-only CSV', async () => {
await mountActivity({ exportFile: () => csvBlob(CSV_HEADER_ONLY) })
await openDialog()
await submitDialog()
await vi.waitFor(() => expect(textOf(dialogElement())).toContain('Nothing to export'))
expect(clicked).toHaveLength(0)
expect(textOf(dialogElement())).toContain(
'There is no activity in this date range. Choose a wider range.',
)
expect(footerLabels()).toEqual(['Close'])
})
it('explains a 403 and disables both menu items afterwards', async () => {
await mountActivity({
exportFile: () => {
throw new ApiError(403)
},
})
await openDialog()
await submitDialog()
expect(clicked).toHaveLength(0)
expect(textOf(dialogElement())).toContain("You can't export all activity")
expect(textOf(dialogElement())).toContain(
'Only owners and admins can export all activity. Ask an owner if you need a copy.',
)
expect(footerLabels()).toEqual(['Close'])
await click(dialogButton('Close'))
await vi.waitFor(() => expect(dialogElement()).toBeNull())
await openExportMenu()
expect(menuItems().every((item) => item.getAttribute('aria-disabled') === 'true')).toBe(
true,
)
})
it('is a bottom sheet below md with the same fields and footer', async () => {
await mountActivity({ desktop: false })
await click(exportButton())
await vi.waitFor(() => expect(menuItems()).toHaveLength(2))
await click(menuItem('Export all activity…'))
await vi.waitFor(() => expect(textOf(document.body)).toContain('Date range'))
const dialog = Array.from(document.body.querySelectorAll('[role="dialog"]')).find(
(candidate) => textOf(candidate).includes('Date range'),
)!
expect(dialog.className).toContain('max-md:!bottom-0')
expect(textOf(dialog)).toContain('Format')
expect(textOf(dialog)).toContain('All time')
expect(
Array.from(
dialog.querySelectorAll<HTMLButtonElement>('[data-slot="footer"] button'),
).map(textOf),
).toEqual(['Cancel', 'Export CSV'])
})
})
describe('Export: no JSON, French', () => {
it('offers no JSON anywhere and never sends a format parameter', async () => {
await mountActivity()
await openExportMenu()
expect(textOf(document.body)).not.toContain('JSON')
await click(menuItem('Export current view'))
await vi.waitFor(() => expect(clicked).toHaveLength(1))
await vi.waitFor(() => expect(exportButton()!.disabled).toBe(false))
await openDialog()
await submitDialog()
expect(textOf(document.body)).not.toContain('JSON')
for (const query of callsTo(EXPORT_PATH)) expect(query.has('format')).toBe(false)
await vi.waitFor(() => expect(clicked).toHaveLength(2))
})
it('has a French entry for every new string', () => {
const keys = [
'Export',
'Export current view',
'Export all activity…',
'CSV · {0} events matching your filters and sort, across all pages',
'CSV · every event, ignoring filters. Owners and admins only.',
'Nothing to export. No events match these filters.',
OWNERS_ONLY,
'Export all activity',
'Date range',
'All time',
'Default is all time. Filters on the page are ignored.',
'Preparing download…',
'Preparing…',
'Download started',
'{0} is downloading.',
"Couldn't prepare the download",
'Nothing was downloaded. Try again in a moment.',
'Nothing to export',
"You can't export all activity",
SERVER_COPY,
BAD_REQUEST_COPY,
'Type: {0}',
'Click to filter the list',
'Filtering. Click to clear',
'Click to clear this filter',
]
for (const key of keys) {
expect(messages[key as keyof typeof messages], key).toBeTruthy()
}
})
it('words the menu, dialog and toast in French', async () => {
setLocale('fr')
await mountActivity()
expect(textOf(findButton('Exporter'))).toBe('Exporter')
await openExportMenu()
expect(textOf(menuItem('Exporter la vue actuelle'))).toContain(
'CSV · 1 284 événements correspondant à vos filtres et à votre tri, sur toutes les pages',
)
await click(menuItem('Exporter toute l’activité…'))
await vi.waitFor(() => expect(dialogElement()).not.toBeNull())
expect(textOf(dialogElement())).toContain('Période')
expect(textOf(dialogElement())).toContain('Toute la période')
await click(dialogButton('Exporter en CSV'))
await vi.waitFor(() => expect(textOf(dialogElement())).toContain('Téléchargement lancé'))
})
})
@@ -284,7 +284,7 @@ describe('Strip cards apply their filter', () => {
await click(stripCards()[3])
expect(queryObject(lastListQuery())).toEqual({ ...DEFAULT_QUERY, security: 'true' })
expect(chipTexts()).toEqual(['Security events'])
expect(chipTexts()).toEqual(['Type: Security events'])
expect(stripCards()[3]!.getAttribute('aria-pressed')).toBe('true')
})
+3 -86
View File
@@ -1,17 +1,15 @@
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 { useApi } from '@/composables/useApi'
import { initializeLocale, setLocale } from '@/i18n'
import {
EXPORT_PATH,
LIST_PATH,
SUMMARY_PATH,
callsTo,
click,
days,
findButton,
harness,
mountActivity,
queryObject,
rows,
@@ -19,7 +17,6 @@ import {
textOf,
unmountActivity,
} from './activityHarness'
import ActivityCategorySelect from '@/components/ActivityCategorySelect.vue'
import { activityItems, makeItem, pageOf, summaryDefault } from './activityFixtures'
vi.mock('@/composables/useApi', async (importOriginal) => {
@@ -34,24 +31,13 @@ vi.mock('@nuxt/ui/composables', async (importOriginal) => {
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()
@@ -59,75 +45,6 @@ afterEach(async () => {
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({
@@ -142,7 +59,7 @@ describe('My activity: a role that is not owner or admin sees its own events', (
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()
expect(findButton('Export')).toBeUndefined()
const headers = Array.from(document.body.querySelectorAll('[role="columnheader"]')).map(
textOf,
)
@@ -204,7 +121,7 @@ describe('The French interface', () => {
})
expect(textOf(document.body.querySelector('h1'))).toBe('Activité')
expect(textOf(findButton('Exporter en CSV'))).toBe('Exporter en CSV')
expect(textOf(findButton('Exporter'))).toBe('Exporter')
const strip = stripCards().map(textOf)
expect(strip[0]).toContain('Actions au total')
expect(strip[1]).toContain('Personnes actives')
+6 -6
View File
@@ -98,7 +98,7 @@ describe('Default: an owner sees the strip, the filter bar and a day-grouped tab
expect(textOf(document.body.querySelector('h1'))).toBe('Activity')
expect(pageText()).toContain('Who did what, and when, across your site.')
expect(findButton('Export CSV')?.disabled).toBe(false)
expect(findButton('Export')?.disabled).toBe(false)
expect(stripTexts()).toEqual([
expect.stringContaining('Total actions'),
expect.stringContaining('Active people'),
@@ -410,7 +410,7 @@ describe('Loading: skeletons after 150 ms', () => {
await mountActivity({ list: () => never, summary: () => never })
expect(document.body.querySelector('h1')?.textContent).toBe('Activity')
expect(findButton('Export CSV')?.disabled).toBe(false)
expect(findButton('Export')?.disabled).toBe(false)
expect(
document.body
.querySelector('[data-testid="activity-filter-bar"] input')
@@ -536,7 +536,7 @@ describe('Empty states', () => {
'Actions on articles, media, users and API keys will appear here.',
)
await expectIcon(document.body.querySelector('[data-slot="root"]'), 'history')
expect(findButton('Export CSV')?.disabled).toBe(true)
expect(findButton('Export')?.disabled).toBe(true)
expect(document.body.querySelector('[role="table"]')).toBeNull()
expect(document.body.querySelector('[data-testid="activity-pager"]')).toBeNull()
const texts = stripTexts()
@@ -563,7 +563,7 @@ describe('Empty states', () => {
expect(chipTexts()).toEqual(['Search: sitemap'])
expect(pageText()).toContain('No activity matches these filters')
expect(pageText()).toContain('Try a different search or a wider date range.')
expect(findButton('Export CSV')?.disabled).toBe(false)
expect(findButton('Export')?.disabled).toBe(false)
expect(callsTo(SUMMARY_PATH)).toHaveLength(1)
empty = false
@@ -601,7 +601,7 @@ describe('Error with retry', () => {
const alert = document.body.querySelector('[role="alert"]')!
expect(textOf(alert)).toContain("Couldn't load activity")
expect(textOf(alert)).toContain('Check your connection and try again.')
expect(findButton('Export CSV')?.disabled).toBe(true)
expect(findButton('Export')?.disabled).toBe(true)
expect(document.body.querySelector('[role="table"]')).toBeNull()
expect(document.body.querySelector('[data-testid="activity-pager"]')).toBeNull()
@@ -624,7 +624,7 @@ describe('Forbidden (403)', () => {
})
expect(textOf(document.body.querySelector('h1'))).toBe('Activity')
expect(findButton('Export CSV')).toBeUndefined()
expect(findButton('Export')).toBeUndefined()
expect(stripCards()).toHaveLength(0)
expect(document.body.querySelector('[data-testid="activity-filter-bar"]')).toBeNull()
expect(document.body.querySelector('[data-testid="activity-chips"]')).toBeNull()
+48 -2
View File
@@ -41,8 +41,17 @@ export const EXPORT_PATH = '/v1/activity/export'
export const USERS_PATH = '/v1/user/'
export const KEYS_PATH = '/v1/api-key/'
export const CSV_HEADER = 'when,actor,action,target,outcome,source,ip'
export const CSV_WITH_ROWS = `${CSV_HEADER}\n2026-10-06,Marta Alves,Published,Spring release,success,web,203.0.113.24\n2026-10-05,Ines Duarte,Deleted,old-banner.png,success,web,203.0.113.12\n`
export const CSV_HEADER_ONLY = `${CSV_HEADER}\n`
export function csvBlob(text: string) {
return new Blob([text], { type: 'text/csv' })
}
export const harness = {
calls: [] as RecordedCall[],
signals: [] as (AbortSignal | undefined)[],
wrapper: null as VueWrapper | null,
}
@@ -65,12 +74,13 @@ export async function mountActivity(options: MountOptions = {}) {
const api: ActivityApiMock = {
list: () => pageOf(activityItems),
summary: () => summaryDefault,
exportFile: () => new Blob(['when,actor'], { type: 'text/csv' }),
exportFile: () => csvBlob(CSV_WITH_ROWS),
users: () => [],
keys: () => [],
...options,
}
harness.calls = []
harness.signals = []
counters.clear()
setViewportWidth(desktop ? DESKTOP_WIDTH : MOBILE_WIDTH)
setActivePinia(createPinia())
@@ -105,7 +115,8 @@ export async function mountActivity(options: MountOptions = {}) {
throw new Error(`Unexpected request: ${path}`)
}
})
const requestBlob = vi.fn(async (path: string) => {
const requestBlob = vi.fn(async (path: string, options?: RequestInit) => {
harness.signals.push(options?.signal ?? undefined)
const url = record(path)
return respond(api.exportFile, url.searchParams, url.pathname) as Blob
})
@@ -204,3 +215,38 @@ export async function expectIcon(root: Element | null | undefined, name: string,
await new Promise((resolve) => setTimeout(resolve, 50))
expect(hasIcon(root, name)).toBe(false)
}
export function exportButton() {
return findButton('Export') ?? findButton('Exporter')
}
export function menuItems() {
return Array.from(document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'))
}
export function menuItem(label: string) {
return menuItems().find((item) => textOf(item).startsWith(label))
}
export async function openExportMenu() {
await click(exportButton())
await vi.waitFor(() => expect(menuItems().length).toBe(2))
}
export function dialogElement() {
return document.body.querySelector<HTMLElement>('[role="dialog"]')
}
export function dialogButton(label: string) {
return Array.from(dialogElement()?.querySelectorAll<HTMLButtonElement>('button') ?? []).find(
(button) => textOf(button) === label || button.getAttribute('aria-label') === label,
)
}
export function dialogButtons() {
return Array.from(
dialogElement()?.querySelectorAll<HTMLButtonElement>(
'footer button, [data-slot="footer"] button',
) ?? [],
).map(textOf)
}
+39
View File
@@ -53,6 +53,45 @@ const extraSources: Record<string, Source[]> = {
},
],
activity: [
{
board: officeBoard,
group: 'Activity: filter cards',
states: [
'Default, no filter',
'Card hover',
'Card focus-visible',
'Failed card active',
'Active card hover',
'Security card active',
'Chip × hover',
'Clear all hover',
'Clear all focus-visible',
'Card plus other filters',
],
alias: {},
},
{
board: officeBoard,
group: 'Activity: export',
states: [
'Menu open',
'Menu, item hover',
'Menu, filtered view',
'Menu, nothing to export',
'Menu, no permission',
'Export button focus-visible',
'Dialog: configure, CSV only',
'Dialog: configure, CSV or JSON (proposed)',
'Dialog: preparing download',
'Dialog: download started',
'Dialog: failed',
'Dialog: nothing to export',
'Dialog: not allowed (403)',
'Toast: current view',
'Toast: export all',
],
alias: {},
},
{
board: officeBoard,
group: 'Activity details',
+45
View File
@@ -3119,6 +3119,51 @@ async function filterActivityToNothing(page: Page) {
}
const activityStates: Record<string, VisualState> = {
'Default, no filter': activityClockSensitive,
'Card hover': activityClockSensitive,
'Card focus-visible': activityClockSensitive,
'Failed card active': activityClockSensitive,
'Active card hover': activityClockSensitive,
'Security card active': activityClockSensitive,
'Chip × hover': activityClockSensitive,
'Clear all hover': activityClockSensitive,
'Clear all focus-visible': activityClockSensitive,
'Card plus other filters': activityClockSensitive,
'Menu open': activityClockSensitive,
'Menu, item hover': activityClockSensitive,
'Menu, filtered view': activityClockSensitive,
'Menu, no permission': activityClockSensitive,
'Export button focus-visible': activityClockSensitive,
'Dialog: configure, CSV only': activityClockSensitive,
'Dialog: preparing download': activityClockSensitive,
'Dialog: download started': activityClockSensitive,
'Dialog: failed': activityClockSensitive,
'Dialog: nothing to export': activityClockSensitive,
'Dialog: not allowed (403)': activityClockSensitive,
'Toast: current view': activityClockSensitive,
'Toast: export all': activityClockSensitive,
'Dialog: configure, CSV or JSON (proposed)': { path: '/activity', skip: 'pending' },
'Menu, nothing to export': {
path: '/activity',
api: activityApi({
'GET /v1/activity': {
status: 200,
bodyFor: (url) =>
url.searchParams.has('q') ||
url.searchParams.has('category') ||
url.searchParams.has('outcome')
? activityPage([], 0)
: activityPage(activityBoardEvents, 1284),
},
}),
steps: async (page) => {
await filterActivityToNothing(page)
await page.getByRole('button', { name: 'Export', exact: true }).click()
await page.getByRole('menuitem').first().waitFor()
await page.mouse.move(0, 0)
await page.waitForTimeout(300)
},
},
'Default, large dataset': activityClockSensitive,
Loading: {
path: '/activity',