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

+1
View File
@@ -5,6 +5,7 @@
"": {
"name": "renewcms-frontend",
"dependencies": {
"@internationalized/date": "^3.12.4",
"@nuxt/ui": "^4.11.2",
"@tailwindcss/vite": "^4.1.18",
"@tiptap/core": "3.31.3",
+1
View File
@@ -16,6 +16,7 @@
"format": "prettier --write --experimental-cli src/"
},
"dependencies": {
"@internationalized/date": "^3.12.4",
"@nuxt/ui": "^4.11.2",
"@tailwindcss/vite": "^4.1.18",
"@tiptap/core": "3.31.3",
+13
View File
@@ -105,6 +105,18 @@
--text-xl--line-height: 28px;
--text-2xl: 1.75rem;
--text-2xl--line-height: 36px;
--text-3xs: 0.625rem;
--text-3xs--line-height: 16px;
--text-2xs: 0.6875rem;
--text-2xs--line-height: 20px;
--text-md: 0.9375rem;
--text-md--line-height: 22px;
--text-lead: 1.125rem;
--text-lead--line-height: 26px;
--text-stat-sm: 1.375rem;
--text-stat-sm--line-height: 32px;
--text-stat: 1.5rem;
--text-stat--line-height: 32px;
--text-code: 0.75rem;
--text-code--line-height: 19px;
@@ -116,6 +128,7 @@
--shadow-popover: 0 4px 16px rgb(17 17 17 / 0.08), 0 1px 2px rgb(17 17 17 / 0.06);
--shadow-modal: 0 16px 48px rgb(17 17 17 / 0.18);
--shadow-flag: inset 2px 0 0 var(--ui-warning);
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
--ease-enter: cubic-bezier(0, 0, 0, 1);
+52
View File
@@ -0,0 +1,52 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import ActivityBadge from '@/components/ActivityBadge.vue'
import { initials, type ActivityActor } from '@/lib/activity'
const props = defineProps<{
actor: ActivityActor
}>()
const displayName = computed(() =>
props.actor.type === 'anonymous' ? t('Not signed in') : props.actor.name,
)
const iconName = computed(() => {
switch (props.actor.type) {
case 'api_key':
return 'i-lucide-key-round'
case 'system':
return 'i-lucide-cog'
default:
return 'i-lucide-user-round-x'
}
})
</script>
<template>
<span class="flex min-w-0 items-center gap-2" data-testid="activity-actor">
<UAvatar
v-if="actor.type === 'user'"
aria-hidden="true"
:text="initials(actor.name)"
:ui="{
root: 'size-6 shrink-0 bg-(--ui-color-secondary-200) dark:bg-(--ui-color-secondary-300)',
fallback: 'pt-px text-3xs font-semibold text-(--ui-color-primary-900)',
}"
/>
<UAvatar
v-else
aria-hidden="true"
:icon="iconName"
:ui="{
root: 'size-6 shrink-0 border border-accented bg-elevated',
icon: 'size-3.5 text-toned',
}"
/>
<span class="min-w-0 truncate font-medium text-highlighted">{{ displayName }}</span>
<ActivityBadge v-if="actor.type === 'api_key'" tone="neutral" :label="t('API key')" />
<ActivityBadge v-else-if="actor.type === 'system'" tone="neutral" :label="t('System')" />
</span>
</template>
+34
View File
@@ -0,0 +1,34 @@
<script setup lang="ts">
import { computed } from 'vue'
export type ActivityBadgeTone = 'ok' | 'fail' | 'security' | 'deleted' | 'neutral'
const TONES: Record<ActivityBadgeTone, { class: string; icon?: string }> = {
ok: { class: 'border border-accented bg-transparent text-toned', icon: 'i-lucide-check' },
fail: { class: 'bg-(--rc-error-soft) text-error', icon: 'i-lucide-circle-x' },
security: { class: 'bg-(--rc-warning-soft) text-warning', icon: 'i-lucide-shield-alert' },
deleted: {
class: 'border border-accented bg-transparent text-toned',
icon: 'i-lucide-trash-2',
},
neutral: { class: 'border border-accented bg-transparent text-toned' },
}
const props = defineProps<{
tone: ActivityBadgeTone
label: string
}>()
const config = computed(() => TONES[props.tone])
</script>
<template>
<UBadge
color="neutral"
variant="soft"
:label="label"
:icon="config.icon"
:class="config.class"
:ui="{ base: 'h-5 shrink-0 gap-1 px-1.5 whitespace-nowrap', leadingIcon: 'size-3' }"
/>
</template>
+122
View File
@@ -0,0 +1,122 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import ActivityActor from '@/components/ActivityActor.vue'
import ActivityBadge from '@/components/ActivityBadge.vue'
import ActivitySentence from '@/components/ActivitySentence.vue'
import { ACTIVITY_SKELETON_CARDS } from '@/composables/useActivity'
import { categoryIcon, outcomeBadge, rowSurface, type ActivityItem } from '@/lib/activity'
import { fullLocalTime, groupByDay, whenLabel } from '@/lib/activityTime'
const SKELETON_SENTENCE_WIDTHS = ['w-9/10', 'w-3/4', 'w-17/20']
const props = defineProps<{
items: ActivityItem[]
loading: boolean
selectedId: number | null
showActor: boolean
}>()
const emit = defineEmits<{
open: [item: ActivityItem, opener: HTMLElement]
}>()
const groups = computed(() => groupByDay(props.items))
function open(item: ActivityItem, event: Event) {
emit('open', item, event.currentTarget as HTMLElement)
}
function cardClass(item: ActivityItem) {
return rowSurface(props.selectedId === item.id, item.security)
}
</script>
<template>
<div
:aria-busy="loading ? 'true' : undefined"
class="flex flex-col overflow-hidden border border-default bg-default"
>
<template v-if="loading">
<span role="status" class="sr-only">{{ t('Loading activity') }}</span>
<div
v-for="n in ACTIVITY_SKELETON_CARDS"
:key="n"
class="flex flex-col gap-2.5 border-t border-default px-4 py-3.5"
data-testid="activity-skeleton-card"
>
<div class="flex items-center gap-2">
<USkeleton class="size-6 shrink-0 rounded-none bg-accented" />
<USkeleton class="h-3 w-22.5 rounded-none bg-accented" />
<span class="flex-1" />
<USkeleton class="h-2.5 w-10 rounded-none bg-accented" />
</div>
<USkeleton
class="h-3.5 rounded-none bg-accented"
:class="SKELETON_SENTENCE_WIDTHS[(n - 1) % 3]"
/>
<USkeleton class="h-5 w-17.5 rounded-none bg-accented" />
</div>
</template>
<template v-else>
<template v-for="group in groups" :key="group.key">
<div
data-testid="activity-day"
class="flex h-8 shrink-0 items-center border-t border-default bg-muted px-4 text-xs font-semibold text-highlighted"
>
{{ group.label }}
</div>
<div
v-for="item in group.items"
:key="item.id"
tabindex="0"
role="button"
data-testid="activity-card"
:data-activity-id="item.id"
:aria-pressed="selectedId === item.id"
:class="cardClass(item)"
class="flex min-h-11 cursor-pointer flex-col gap-2 border-t border-default px-4 py-3 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary"
@click="open(item, $event)"
@keydown.enter.self.prevent="open(item, $event)"
>
<div class="flex min-w-0 items-center gap-2">
<ActivityActor v-if="showActor" :actor="item.actor" class="flex-1" />
<span v-else class="flex-1" />
<span
data-testid="activity-when"
:title="fullLocalTime(item.created_at)"
class="text-xs/5 whitespace-nowrap text-muted"
>{{ whenLabel(item.created_at) }}</span
>
</div>
<div class="flex items-start gap-2">
<UIcon
:name="categoryIcon(item.category)"
class="mt-0.5 size-4 shrink-0 text-muted"
/>
<span
class="line-clamp-3 min-w-0 flex-1 text-md wrap-anywhere text-default"
>
<ActivitySentence :item="item" />
</span>
</div>
<div class="flex flex-wrap items-center gap-1.5">
<ActivityBadge v-bind="outcomeBadge(item.outcome)" />
<ActivityBadge
v-if="item.security"
tone="security"
:label="t('Security')"
/>
<ActivityBadge
v-if="item.target && !item.target.exists"
tone="deleted"
:label="t('Target deleted')"
/>
</div>
</div>
</template>
</template>
</div>
</template>
@@ -0,0 +1,44 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import { ACTIVITY_CATEGORIES, categoryLabel, type ActivityCategory } from '@/lib/activity'
defineProps<{
category: ActivityCategory | ''
}>()
const emit = defineEmits<{
select: [category: ActivityCategory]
}>()
const items = computed(() =>
ACTIVITY_CATEGORIES.map((category) => ({
label: categoryLabel(category),
value: category,
})),
)
</script>
<template>
<USelectMenu
:model-value="category || undefined"
:items="items"
value-key="value"
icon="i-lucide-layout-list"
:placeholder="t('All categories')"
:search-input="false"
:aria-label="t('Category')"
color="neutral"
variant="outline"
class="w-full md:w-44"
: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',
placeholder: 'text-muted',
content: 'min-w-48',
}"
@update:model-value="emit('select', $event as ActivityCategory)"
/>
</template>
+54
View File
@@ -0,0 +1,54 @@
<script setup lang="ts">
import { t } from '@/i18n'
import type { ActivityChip, ChipKey } from '@/lib/activity'
defineProps<{
chips: ActivityChip[]
}>()
const emit = defineEmits<{
remove: [key: ChipKey]
clear: []
}>()
</script>
<template>
<div
v-if="chips.length > 0"
data-testid="activity-chips"
class="flex flex-wrap items-center gap-2"
>
<UBadge
v-for="chip in chips"
:key="chip.key"
color="neutral"
variant="subtle"
data-testid="activity-chip"
:ui="{
base: 'h-8 gap-1 border border-default bg-elevated py-0 ps-2 pe-1 text-xs font-medium text-highlighted ring-0 md:h-6',
}"
>
{{ chip.label }}
<template #trailing>
<UButton
color="neutral"
variant="ghost"
icon="i-lucide-x"
:aria-label="t('Remove filter {0}', [chip.label])"
class="size-5 justify-center p-0 text-muted"
:ui="{ leadingIcon: 'size-3' }"
@click="emit('remove', chip.key)"
/>
</template>
</UBadge>
<UButton
color="neutral"
variant="link"
class="h-11 px-1.5 text-xs font-medium text-highlighted underline underline-offset-3 md:h-6"
@click="emit('clear')"
>
{{ t('Clear all') }}
</UButton>
</div>
</template>
+164
View File
@@ -0,0 +1,164 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, ref, watch } from 'vue'
import { CalendarDate, parseDate } from '@internationalized/date'
import { RANGE_KEYS, rangeLabel, type RangeKey } from '@/lib/activity'
import { SEGMENT_GROUP, segmentClass } from '@/lib/segmentStyles'
interface RangeChange {
range: RangeKey
from: string
to: string
}
interface CalendarRange {
start?: CalendarDate
end?: CalendarDate
}
const props = defineProps<{
range: RangeKey
from: string
to: string
block?: boolean
}>()
const emit = defineEmits<{
change: [value: RangeChange]
}>()
const popoverOpen = ref(false)
const customPending = ref(false)
const draftFrom = ref(props.from)
const draftTo = ref(props.to)
const presets = RANGE_KEYS.filter((key) => key !== 'custom')
const customSelected = computed(
() => props.range === 'custom' || popoverOpen.value || customPending.value,
)
const showFields = computed(() => props.block && customSelected.value)
function parse(value: string) {
try {
return parseDate(value)
} catch {
return undefined
}
}
const calendarValue = computed<CalendarRange>(() => ({
start: parse(props.from),
end: parse(props.to),
}))
watch(
() => [props.from, props.to],
([from, to]) => {
draftFrom.value = from ?? ''
draftTo.value = to ?? ''
},
)
function choose(range: RangeKey) {
customPending.value = false
emit('change', { range, from: '', to: '' })
}
function chooseCustom() {
if (props.block) customPending.value = true
}
function applyCustom(from: string, to: string) {
customPending.value = false
emit(
'change',
from <= to ? { range: 'custom', from, to } : { range: 'custom', from: to, to: from },
)
}
function onCalendar(value: CalendarRange | null) {
if (!value?.start || !value.end) return
popoverOpen.value = false
applyCustom(value.start.toString(), value.end.toString())
}
function onField(side: 'from' | 'to', value: string | number | null | undefined) {
const next = typeof value === 'string' ? value : ''
if (side === 'from') draftFrom.value = next
else draftTo.value = next
if (draftFrom.value && draftTo.value) applyCustom(draftFrom.value, draftTo.value)
}
</script>
<template>
<div class="flex flex-col gap-2" :class="block ? 'w-full' : ''">
<div role="group" :aria-label="t('Dates')" :class="[SEGMENT_GROUP, block ? 'w-full' : '']">
<UButton
v-for="key in presets"
:key="key"
color="neutral"
:variant="range === key && !customSelected ? 'soft' : 'outline'"
:aria-pressed="range === key && !customSelected"
:class="segmentClass(range === key && !customSelected, block)"
@click="choose(key)"
>
{{ rangeLabel(key) }}
</UButton>
<UButton
v-if="block"
color="neutral"
:variant="customSelected ? 'soft' : 'outline'"
icon="i-lucide-calendar"
:aria-pressed="customSelected"
:class="segmentClass(customSelected, block)"
:ui="{ leadingIcon: 'size-3.5' }"
@click="chooseCustom"
>
{{ rangeLabel('custom') }}
</UButton>
<UPopover v-else v-model:open="popoverOpen" :content="{ align: 'end' }">
<UButton
color="neutral"
:variant="customSelected ? 'soft' : 'outline'"
icon="i-lucide-calendar"
:aria-pressed="customSelected"
:class="segmentClass(customSelected)"
:ui="{ leadingIcon: 'size-3.5' }"
>
{{ rangeLabel('custom') }}
</UButton>
<template #content>
<UCalendar
range
:model-value="calendarValue"
:number-of-months="1"
class="p-2"
@update:model-value="onCalendar($event as CalendarRange | null)"
/>
</template>
</UPopover>
</div>
<div v-if="showFields" class="flex items-center gap-2">
<UInput
type="date"
icon="i-lucide-calendar"
:model-value="draftFrom"
:aria-label="t('Start date')"
class="min-w-0 flex-1"
:ui="{ base: 'h-11 w-full min-w-0 text-lg' }"
@update:model-value="onField('from', $event)"
/>
<UInput
type="date"
icon="i-lucide-calendar"
:model-value="draftTo"
:aria-label="t('End date')"
class="min-w-0 flex-1"
:ui="{ base: 'h-11 w-full min-w-0 text-lg' }"
@update:model-value="onField('to', $event)"
/>
</div>
</div>
</template>
+224
View File
@@ -0,0 +1,224 @@
<script setup lang="ts">
import { t, formatNumber } from '@/i18n'
import { computed, ref, watch } from 'vue'
import ActivityActor from '@/components/ActivityActor.vue'
import ActivityBadge from '@/components/ActivityBadge.vue'
import ActivityDiff from '@/components/ActivityDiff.vue'
import ActivitySentence from '@/components/ActivitySentence.vue'
import {
categoryIcon,
categoryLabel,
outcomeBadge,
sourceLabel,
targetLink,
targetTypeLabel,
type ActivityItem,
} from '@/lib/activity'
import { fullLocalTime, fullUtcTime } from '@/lib/activityTime'
const props = defineProps<{
item: ActivityItem | null
position: number
total: number
canPrevious: boolean
canNext: boolean
isDesktop: boolean
}>()
const emit = defineEmits<{
previous: []
next: []
close: []
}>()
const retained = ref<ActivityItem | null>(props.item)
watch(
() => props.item,
(value) => {
if (value) retained.value = value
},
)
const current = computed(() => props.item ?? retained.value)
const link = computed(() => targetLink(current.value?.target ?? null))
const side = computed(() => (props.isDesktop ? 'right' : 'bottom'))
const positionOf = computed(() =>
t('{0} of {1}', [formatNumber(props.position), formatNumber(props.total)]),
)
const contentClass = computed(() =>
props.isDesktop
? 'w-full max-w-120'
: 'h-[88%] 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',
)
const closeButton = computed(() => ({
color: 'neutral' as const,
variant: 'ghost' as const,
icon: 'i-lucide-x',
class: 'text-muted',
}))
function onOpenChange(isOpen: boolean) {
if (!isOpen) emit('close')
}
</script>
<template>
<USlideover
:open="item !== null"
:side="side"
:title="t('Activity details')"
:description="t('Activity details')"
:close="closeButton"
:ui="{
content: contentClass,
header: 'h-14 min-h-14 border-b border-default px-4 py-0 sm:px-4 md:px-5',
wrapper: 'min-w-0 flex-1 pe-10 md:pe-8',
title: 'flex items-baseline justify-between gap-2 text-lg font-semibold text-highlighted',
description: 'sr-only',
close: 'top-4.5 end-2 size-11 justify-center p-0 md:top-3.5 md:end-5 md:size-7',
body: 'flex-1 overflow-y-auto p-4 sm:p-4 md:p-5',
footer: 'flex gap-2 border-t border-default px-4 py-3 sm:px-4 md:px-5',
}"
@update:open="onOpenChange"
>
<template #title>
<span>{{ t('Activity details') }}</span>
<span v-if="!isDesktop" class="text-xs font-normal text-muted">{{ positionOf }}</span>
</template>
<template #body>
<div v-if="current" data-testid="activity-details" class="flex flex-col gap-5">
<div class="flex flex-col gap-2.5">
<p
data-testid="activity-details-sentence"
class="text-lead font-semibold wrap-anywhere text-highlighted"
>
<ActivitySentence :item="current" />
</p>
<div class="flex flex-wrap items-center gap-1.5">
<ActivityBadge v-bind="outcomeBadge(current.outcome)" />
<ActivityBadge
v-if="current.security"
tone="security"
:label="t('Security')"
/>
<span class="inline-flex items-center gap-1 text-xs text-muted">
<UIcon :name="categoryIcon(current.category)" class="size-3.5" />
{{ categoryLabel(current.category) }}
</span>
</div>
</div>
<UAlert
v-if="current.outcome === 'failed' && current.failure_reason"
role="alert"
color="error"
variant="soft"
icon="i-lucide-circle-x"
:title="t('Why it failed')"
:description="current.failure_reason"
/>
<dl class="grid grid-cols-[96px_minmax(0,1fr)] items-start gap-x-3 gap-y-2.5">
<dt class="text-muted">{{ t('When') }}</dt>
<dd class="m-0 flex flex-col gap-0.5">
<span class="text-highlighted">{{
t('{0} (your time)', [fullLocalTime(current.created_at)])
}}</span>
<span class="text-xs/5 text-muted">{{
t('{0} UTC', [fullUtcTime(current.created_at)])
}}</span>
</dd>
<dt class="text-muted">{{ t('Actor') }}</dt>
<dd class="m-0 min-w-0"><ActivityActor :actor="current.actor" /></dd>
<dt class="text-muted">{{ t('Source') }}</dt>
<dd class="m-0 text-highlighted">{{ sourceLabel(current.source) }}</dd>
<dt class="text-muted">{{ t('IP address') }}</dt>
<dd v-if="current.ip" class="m-0 font-mono text-xs/5 text-highlighted">
{{ current.ip }}
</dd>
<dd v-else class="m-0 text-muted">{{ t('Not applicable') }}</dd>
<dt class="text-muted">{{ t('Browser') }}</dt>
<dd v-if="current.user_agent" class="m-0 text-highlighted">
{{ current.user_agent }}
</dd>
<dd v-else class="m-0 text-muted">{{ t('Not applicable') }}</dd>
</dl>
<section
v-if="current.target"
class="flex flex-col gap-2"
data-testid="activity-target-section"
>
<h3 class="text-base font-semibold text-highlighted">{{ t('Target') }}</h3>
<div class="flex flex-col gap-1.5 border border-default px-3 py-2.5">
<div class="flex min-w-0 items-center gap-2">
<template v-if="!current.target.exists">
<span
class="min-w-0 flex-1 wrap-anywhere text-muted line-through"
>{{ current.target.name }}</span
>
<ActivityBadge tone="deleted" :label="t('Deleted')" />
</template>
<RouterLink
v-else-if="link"
:to="link"
class="flex min-w-0 flex-1 items-center gap-1 wrap-anywhere text-highlighted underline underline-offset-3"
>
{{ current.target.name }}
<UIcon
name="i-lucide-arrow-up-right"
class="size-3.5 shrink-0 text-muted"
/>
</RouterLink>
<span v-else class="min-w-0 flex-1 wrap-anywhere text-highlighted">{{
current.target.name
}}</span>
</div>
<span class="text-xs/5 text-muted">{{
`${targetTypeLabel(current.target.type)} · ${current.target.id}`
}}</span>
<span v-if="!current.target.exists" class="text-xs/5 text-toned">{{
t('This item was deleted. Its name is kept for reference.')
}}</span>
</div>
</section>
<section v-if="current.changes.length > 0" class="flex flex-col gap-2">
<h3 class="text-base font-semibold text-highlighted">{{ t('Changes') }}</h3>
<ActivityDiff :changes="current.changes" :is-desktop="isDesktop" />
</section>
</div>
</template>
<template #footer>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-chevron-up"
:disabled="!canPrevious"
class="h-11 justify-center border border-accented bg-default px-3 text-sm text-highlighted ring-0 max-md:flex-1 md:h-8"
@click="emit('previous')"
>
{{ t('Previous event') }}
</UButton>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-chevron-down"
:disabled="!canNext"
class="h-11 justify-center border border-accented bg-default px-3 text-sm text-highlighted ring-0 max-md:flex-1 md:h-8"
@click="emit('next')"
>
{{ t('Next event') }}
</UButton>
<span v-if="isDesktop" class="ml-auto self-center text-xs text-muted">{{
t('Event {0} of {1}', [formatNumber(position), formatNumber(total)])
}}</span>
</template>
</USlideover>
</template>
+113
View File
@@ -0,0 +1,113 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { bodyDeltaLabel, changeValue, fieldLabel, type ActivityChange } from '@/lib/activity'
const props = defineProps<{
changes: ActivityChange[]
isDesktop: boolean
}>()
const expanded = ref<Record<number, boolean>>({})
const overflowing = ref<Record<number, boolean>>({})
const valueBoxes = new Map<number, HTMLElement[]>()
function register(index: number, element: unknown) {
if (!(element instanceof HTMLElement)) return
const boxes = valueBoxes.get(index) ?? []
if (!boxes.includes(element)) valueBoxes.set(index, [...boxes, element])
}
function measure() {
const next: Record<number, boolean> = { ...overflowing.value }
for (const [index, boxes] of valueBoxes) {
if (expanded.value[index]) continue
next[index] = boxes.some((box) => box.scrollHeight > box.clientHeight)
}
overflowing.value = next
}
function toggle(index: number) {
expanded.value = { ...expanded.value, [index]: !expanded.value[index] }
}
watch(
() => props.changes,
() => {
expanded.value = {}
overflowing.value = {}
valueBoxes.clear()
nextTick(measure)
},
)
onMounted(() => {
nextTick(measure)
window.addEventListener('resize', measure)
})
onBeforeUnmount(() => window.removeEventListener('resize', measure))
</script>
<template>
<div data-testid="activity-diff" class="flex flex-col border border-default">
<div
v-for="(change, index) in changes"
:key="index"
data-testid="activity-change"
class="flex flex-col gap-2 border-t border-muted p-3 first:border-t-0"
>
<span class="font-medium text-highlighted">{{ fieldLabel(change.field) }}</span>
<span
v-if="change.kind === 'body'"
data-testid="activity-body-change"
class="flex items-center gap-1.5 text-toned"
>
<UIcon name="i-lucide-file-pen-line" class="size-3.5 shrink-0" />
{{ bodyDeltaLabel(change.delta) }}
</span>
<template v-else>
<div
class="grid items-start gap-1.5 md:grid-cols-[minmax(0,1fr)_16px_minmax(0,1fr)] md:gap-2"
>
<div class="flex min-w-0 flex-col gap-0.5">
<span class="text-2xs text-muted">{{ t('Before') }}</span>
<span
:ref="(element) => register(index, element)"
data-testid="activity-before"
class="border border-default bg-elevated px-2.5 py-2 wrap-anywhere text-toned"
:class="expanded[index] ? '' : 'line-clamp-2'"
>{{ changeValue(change.field, change.before) }}</span
>
</div>
<UIcon
:name="isDesktop ? 'i-lucide-arrow-right' : 'i-lucide-arrow-down'"
class="size-3.5 shrink-0 justify-self-center text-muted md:mt-7.5"
/>
<div class="flex min-w-0 flex-col gap-0.5">
<span class="text-2xs text-muted">{{ t('After') }}</span>
<span
:ref="(element) => register(index, element)"
data-testid="activity-after"
class="border border-accented bg-default px-2.5 py-2 wrap-anywhere text-highlighted"
:class="expanded[index] ? '' : 'line-clamp-2'"
>{{ changeValue(change.field, change.after) }}</span
>
</div>
</div>
<UButton
v-if="overflowing[index]"
color="neutral"
variant="link"
class="min-h-8 self-start px-0 text-xs font-medium text-highlighted underline underline-offset-3 md:min-h-4"
@click="toggle(index)"
>
{{ expanded[index] ? t('Show less') : t('Show more') }}
</UButton>
</template>
</div>
</div>
</template>
+37
View File
@@ -0,0 +1,37 @@
<script setup lang="ts">
defineProps<{
icon: string
title: string
description: string
actionLabel?: string
}>()
const emit = defineEmits<{ action: [] }>()
</script>
<template>
<UEmpty
variant="naked"
:icon="icon"
:title="title"
:description="description"
:ui="{
root: 'gap-3 border border-default bg-default px-6 py-14 sm:px-6 sm:py-14 lg:px-6 lg:py-14',
header: 'gap-3',
avatar: 'mb-0 size-11 rounded-none bg-elevated text-toned',
title: 'text-md/5 font-semibold',
description: 'max-w-85 text-sm/5 text-muted text-pretty',
}"
>
<template v-if="actionLabel" #actions>
<UButton
color="neutral"
variant="outline"
class="h-11 border border-accented bg-default px-4 text-sm text-highlighted ring-0 md:h-8"
@click="emit('action')"
>
{{ actionLabel }}
</UButton>
</template>
</UEmpty>
</template>
+174
View File
@@ -0,0 +1,174 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import ActivityCategorySelect from '@/components/ActivityCategorySelect.vue'
import ActivityDatePresets from '@/components/ActivityDatePresets.vue'
import ActivityPersonSelect from '@/components/ActivityPersonSelect.vue'
import ActivitySegmented from '@/components/ActivitySegmented.vue'
import type { PersonOptions } from '@/composables/useActivity'
import { outcomeOptions, type ActivityFilters, type OutcomeFilter } from '@/lib/activity'
const SEARCH_DEBOUNCE_MS = 300
const props = defineProps<{
filters: ActivityFilters
people: PersonOptions | null
canFilterPeople: boolean
isDesktop: boolean
chipCount: number
}>()
const emit = defineEmits<{
'update:filters': [filters: ActivityFilters]
'open-people': []
'open-sheet': []
}>()
const searchText = ref(props.filters.q)
let debounceTimer: ReturnType<typeof setTimeout> | null = null
const searchIconClass = computed(
() => `size-3.5 ${searchText.value ? 'text-highlighted' : 'text-muted'}`,
)
const outcomeChoices = computed(() => outcomeOptions())
function clearDebounce() {
if (debounceTimer !== null) {
clearTimeout(debounceTimer)
debounceTimer = null
}
}
function applySearch() {
clearDebounce()
const trimmed = searchText.value.trim()
if (trimmed !== props.filters.q) emit('update:filters', { ...props.filters, q: trimmed })
}
watch(
() => props.filters.q,
(value) => {
if (value !== searchText.value.trim()) {
clearDebounce()
searchText.value = value
}
},
)
watch(searchText, () => {
clearDebounce()
debounceTimer = setTimeout(applySearch, SEARCH_DEBOUNCE_MS)
})
function clearSearch() {
searchText.value = ''
applySearch()
}
function onActor(actor: string, label: string) {
emit('update:filters', { ...props.filters, actor, actorLabel: label })
}
function onOutcome(outcome: OutcomeFilter) {
emit('update:filters', { ...props.filters, outcome })
}
onBeforeUnmount(clearDebounce)
</script>
<template>
<div
v-if="isDesktop"
data-testid="activity-filter-bar"
class="flex flex-wrap items-center gap-2"
>
<UInput
v-model="searchText"
type="text"
icon="i-lucide-search"
:placeholder="t('Search activity')"
:aria-label="t('Search activity')"
class="w-60"
:ui="{ base: 'h-8 ps-8 text-sm', leadingIcon: searchIconClass }"
@keydown.enter="applySearch"
>
<template v-if="searchText" #trailing>
<UButton
color="neutral"
variant="link"
size="xs"
icon="i-lucide-x"
:aria-label="t('Clear search')"
class="text-muted"
@click="clearSearch"
/>
</template>
</UInput>
<ActivityPersonSelect
v-if="canFilterPeople"
:actor="filters.actor"
:people="people"
@select="onActor"
@open="emit('open-people')"
/>
<ActivityCategorySelect
:category="filters.category"
@select="emit('update:filters', { ...filters, category: $event })"
/>
<ActivitySegmented
:options="outcomeChoices"
:model-value="filters.outcome"
:label="t('Outcome')"
@update:model-value="onOutcome"
/>
<div class="min-w-0 flex-1" />
<ActivityDatePresets
:range="filters.range"
:from="filters.from"
:to="filters.to"
@change="emit('update:filters', { ...filters, ...$event })"
/>
</div>
<div v-else data-testid="activity-filter-bar" class="flex gap-2">
<UInput
v-model="searchText"
type="text"
icon="i-lucide-search"
:placeholder="t('Search activity')"
:aria-label="t('Search activity')"
class="min-w-0 flex-1"
:ui="{ base: 'h-11 ps-8 text-lg', leadingIcon: searchIconClass }"
@keydown.enter="applySearch"
>
<template v-if="searchText" #trailing>
<UButton
color="neutral"
variant="link"
size="xs"
icon="i-lucide-x"
:aria-label="t('Clear search')"
class="text-muted"
@click="clearSearch"
/>
</template>
</UInput>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-sliders-horizontal"
class="h-11 shrink-0 border border-accented bg-default px-3 text-sm text-highlighted ring-0"
@click="emit('open-sheet')"
>
{{ t('Filters') }}
<span
v-if="chipCount > 0"
data-testid="activity-filter-count"
class="inline-flex h-4.5 min-w-4.5 items-center justify-center bg-primary px-1.25 font-mono text-2xs font-medium text-(--ui-bg)"
>{{ chipCount }}</span
>
</UButton>
</div>
</template>
+115
View File
@@ -0,0 +1,115 @@
<script setup lang="ts">
import { t, formatNumber } from '@/i18n'
import { computed } from 'vue'
import ActivityCategorySelect from '@/components/ActivityCategorySelect.vue'
import ActivityDatePresets from '@/components/ActivityDatePresets.vue'
import ActivityPersonSelect from '@/components/ActivityPersonSelect.vue'
import ActivitySegmented from '@/components/ActivitySegmented.vue'
import type { PersonOptions } from '@/composables/useActivity'
import { outcomeOptions, type ActivityFilters, type OutcomeFilter } from '@/lib/activity'
const props = defineProps<{
filters: ActivityFilters
people: PersonOptions | null
canFilterPeople: boolean
chipCount: number
total: number
}>()
const emit = defineEmits<{
'update:filters': [filters: ActivityFilters]
'open-people': []
clear: []
}>()
const open = defineModel<boolean>('open', { default: false })
const outcomeChoices = computed(() => outcomeOptions())
const showLabel = computed(() =>
props.total === 1 ? t('Show 1 result') : t('Show {0} results', [formatNumber(props.total)]),
)
function onActor(actor: string, label: string) {
emit('update:filters', { ...props.filters, actor, actorLabel: label })
}
function onOutcome(outcome: OutcomeFilter) {
emit('update:filters', { ...props.filters, outcome })
}
</script>
<template>
<USlideover
v-model:open="open"
side="bottom"
:title="t('Filters')"
:description="t('Filters')"
: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-4 sm:p-4',
footer: 'gap-2 border-t border-default px-4 pt-3 pb-4 sm:px-4',
}"
>
<template #body>
<div data-testid="activity-filters-sheet" class="flex flex-col gap-4">
<div v-if="canFilterPeople" class="flex flex-col gap-1.5">
<span class="font-medium text-highlighted">{{ t('Person') }}</span>
<ActivityPersonSelect
:actor="filters.actor"
:people="people"
@select="onActor"
@open="emit('open-people')"
/>
</div>
<div class="flex flex-col gap-1.5">
<span class="font-medium text-highlighted">{{ t('Category') }}</span>
<ActivityCategorySelect
:category="filters.category"
@select="emit('update:filters', { ...filters, category: $event })"
/>
</div>
<div class="flex flex-col gap-1.5">
<span class="font-medium text-highlighted">{{ t('Outcome') }}</span>
<ActivitySegmented
block
:options="outcomeChoices"
:model-value="filters.outcome"
:label="t('Outcome')"
@update:model-value="onOutcome"
/>
</div>
<div class="flex flex-col gap-1.5">
<span class="font-medium text-highlighted">{{ t('Dates') }}</span>
<ActivityDatePresets
block
:range="filters.range"
:from="filters.from"
:to="filters.to"
@change="emit('update:filters', { ...filters, ...$event })"
/>
</div>
</div>
</template>
<template #footer>
<UButton
color="neutral"
variant="outline"
:disabled="chipCount === 0"
class="h-11 flex-1 justify-center border border-accented bg-default px-3 text-sm text-highlighted ring-0"
@click="emit('clear')"
>
{{ t('Clear all') }}
</UButton>
<UButton class="h-11 flex-1 justify-center px-3 text-sm" @click="open = false">
{{ showLabel }}
</UButton>
</template>
</USlideover>
</template>
+118
View File
@@ -0,0 +1,118 @@
<script setup lang="ts">
import { t, formatNumber } from '@/i18n'
import { computed } from 'vue'
import { PAGE_SIZES } from '@/lib/activity'
const props = defineProps<{
page: number
pageCount: number
total: number
size: number
from: number
to: number
isDesktop: boolean
}>()
const emit = defineEmits<{
'update:page': [page: number]
'update:size': [size: number]
}>()
const sizeItems = PAGE_SIZES.map((value) => ({ label: String(value), value }))
const range = computed(() => ({
from: formatNumber(props.from),
to: formatNumber(props.to),
total: formatNumber(props.total),
}))
const PAGER_BUTTON =
'size-11 justify-center border border-accented bg-default p-0 text-highlighted ring-0 md:size-8'
</script>
<template>
<div v-if="isDesktop" data-testid="activity-pager" class="flex shrink-0 items-center gap-4">
<span class="text-muted">{{
t('Showing {0}–{1} of {2}', [range.from, range.to, range.total])
}}</span>
<div class="flex-1" />
<div class="flex items-center gap-2 text-muted">
<span>{{ t('Rows per page') }}</span>
<USelectMenu
:model-value="size"
:items="sizeItems"
value-key="value"
:search-input="false"
:aria-label="t('Rows per page')"
color="neutral"
variant="outline"
class="w-20"
:ui="{
base: 'h-8 rounded-none border border-accented bg-default px-2.5 text-sm ring-0',
trailingIcon: 'size-3.5 text-muted',
}"
@update:model-value="emit('update:size', $event as number)"
/>
</div>
<UPagination
:page="page"
:total="total"
:items-per-page="size"
:sibling-count="1"
show-edges
color="neutral"
variant="outline"
active-color="primary"
active-variant="solid"
:ui="{
item: 'size-8 justify-center border border-accented ring-0',
prev: 'size-8 justify-center border border-accented ring-0',
next: 'size-8 justify-center border border-accented ring-0',
}"
@update:page="emit('update:page', $event)"
/>
</div>
<div v-else data-testid="activity-pager" class="flex shrink-0 items-center gap-2">
<UButton
color="neutral"
variant="outline"
icon="i-lucide-chevron-left"
:disabled="page <= 1"
:aria-label="t('Previous page')"
:class="PAGER_BUTTON"
@click="emit('update:page', page - 1)"
/>
<div class="flex flex-1 flex-col items-center text-xs text-muted">
<span class="font-medium text-highlighted">{{
t('Page {0} of {1}', [formatNumber(page), formatNumber(pageCount)])
}}</span>
<span>{{ t('{0}–{1} of {2}', [range.from, range.to, range.total]) }}</span>
</div>
<USelectMenu
:model-value="size"
:items="sizeItems"
value-key="value"
:search-input="false"
:aria-label="t('Rows per page')"
color="neutral"
variant="outline"
class="w-24 shrink-0"
:ui="{
base: 'h-11 rounded-none border border-accented bg-default px-2.5 text-sm ring-0',
trailingIcon: 'size-3.5 text-muted',
}"
@update:model-value="emit('update:size', $event as number)"
/>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-chevron-right"
:disabled="page >= pageCount"
:aria-label="t('Next page')"
:class="PAGER_BUTTON"
@click="emit('update:page', page + 1)"
/>
</div>
</template>
@@ -0,0 +1,67 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import type { PersonOptions } from '@/composables/useActivity'
const props = defineProps<{
actor: string
people: PersonOptions | null
}>()
const emit = defineEmits<{
select: [actor: string, label: string]
open: []
}>()
const groups = computed(() => {
const users = (props.people?.users ?? []).map((user) => ({
label: user.name,
value: `user:${user.id}`,
}))
const keys = (props.people?.keys ?? []).map((key) => ({
label: key.name,
value: `key:${key.id}`,
}))
return [
[{ type: 'label' as const, label: t('Users') }, ...users],
[{ type: 'label' as const, label: t('API keys') }, ...keys],
[{ label: t('System'), value: 'system' }],
]
})
function labelFor(value: string) {
if (value === 'system') return t('System')
const options = value.startsWith('user:') ? props.people?.users : props.people?.keys
const id = Number(value.slice(value.indexOf(':') + 1))
return options?.find((option) => option.id === id)?.name ?? value
}
function onSelect(value: string | undefined) {
if (value) emit('select', value, labelFor(value))
}
</script>
<template>
<USelectMenu
:model-value="actor || undefined"
:items="groups"
value-key="value"
icon="i-lucide-user-round"
:placeholder="t('All people')"
:search-input="false"
:aria-label="t('Person')"
color="neutral"
variant="outline"
class="w-full md:w-42"
: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',
placeholder: 'text-muted',
content: 'min-w-48',
}"
@update:open="(isOpen: boolean) => isOpen && emit('open')"
@update:model-value="onSelect($event as string | undefined)"
/>
</template>
+30
View File
@@ -0,0 +1,30 @@
<script setup lang="ts" generic="V extends string">
import { SEGMENT_GROUP, segmentClass } from '@/lib/segmentStyles'
defineProps<{
options: { value: V; label: string }[]
modelValue: V
label: string
block?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: V]
}>()
</script>
<template>
<div role="group" :aria-label="label" :class="[SEGMENT_GROUP, block ? 'w-full' : '']">
<UButton
v-for="option in options"
:key="option.value"
color="neutral"
:variant="option.value === modelValue ? 'soft' : 'outline'"
:aria-pressed="option.value === modelValue"
:class="segmentClass(option.value === modelValue, block)"
@click="emit('update:modelValue', option.value)"
>
{{ option.label }}
</UButton>
</div>
</template>
+22
View File
@@ -0,0 +1,22 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { ActivityItem } from '@/lib/activity'
import { composeSentence } from '@/lib/activitySentence'
const props = defineProps<{
item: ActivityItem
}>()
const segments = computed(() => composeSentence(props.item))
</script>
<template>
<span data-testid="activity-sentence"
><template v-for="(segment, index) in segments" :key="index"
><span v-if="segment.strong" class="font-semibold text-highlighted">{{
segment.text
}}</span
><template v-else>{{ segment.text }}</template></template
></span
>
</template>
+141
View File
@@ -0,0 +1,141 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { ActivityFilters, ActivitySummary } from '@/lib/activity'
import {
barHeights,
STRIP_KEYS,
stripCards,
type StripCard,
type StripKey,
} from '@/lib/activitySummary'
const DESKTOP_BAR_HEIGHT = 20
const MOBILE_BAR_HEIGHT = 16
const props = defineProps<{
summary: ActivitySummary | null
range: ActivityFilters['range']
loading: boolean
isDesktop: boolean
outcome: ActivityFilters['outcome']
security: boolean
}>()
const emit = defineEmits<{
'apply-filter': [key: StripKey]
}>()
const cards = computed(() => (props.summary ? stripCards(props.summary, props.range) : []))
const barHeight = computed(() => (props.isDesktop ? DESKTOP_BAR_HEIGHT : MOBILE_BAR_HEIGHT))
const CARD_PADDING = 'px-3 py-2.5 md:px-3.5 md:py-3'
const BAR_TONES: Record<StripCard['tone'], string> = {
neutral: 'bg-(--ui-text-muted)',
error: 'bg-error',
warning: 'bg-warning',
calm: 'bg-(--ui-border-accented)',
}
const VALUE_TONES: Record<StripCard['tone'], string> = {
neutral: 'text-highlighted',
error: 'text-error',
warning: 'text-warning',
calm: 'text-highlighted',
}
function pressed(key: StripKey) {
if (key === 'failed') return props.outcome === 'failed'
if (key === 'security') return props.security
return false
}
function rootClass(card: StripCard) {
const fill =
card.tone === 'error'
? 'bg-(--rc-error-soft)'
: card.tone === 'warning'
? 'bg-(--rc-warning-soft)'
: 'bg-default'
const edge = pressed(card.key)
? 'border-primary ring-1 ring-primary ring-inset'
: '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,
]
}
</script>
<template>
<div
data-testid="activity-strip"
class="grid grid-cols-4 gap-3 max-md:-mx-4 max-md:flex max-md:gap-2 max-md:overflow-hidden max-md:px-4"
>
<template v-if="loading">
<UCard
v-for="key in STRIP_KEYS"
:key="key"
data-testid="activity-strip-skeleton"
:ui="{
root: 'border border-default bg-default ring-0 max-md:w-39 max-md:flex-none',
body: 'flex flex-col gap-2 p-3.5 sm:p-3.5 max-md:px-3 max-md:py-2.5 max-md:sm:px-3 max-md:sm:py-2.5',
}"
>
<USkeleton class="h-3 w-11/20 rounded-none bg-accented" />
<USkeleton class="h-5.5 w-2/5 rounded-none bg-accented" />
<USkeleton class="h-2.5 w-7/10 rounded-none bg-accented" />
</UCard>
</template>
<template v-else>
<UCard
v-for="card in cards"
:key="card.key"
as="button"
type="button"
data-testid="activity-strip-card"
:data-key="card.key"
:aria-pressed="
card.key === 'failed' || card.key === 'security' ? pressed(card.key) : undefined
"
:class="rootClass(card)"
:ui="{
root: 'overflow-visible rounded-none shadow-none md:min-w-0',
body: 'p-0 sm:p-0',
}"
@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
aria-hidden="true"
data-testid="activity-bars"
class="flex shrink-0 items-end gap-0.5"
:style="{ height: `${barHeight}px` }"
>
<span
v-for="(height, index) in barHeights(card.series, barHeight)"
:key="index"
class="block w-1"
:class="BAR_TONES[card.tone]"
:style="{ height: `${height}px` }"
/>
</span>
</span>
<span
class="text-stat-sm font-semibold md:text-stat"
:class="VALUE_TONES[card.tone]"
>{{ card.value }}</span
>
<span
class="flex items-center gap-1 truncate text-xs/5"
:class="card.tone === 'calm' ? 'text-success' : 'text-muted'"
>
<UIcon v-if="card.icon" :name="card.icon" class="size-3 shrink-0" />
<span class="truncate">{{ card.subline }}</span>
</span>
</span>
</UCard>
</template>
</div>
</template>
+148
View File
@@ -0,0 +1,148 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import ActivityActor from '@/components/ActivityActor.vue'
import ActivityBadge from '@/components/ActivityBadge.vue'
import ActivitySentence from '@/components/ActivitySentence.vue'
import ActivityTargetCell from '@/components/ActivityTargetCell.vue'
import { ACTIVITY_SKELETON_ROWS } from '@/composables/useActivity'
import { categoryIcon, outcomeBadge, rowSurface, type ActivityItem } from '@/lib/activity'
import { fullLocalTime, groupByDay, whenLabel } from '@/lib/activityTime'
const FULL_GRID = 'grid-cols-[96px_210px_minmax(0,1fr)_230px_120px]'
const OWN_GRID = 'grid-cols-[96px_minmax(0,1fr)_230px_120px]'
const SKELETON_ACTION_WIDTHS = ['w-7/10', 'w-11/20', 'w-41/50']
const props = defineProps<{
items: ActivityItem[]
loading: boolean
selectedId: number | null
showActor: boolean
}>()
const emit = defineEmits<{
open: [item: ActivityItem, opener: HTMLElement]
}>()
const grid = computed(() => `grid ${props.showActor ? FULL_GRID : OWN_GRID}`)
const groups = computed(() => groupByDay(props.items))
function open(item: ActivityItem, event: Event) {
emit('open', item, event.currentTarget as HTMLElement)
}
function rowClass(item: ActivityItem) {
return [grid.value, ...rowSurface(props.selectedId === item.id, item.security)]
}
</script>
<template>
<div
role="table"
:aria-label="t('Activity')"
:aria-busy="loading ? 'true' : undefined"
class="flex flex-col overflow-hidden border border-default bg-default"
>
<div
role="row"
:class="grid"
class="h-9 shrink-0 items-center bg-elevated pr-4 font-medium text-muted"
>
<span role="columnheader" class="flex items-center gap-1 pl-4 text-highlighted">
{{ t('When') }}
<UIcon name="i-lucide-arrow-down" class="size-3 shrink-0" />
</span>
<span v-if="showActor" role="columnheader">{{ t('Actor') }}</span>
<span role="columnheader">{{ t('Action') }}</span>
<span role="columnheader">{{ t('Target') }}</span>
<span role="columnheader">{{ t('Outcome') }}</span>
</div>
<template v-if="loading">
<span role="status" class="sr-only">{{ t('Loading activity') }}</span>
<div
v-for="n in ACTIVITY_SKELETON_ROWS"
:key="n"
role="row"
:class="grid"
class="h-13 items-center border-t border-default pr-4"
data-testid="activity-skeleton-row"
>
<span class="pl-4"><USkeleton class="h-3 w-14 rounded-none bg-accented" /></span>
<span v-if="showActor" class="flex items-center gap-2">
<USkeleton class="size-6 shrink-0 rounded-none bg-accented" />
<USkeleton class="h-3 w-22.5 rounded-none bg-accented" />
</span>
<span>
<USkeleton
class="h-3 rounded-none bg-accented"
:class="SKELETON_ACTION_WIDTHS[(n - 1) % 3]"
/>
</span>
<span><USkeleton class="h-3 w-3/5 rounded-none bg-accented" /></span>
<span><USkeleton class="h-5 w-18 rounded-none bg-accented" /></span>
</div>
</template>
<template v-else>
<template v-for="group in groups" :key="group.key">
<div role="row" data-testid="activity-day">
<div
role="cell"
class="flex h-8 items-center border-t border-default bg-muted px-4 text-xs font-semibold text-highlighted"
>
{{ group.label }}
</div>
</div>
<div
v-for="item in group.items"
:key="item.id"
role="row"
tabindex="0"
data-testid="activity-row"
:data-activity-id="item.id"
:aria-selected="selectedId === item.id"
:class="rowClass(item)"
class="min-h-13 cursor-pointer items-center border-t border-default pr-4 hover:bg-elevated focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary"
@click="open(item, $event)"
@keydown.enter.self.prevent="open(item, $event)"
>
<span
role="cell"
data-testid="activity-when"
:title="fullLocalTime(item.created_at)"
class="pl-4 whitespace-nowrap text-toned"
>
{{ whenLabel(item.created_at) }}
</span>
<span v-if="showActor" role="cell" class="min-w-0 pr-3">
<ActivityActor :actor="item.actor" />
</span>
<span role="cell" class="flex min-w-0 items-center gap-2.5 pr-4">
<UIcon
:name="categoryIcon(item.category)"
class="size-4 shrink-0 text-muted"
/>
<span class="truncate text-default">
<ActivitySentence :item="item" />
</span>
<UIcon
v-if="item.security"
name="i-lucide-shield-alert"
role="img"
:aria-label="t('Security')"
class="size-3.5 shrink-0 text-warning"
/>
</span>
<span role="cell" class="min-w-0">
<ActivityTargetCell :target="item.target" />
</span>
<span role="cell" class="flex">
<ActivityBadge v-bind="outcomeBadge(item.outcome)" />
</span>
</div>
</template>
</template>
</div>
</template>
+34
View File
@@ -0,0 +1,34 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import ActivityBadge from '@/components/ActivityBadge.vue'
import { targetLink, targetTypeLabel, type ActivityTarget } from '@/lib/activity'
const props = defineProps<{
target: ActivityTarget | null
}>()
const link = computed(() => targetLink(props.target))
</script>
<template>
<span v-if="!target" class="text-muted">—</span>
<span v-else class="flex min-w-0 flex-col pr-3" data-testid="activity-target">
<span v-if="!target.exists" class="flex min-w-0 items-center gap-1.5">
<span class="min-w-0 flex-1 truncate text-muted line-through">{{ target.name }}</span>
<ActivityBadge tone="deleted" :label="t('Deleted')" />
</span>
<RouterLink
v-else-if="link"
:to="link"
class="flex min-w-0 items-center gap-1 text-highlighted underline underline-offset-3"
@click.stop
>
<span class="truncate">{{ target.name }}</span>
<UIcon name="i-lucide-arrow-up-right" class="size-3 shrink-0 text-muted" />
</RouterLink>
<span v-else class="truncate text-highlighted">{{ target.name }}</span>
<span class="text-xs/5 text-muted">{{ targetTypeLabel(target.type) }}</span>
</span>
</template>
+7
View File
@@ -92,6 +92,13 @@ const navItems = computed(() =>
to: '/users',
icon: 'i-lucide-users',
},
{
get label() {
return t('Activity')
},
to: '/activity',
icon: 'i-lucide-history',
},
{
get label() {
return t('Integration')
@@ -58,8 +58,8 @@ afterEach(async () => {
document.body.innerHTML = ''
})
describe('DashboardShell renders the six navigation destinations', () => {
it('lists Home, Articles, Media, Users, Integration and Settings in order, with only Articles carrying aria-current', async () => {
describe('DashboardShell renders the seven navigation destinations', () => {
it('lists Home, Articles, Media, Users, Activity, Integration and Settings in order, with only Articles carrying aria-current', async () => {
const wrapper = await mountShell('/article')
const hrefs = wrapper
@@ -70,6 +70,7 @@ describe('DashboardShell renders the six navigation destinations', () => {
'/article',
'/media',
'/users',
'/activity',
'/integration',
'/settings',
])
@@ -85,7 +86,7 @@ describe('DashboardShell renders the six navigation destinations', () => {
expect(integration.attributes('aria-current')).toBeUndefined()
})
it('lists the same six destinations in the mobile drawer and Settings current', async () => {
it('lists the same seven destinations in the mobile drawer and Settings current', async () => {
await mountShell('/settings', { width: MOBILE_WIDTH })
const toggle = document.body.querySelector('[data-slot="toggle"]') as HTMLButtonElement
@@ -100,11 +101,12 @@ describe('DashboardShell renders the six navigation destinations', () => {
'/article',
'/media',
'/users',
'/activity',
'/integration',
'/settings',
])
expect(links[5].querySelector('[class*="lucide"], svg')).not.toBeNull()
expect(links[5].getAttribute('aria-current')).toBe('page')
expect(links[6].querySelector('[class*="lucide"], svg')).not.toBeNull()
expect(links[6].getAttribute('aria-current')).toBe('page')
})
it('marks Media with aria-current on /media, in the sidebar and in the mobile drawer', async () => {
@@ -263,11 +265,11 @@ describe('The account chip shows the display name and no item is current on /pro
expect(trigger?.getAttribute('aria-label')).toBe('Marta Alves — account menu')
})
it('marks none of the six navigation items current on /profile', async () => {
it('marks none of the seven navigation items current on /profile', async () => {
const wrapper = await mountShell('/profile')
const links = wrapper.findAll('nav[aria-label="Main"] a')
expect(links).toHaveLength(6)
expect(links).toHaveLength(7)
links.forEach((link) => expect(link.attributes('aria-current')).toBeUndefined())
})
})
+69
View File
@@ -0,0 +1,69 @@
import { useApi } from '@/composables/useApi'
import {
exportQuery,
listQuery,
summaryQuery,
type ActivityFilters,
type ActivityPage,
type ActivitySummary,
} from '@/lib/activity'
import type { UserListItem } from '@/composables/useUsers'
import type { ApiKey } from '@/composables/useApiKeys'
export interface PersonOptions {
users: { id: number; name: string }[]
keys: { id: number; name: string }[]
}
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()
function listActivity(
filters: ActivityFilters,
page: number,
size: number,
withActor = true,
): Promise<ActivityPage> {
return request<ActivityPage>(`/v1/activity?${listQuery(filters, page, size, withActor)}`)
}
function getSummary(filters: ActivityFilters): Promise<ActivitySummary> {
return request<ActivitySummary>(`/v1/activity/summary?${summaryQuery(filters)}`)
}
function exportActivity(filters: ActivityFilters): Promise<Blob> {
return requestBlob(`/v1/activity/export?${exportQuery(filters)}`)
}
async function listPeople(): Promise<PersonOptions> {
const [users, keys] = await Promise.all([
request<UserListItem[]>('/v1/user/'),
request<ApiKey[]>('/v1/api-key/'),
])
return {
users: users.flatMap((item) =>
item.kind === 'user'
? [{ id: item.id, name: item.display_name || item.username }]
: [],
),
keys: keys.map((key) => ({ id: key.id, name: key.name })),
}
}
return { listActivity, getSummary, exportActivity, listPeople }
}
export function downloadBlob(blob: Blob, fileName: string) {
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = fileName
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
URL.revokeObjectURL(url)
}
+316
View File
@@ -0,0 +1,316 @@
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 { useSessionStore } from '@/stores/session'
import {
activeChips,
clearChip,
defaultFilters,
DEFAULT_PAGE_SIZE,
summaryRangeChanged,
type ActivityFilters,
type ActivityItem,
type ActivitySummary,
type ChipKey,
} from '@/lib/activity'
export type ListState = 'idle' | 'loading' | 'ready' | 'error' | 'forbidden'
export type SummaryState = 'idle' | 'loading' | 'ready' | 'failed'
const SKELETON_DELAY_MS = 150
const HANDLED_STATUSES = [401]
function delayedFlag() {
const flag = ref(false)
let timer: ReturnType<typeof setTimeout> | null = null
function stop() {
if (timer !== null) {
clearTimeout(timer)
timer = null
}
flag.value = false
}
function arm() {
stop()
timer = setTimeout(() => {
timer = null
flag.value = true
}, SKELETON_DELAY_MS)
}
return { flag, arm, stop }
}
export function useActivityPage() {
const session = useSessionStore()
const toast = useLocalizedToast()
const { listActivity, getSummary, exportActivity, listPeople } = useActivity()
const filters = ref<ActivityFilters>(defaultFilters())
const page = ref(1)
const size = ref(DEFAULT_PAGE_SIZE)
const items = ref<ActivityItem[]>([])
const total = ref(0)
const listState = ref<ListState>('idle')
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()
const summarySkeleton = delayedFlag()
let listSequence = 0
let summarySequence = 0
let peopleRequested = false
let requestedPage = 1
const role = computed(() => session.session?.role as string | undefined)
const isFullView = computed(() => role.value === 'owner' || role.value === 'admin')
const chips = computed(() => activeChips(filters.value))
const showListSkeleton = computed(
() => listState.value === 'loading' && listSkeleton.flag.value,
)
const showSummarySkeleton = computed(
() => summaryState.value === 'loading' && summarySkeleton.flag.value,
)
const isEmpty = computed(() => listState.value === 'ready' && total.value === 0)
const exportDisabled = computed(
() =>
exporting.value ||
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))
const rangeEnd = computed(() => Math.min(total.value, page.value * size.value))
const selectedIndex = computed(() =>
items.value.findIndex((item) => item.id === selectedId.value),
)
const selectedItem = computed(() => items.value[selectedIndex.value] ?? null)
const position = computed(() =>
selectedIndex.value < 0 ? 0 : (page.value - 1) * size.value + selectedIndex.value + 1,
)
const canPrevious = computed(() => position.value > 1 && !navigating.value)
const canNext = computed(
() => position.value > 0 && position.value < total.value && !navigating.value,
)
function failureState(error: unknown): ListState {
if (error instanceof ApiError && HANDLED_STATUSES.includes(error.status)) return 'idle'
if (error instanceof ApiError && error.status === 403) {
return session.session?.verified === false ? 'idle' : 'forbidden'
}
return 'error'
}
async function fetchPage(
nextPage: number,
options: { silent?: boolean; select?: 'first' | 'last' } = {},
) {
const sequence = ++listSequence
if (!options.silent) {
requestedPage = nextPage
listState.value = 'loading'
listSkeleton.arm()
}
try {
const result = await listActivity(filters.value, nextPage, size.value, isFullView.value)
if (sequence !== listSequence) return false
items.value = result.items
total.value = result.total
page.value = nextPage
listState.value = 'ready'
if (options.select && result.items.length > 0) {
const target = options.select === 'first' ? 0 : result.items.length - 1
selectedId.value = result.items[target]!.id
}
return true
} catch (error) {
if (sequence !== listSequence) return false
if (!options.silent) listState.value = failureState(error)
return false
} finally {
if (sequence === listSequence) listSkeleton.stop()
}
}
function loadList() {
return fetchPage(1)
}
async function loadSummary() {
if (!isFullView.value) return
const sequence = ++summarySequence
summaryState.value = 'loading'
summarySkeleton.arm()
try {
const result = await getSummary(filters.value)
if (sequence !== summarySequence) return
summary.value = result
summaryState.value = 'ready'
} catch {
if (sequence !== summarySequence) return
summary.value = null
summaryState.value = 'failed'
} finally {
if (sequence === summarySequence) summarySkeleton.stop()
}
}
function start() {
loadList()
loadSummary()
}
function applyFilters(next: ActivityFilters, force = false) {
const current = filters.value
if (!force && JSON.stringify(next) === JSON.stringify(current)) return
const rangeChanged = summaryRangeChanged(current, next)
filters.value = next
loadList()
if (rangeChanged) loadSummary()
}
function removeChip(key: ChipKey) {
applyFilters(clearChip(filters.value, key))
}
function clearAll() {
applyFilters(defaultFilters(), true)
}
function goToPage(next: number) {
if (next === page.value || next < 1 || next > pageCount.value) return
fetchPage(next)
}
function setSize(next: number) {
if (next === size.value) return
size.value = next
fetchPage(1)
}
function retry() {
fetchPage(requestedPage)
}
async function ensurePeople() {
if (peopleRequested) return
peopleRequested = true
try {
people.value = await listPeople()
} catch {
peopleRequested = false
}
}
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
}
function closeDetails() {
selectedId.value = null
}
async function step(direction: -1 | 1) {
const index = selectedIndex.value
if (index < 0 || navigating.value || listState.value === 'loading') return
const target = index + direction
const neighbour = items.value[target]
if (neighbour) {
selectedId.value = neighbour.id
return
}
if (direction === 1 ? position.value >= total.value : page.value <= 1) return
navigating.value = true
await fetchPage(page.value + direction, {
silent: true,
select: direction === 1 ? 'first' : 'last',
})
navigating.value = false
}
onBeforeUnmount(() => {
listSkeleton.stop()
summarySkeleton.stop()
})
return {
filters,
page,
size,
items,
total,
listState,
summary,
summaryState,
people,
exporting,
chips,
isFullView,
isEmpty,
exportDisabled,
showListSkeleton,
showSummarySkeleton,
pageCount,
rangeStart,
rangeEnd,
selectedId,
selectedItem,
position,
canPrevious,
canNext,
start,
applyFilters,
removeChip,
clearAll,
goToPage,
setSize,
retry,
ensurePeople,
runExport,
openDetails,
closeDetails,
previous: () => step(-1),
next: () => step(1),
}
}
+13 -2
View File
@@ -101,7 +101,7 @@ export function redirectUnverified(onForbidden: OnForbidden = 'redirect') {
}
export function useApi() {
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
async function send(path: string, options: RequestOptions = {}): Promise<Response> {
const { onUnauthorized = 'redirect', onForbidden = 'redirect', ...init } = options
let response: Response
@@ -132,6 +132,12 @@ export function useApi() {
throw new ApiError(response.status, undefined, await readErrorBody(response))
}
return response
}
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
const response = await send(path, options)
if (response.status === 204) {
return undefined as T
}
@@ -139,6 +145,11 @@ export function useApi() {
return response.json()
}
async function requestBlob(path: string, options: RequestOptions = {}): Promise<Blob> {
const response = await send(path, options)
return response.blob()
}
async function getMe(): Promise<MeResult> {
const body = await request<{
id?: number
@@ -158,5 +169,5 @@ export function useApi() {
}
}
return { getMe, request }
return { getMe, request, requestBlob }
}
+179
View File
@@ -1004,6 +1004,185 @@ export const messages = {
'La médiathèque a changé, rien n’a donc été purgé. Vérifiez le nouveau récapitulatif et réessayez.',
'A purge is already running.': 'Une purge est déjà en cours.',
'Duplicates purged': 'Doublons purgés',
Activity: 'Activité',
'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 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.",
'Total actions': 'Actions au total',
'Active people': 'Personnes actives',
'Failed actions': 'Actions en échec',
'Security events': 'Événements de sécurité',
'{0}% vs previous 7 days': '{0} % par rapport aux 7 jours précédents',
'{0}% vs previous day': '{0} % par rapport au jour précédent',
'{0}% vs previous 30 days': '{0} % par rapport aux 30 jours précédents',
'{0}% vs previous period': '{0} % par rapport à la période précédente',
'No earlier data': 'Aucune donnée antérieure',
'No change': 'Aucun changement',
'No data yet': 'Aucune donnée pour le moment',
'{0} people': '{0} personnes',
'1 person': '1 personne',
'{0} API keys': "{0} clés d'API",
'1 API key': "1 clé d'API",
'{0}% of all actions': '{0} % de toutes les actions',
'{0} flagged': '{0} signalés',
'1 flagged': '1 signalé',
'Nothing failed': 'Aucun échec',
'Nothing to flag': 'Rien à signaler',
'Search activity': "Rechercher dans l'activité",
'All people': 'Toutes les personnes',
'All categories': 'Toutes les catégories',
Succeeded: 'Réussie',
Failed: 'Échec',
'7 days': '7 jours',
'30 days': '30 jours',
Custom: 'Personnalisée',
Person: 'Personne',
Category: 'Catégorie',
Outcome: 'Résultat',
Dates: 'Dates',
Filters: 'Filtres',
'Clear all': 'Tout effacer',
'Search: {0}': 'Recherche : {0}',
'Person: {0}': 'Personne : {0}',
'Category: {0}': 'Catégorie : {0}',
'Outcome: {0}': 'Résultat : {0}',
'Dates: {0}': 'Dates : {0}',
'Remove filter {0}': 'Retirer le filtre {0}',
'Show {0} results': 'Afficher {0} résultats',
'Show 1 result': 'Afficher 1 résultat',
'Showing {0}–{1} of {2}': 'Affichage de {0} à {1} sur {2}',
'Page {0} of {1}': 'Page {0} sur {1}',
'{0}–{1} of {2}': '{0} à {1} sur {2}',
'Rows per page': 'Lignes par page',
'Previous page': 'Page précédente',
'Next page': 'Page suivante',
'Start date': 'Date de début',
'End date': 'Date de fin',
When: 'Quand',
Actor: 'Acteur',
Action: 'Action',
Target: 'Cible',
'Not signed in': 'Non connecté',
Deleted: 'Supprimé',
'Target deleted': 'Cible supprimée',
Security: 'Sécurité',
Article: 'Article',
User: 'Utilisateur',
Invite: 'Invitation',
Release: 'Version',
'{0} min ago': 'Il y a {0} min',
'{0} h ago': 'Il y a {0} h',
'No activity yet': 'Aucune activité pour le moment',
'Actions on articles, media, users and API keys will appear here.':
"Les actions sur les articles, les médias, les utilisateurs et les clés d'API apparaîtront ici.",
'Your actions will appear here once you start working on the site.':
'Vos actions apparaîtront ici dès que vous commencerez à travailler sur le site.',
'No activity matches these filters': 'Aucune activité ne correspond à ces filtres',
'Try a different search or a wider date range.':
'Essayez une autre recherche ou une période plus large.',
"Couldn't load activity": "Impossible de charger l'activité",
"You can't view this activity": 'Vous ne pouvez pas consulter cette activité',
"Your role doesn't include this page. Ask an owner or admin if you need access.":
"Votre rôle n'inclut pas cette page. Demandez à un propriétaire ou à un administrateur si vous avez besoin d'y accéder.",
'Back to home': "Retour à l'accueil",
'Loading activity': "Chargement de l'activité",
'Activity details': "Détails de l'activité",
'Why it failed': "Raison de l'échec",
Source: 'Source',
'IP address': 'Adresse IP',
Browser: 'Navigateur',
'Web app': 'Application web',
'System task': 'Tâche système',
'Not applicable': 'Sans objet',
Changes: 'Modifications',
Before: 'Avant',
After: 'Après',
'Show more': 'Voir plus',
'Show less': 'Voir moins',
'Previous event': 'Événement précédent',
'Next event': 'Événement suivant',
'Event {0} of {1}': 'Événement {0} sur {1}',
'{0} of {1}': '{0} sur {1}',
'{0} (your time)': '{0} (votre heure)',
'{0} UTC': '{0} UTC',
'This item was deleted. Its name is kept for reference.':
'Cet élément a été supprimé. Son nom est conservé pour référence.',
'Body edited ({0} characters)': 'Corps modifié ({0} caractères)',
'Body edited ({0} character)': 'Corps modifié ({0} caractère)',
'Publish state': 'État de publication',
Body: 'Corps',
'File name': 'Nom du fichier',
Published: 'Publié',
'Created the article {0}': 'Article créé : {0}',
'Edited the article {0}': 'Article modifié : {0}',
'Published the article {0}': 'Article publié : {0}',
'Unpublished the article {0}': 'Article dépublié : {0}',
'Deleted the article {0}': 'Article supprimé : {0}',
'Uploaded the image {0}': 'Image téléversée : {0}',
'Renamed the image {0}': 'Image renommée : {0}',
'Deleted the image {0}': 'Image supprimée : {0}',
'Merged duplicates into the image {0}': "Doublons fusionnés dans l'image {0}",
'Purged duplicate images: {0}': 'Doublons purgés : {0}',
'Ran the scheduled duplicate purge: {0}': 'Purge automatique des doublons : {0}',
'{0} articles updated': '{0} articles mis à jour',
'1 article updated': '1 article mis à jour',
'{0} groups failed': '{0} groupes en échec',
'1 group failed': '1 groupe en échec',
'Registered the account {0}': 'Compte créé : {0}',
'Verified the account {0}': 'Compte vérifié : {0}',
'Updated the profile of {0}': 'Profil mis à jour : {0}',
'Invited {0}': 'Invitation envoyée à {0}',
'Cancelled the invite for {0}': 'Invitation annulée : {0}',
'Renewed the invite for {0}': 'Invitation renouvelée : {0}',
'Accepted the invite for {0}': 'Invitation acceptée : {0}',
'Created API key {0}': "Clé d'API créée : {0}",
'Revoked API key {0}': "Clé d'API révoquée : {0}",
'Logged in': 'Connexion',
'Logged out': 'Déconnexion',
'Requested a password reset': 'Réinitialisation du mot de passe demandée',
'Reset the password': 'Mot de passe réinitialisé',
'Checked for updates': 'Recherche de mises à jour',
'Downloaded the update {0}': 'Mise à jour téléchargée : {0}',
'Requested a restart': 'Redémarrage demandé',
'Restarted to apply the update {0}': 'Redémarrage pour appliquer la mise à jour {0}',
'Changed the activity retention from {0} to {1}':
"Durée de conservation de l'activité modifiée : {0} → {1}",
'Removed {0} old events from the activity log':
"{0} anciens événements supprimés du journal d'activité",
'Removed 1 old event from the activity log':
"1 ancien événement supprimé du journal d'activité",
'{0} days': '{0} jours',
Forever: 'Toujours',
'Failed to create the article {0}': "Échec de la création de l'article {0}",
'Failed to edit the article {0}': "Échec de la modification de l'article {0}",
'Failed to publish the article {0}': "Échec de la publication de l'article {0}",
'Failed to unpublish the article {0}': "Échec de la dépublication de l'article {0}",
'Failed to delete the article {0}': "Échec de la suppression de l'article {0}",
'Failed to upload the image {0}': "Échec du téléversement de l'image {0}",
'Failed to rename the image {0}': "Échec du renommage de l'image {0}",
'Failed to delete the image {0}': "Échec de la suppression de l'image {0}",
'Failed to merge duplicates into the image {0}':
"Échec de la fusion des doublons dans l'image {0}",
'Failed to purge duplicate images': 'Échec de la purge des doublons',
'Failed to run the scheduled duplicate purge': 'Échec de la purge automatique des doublons',
'Failed to register the account {0}': 'Échec de la création du compte {0}',
'Failed to verify the account {0}': 'Échec de la vérification du compte {0}',
'Failed to update the profile of {0}': 'Échec de la mise à jour du profil de {0}',
'Failed to invite {0}': "Échec de l'invitation de {0}",
'Failed to cancel the invite for {0}': "Échec de l'annulation de l'invitation de {0}",
'Failed to renew the invite for {0}': "Échec du renouvellement de l'invitation de {0}",
'Failed to accept the invite for {0}': "Échec de l'acceptation de l'invitation de {0}",
'Failed to create API key {0}': "Échec de la création de la clé d'API {0}",
'Failed to revoke API key {0}': "Échec de la révocation de la clé d'API {0}",
'Failed login attempt for {0}': 'Échec de connexion pour {0}',
'Failed to check for updates': 'Échec de la recherche de mises à jour',
'Failed to download the update {0}': 'Échec du téléchargement de la mise à jour {0}',
'Failed to restart': 'Échec du redémarrage',
} as const
export type MessageKey = keyof typeof messages
+305
View File
@@ -0,0 +1,305 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { initializeLocale, setLocale } from '@/i18n'
import {
activeChips,
bodyDeltaLabel,
changeValue,
clearChip,
defaultFilters,
exportQuery,
fieldLabel,
initials,
listQuery,
summaryQuery,
targetLink,
type ActivityFilters,
} from '../activity'
import { dayLabel, formatDateRange, groupByDay, whenLabel } from '../activityTime'
import { barHeights, stripCards } from '../activitySummary'
import { summaryDefault, summaryNone, summaryZero } from '@/views/__tests__/activityFixtures'
beforeEach(() => {
localStorage.clear()
initializeLocale()
})
afterEach(() => {
localStorage.clear()
initializeLocale()
})
function filters(overrides: Partial<ActivityFilters> = {}): ActivityFilters {
return { ...defaultFilters(), ...overrides }
}
function entries(query: string) {
return Object.fromEntries(new URLSearchParams(query))
}
describe('Queries', () => {
it('always sends range, page and size and nothing else by default', () => {
expect(entries(listQuery(filters(), 1, 25))).toEqual({ range: '7d', page: '1', size: '25' })
})
it('adds q, actor, category, outcome and security only when set', () => {
const query = listQuery(
filters({
q: 'spring',
actor: 'user:3',
category: 'auth',
outcome: 'failed',
security: true,
}),
2,
50,
)
expect(entries(query)).toEqual({
q: 'spring',
actor: 'user:3',
category: 'auth',
outcome: 'failed',
security: 'true',
range: '7d',
page: '2',
size: '50',
})
})
it('sends from and to only with the custom range', () => {
expect(
entries(
listQuery(filters({ range: '30d', from: '2026-09-01', to: '2026-09-30' }), 1, 25),
),
).toEqual({ range: '30d', page: '1', size: '25' })
expect(
entries(
listQuery(
filters({ range: 'custom', from: '2026-09-01', to: '2026-09-30' }),
1,
25,
),
),
).toEqual({ range: 'custom', from: '2026-09-01', to: '2026-09-30', page: '1', size: '25' })
})
it('never sends an actor for a regular user', () => {
expect(entries(listQuery(filters({ actor: 'system' }), 1, 25, false)).actor).toBeUndefined()
})
it('keeps the summary to the date range and the export to the list filters', () => {
const all = filters({
q: 'x',
category: 'media',
range: 'custom',
from: '2026-09-01',
to: '2026-09-30',
})
expect(entries(summaryQuery(all))).toEqual({
range: 'custom',
from: '2026-09-01',
to: '2026-09-30',
})
expect(entries(exportQuery(all))).toEqual({
q: 'x',
category: 'media',
range: 'custom',
from: '2026-09-01',
to: '2026-09-30',
})
})
})
describe('Chips', () => {
it('lists the active filters in order and never the default range', () => {
expect(activeChips(filters())).toEqual([])
expect(
activeChips(
filters({
q: 'spring',
actor: 'user:3',
actorLabel: 'Marta Alves',
category: 'auth',
outcome: 'failed',
security: true,
range: '30d',
}),
).map((chip) => chip.label),
).toEqual([
'Search: spring',
'Person: Marta Alves',
'Category: Authentication',
'Outcome: Failed',
'Security events',
'Dates: 30 days',
])
})
it('words custom ranges', () => {
const range = (from: string, to: string) =>
activeChips(filters({ range: 'custom', from, to }))[0]!.label
expect(range('2026-09-01', '2026-09-30')).toBe('Dates: 1 – 30 Sep 2026')
expect(range('2026-09-28', '2026-10-03')).toBe('Dates: 28 Sep – 3 Oct 2026')
expect(range('2025-12-28', '2026-01-03')).toBe('Dates: 28 Dec 2025 – 3 Jan 2026')
expect(range('2026-09-05', '2026-09-05')).toBe('Dates: 5 Sep 2026')
})
it('words custom ranges in French', () => {
setLocale('fr')
expect(formatDateRange('2026-10-01', '2026-10-30')).toBe('1 – 30 oct. 2026')
})
it('resets one filter at a time', () => {
const all = filters({ q: 'a', actor: 'system', actorLabel: 'System', range: '30d' })
expect(clearChip(all, 'q').q).toBe('')
expect(clearChip(all, 'actor')).toMatchObject({ actor: '', actorLabel: '' })
expect(clearChip(all, 'range').range).toBe('7d')
})
})
describe('Times', () => {
const now = new Date(2026, 9, 6, 15, 0, 0)
it('labels days as Today, Yesterday or the date', () => {
expect(dayLabel(new Date(2026, 9, 6, 9, 0).toISOString(), now)).toBe('Today')
expect(dayLabel(new Date(2026, 9, 5, 23, 59).toISOString(), now)).toBe('Yesterday')
expect(dayLabel(new Date(2026, 9, 3, 14, 20).toISOString(), now)).toBe('3 Oct 2026')
setLocale('fr')
expect(dayLabel(new Date(2026, 9, 6, 9, 0).toISOString(), now)).toBe('Aujourd’hui')
expect(dayLabel(new Date(2026, 9, 3, 14, 20).toISOString(), now)).toBe('3 oct. 2026')
})
it('reads relative within today and as a clock time before', () => {
expect(whenLabel(new Date(2026, 9, 6, 14, 58).toISOString(), now)).toBe('2 min ago')
expect(whenLabel(new Date(2026, 9, 6, 14, 31).toISOString(), now)).toBe('29 min ago')
expect(whenLabel(new Date(2026, 9, 6, 14, 0).toISOString(), now)).toBe('1 h ago')
expect(whenLabel(new Date(2026, 9, 5, 18, 20).toISOString(), now)).toBe('18:20')
expect(whenLabel(new Date(2026, 9, 6, 14, 59, 50).toISOString(), now)).toBe('1 min ago')
})
it('groups consecutive items of the same local day in API order', () => {
const groups = groupByDay(
[
{ created_at: new Date(2026, 9, 6, 12).toISOString(), id: 1 },
{ created_at: new Date(2026, 9, 6, 9).toISOString(), id: 2 },
{ created_at: new Date(2026, 9, 5, 20).toISOString(), id: 3 },
],
now,
)
expect(groups.map((group) => [group.label, group.items.map((entry) => entry.id)])).toEqual([
['Today', [1, 2]],
['Yesterday', [3]],
])
})
})
describe('Labels', () => {
it('takes the first letters of the first two words', () => {
expect(initials('Marta Alves')).toBe('MA')
expect(initials('inès duarte jr')).toBe('ID')
expect(initials('Madonna')).toBe('M')
})
it('links each target type, and nothing for a deleted or unknown one', () => {
const link = (type: string, exists = true) =>
targetLink({ type, id: '8', name: 'x', exists })
expect(link('article')).toBe('/article/8/edit')
expect(link('image')).toBe('/media')
expect(link('user')).toBe('/users')
expect(link('invite')).toBe('/users')
expect(link('api_key')).toBe('/integration/api-keys')
expect(link('release')).toBeNull()
expect(link('article', false)).toBeNull()
expect(targetLink(null)).toBeNull()
})
it('labels fields and values, keeping unknown codes', () => {
expect(fieldLabel('title')).toBe('Title')
expect(fieldLabel('online')).toBe('Publish state')
expect(fieldLabel('mystery')).toBe('mystery')
expect(changeValue('online', 'false')).toBe('Draft')
expect(changeValue('online', 'true')).toBe('Published')
expect(changeValue('title', 'true')).toBe('true')
setLocale('fr')
expect(fieldLabel('filename')).toBe('Nom du fichier')
expect(changeValue('online', 'true')).toBe('Publié')
})
it('words body deltas with sign and singular', () => {
expect(bodyDeltaLabel(312)).toBe('Body edited (+312 characters)')
expect(bodyDeltaLabel(-48)).toBe('Body edited (-48 characters)')
expect(bodyDeltaLabel(1)).toBe('Body edited (+1 character)')
})
})
describe('Summary strip', () => {
it('derives the four cards from the summary', () => {
const cards = stripCards(summaryDefault, '7d')
expect(cards.map((card) => [card.label, card.value, card.subline])).toEqual([
['Total actions', '1,284', '12% vs previous 7 days'],
['Active people', '9', '7 people, 2 API keys'],
['Failed actions', '6', '0.5% of all actions'],
['Security events', '11', '11 flagged'],
])
expect(cards.map((card) => card.tone)).toEqual(['neutral', 'neutral', 'error', 'warning'])
expect(cards[0]!.icon).toBe('i-lucide-trending-up')
})
it('words the comparison for each range', () => {
const subline = (range: 'today' | '7d' | '30d' | 'custom') =>
stripCards(summaryDefault, range)[0]!.subline
expect(subline('today')).toBe('12% vs previous day')
expect(subline('30d')).toBe('12% vs previous 30 days')
expect(subline('custom')).toBe('12% vs previous period')
})
it('goes calm on zero and reads No data yet on an empty site', () => {
const quiet = stripCards(summaryZero, '7d')
expect(quiet[0]!.icon).toBe('i-lucide-trending-down')
expect(quiet.slice(2).map((card) => [card.tone, card.subline, card.icon])).toEqual([
['calm', 'Nothing failed', 'i-lucide-circle-check'],
['calm', 'Nothing to flag', 'i-lucide-circle-check'],
])
const none = stripCards(summaryNone, '7d')
expect(none.map((card) => card.subline)).toEqual([
'No data yet',
'No data yet',
'Nothing failed',
'Nothing to flag',
])
})
it('words singulars, the French forms and the flag count', () => {
const single = stripCards(
{ ...summaryDefault, active_people: { users: 1, api_keys: 1 }, security: 1 },
'7d',
)
expect(single[1]!.subline).toBe('1 person, 1 API key')
expect(single[3]!.subline).toBe('1 flagged')
setLocale('fr')
expect(stripCards(summaryDefault, '7d')[3]!.subline).toBe('11 signalés')
expect(stripCards({ ...summaryDefault, security: 1 }, '7d')[3]!.subline).toBe('1 signalé')
expect(
stripCards({ ...summaryDefault, total: 40, previous_total: 0 }, '7d')[0]!.subline,
).toBe('Aucune donnée antérieure')
expect(
stripCards({ ...summaryDefault, total: 40, previous_total: 40 }, '7d')[0]!.subline,
).toBe('Aucun changement')
})
it('sizes bars from the peak with a 2 px minimum', () => {
expect(barHeights([4, 6, 5, 8, 7, 9, 6], 20)).toEqual([9, 13, 11, 18, 16, 20, 13])
expect(barHeights([0, 0, 0, 0, 0, 0, 0], 20)).toEqual([2, 2, 2, 2, 2, 2, 2])
expect(barHeights([0, 1, 0, 0, 0, 0, 100], 16)).toEqual([2, 2, 2, 2, 2, 2, 16])
})
})
@@ -0,0 +1,240 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { initializeLocale, setLocale, t } from '@/i18n'
import { messages, type MessageKey } from '@/i18n/messages'
import type { ActivityItem } from '@/lib/activity'
import {
SENTENCE_ACTIONS,
composeSentence,
sentenceTemplates,
sentenceText,
} from '../activitySentence'
function item(overrides: Partial<ActivityItem>): ActivityItem {
return {
id: 1,
created_at: '2026-10-03T12:00:00Z',
action: 'article.publish',
category: 'articles',
actor: { type: 'user', id: 3, name: 'Marta Alves' },
target: { type: 'article', id: '8', name: 'Spring release', exists: true },
outcome: 'success',
failure_reason: null,
security: false,
source: 'web',
ip: null,
user_agent: null,
params: {},
changes: [],
...overrides,
}
}
beforeEach(() => {
localStorage.clear()
initializeLocale()
})
afterEach(() => {
localStorage.clear()
initializeLocale()
})
describe('Sentences: English and French come from the same code and params', () => {
const cases: [string, Partial<ActivityItem>, string, string][] = [
[
'article.publish',
{},
'Published the article Spring release',
'Article publié : Spring release',
],
[
'article.publish failed',
{ outcome: 'failed' },
'Failed to publish the article Spring release',
"Échec de la publication de l'article Spring release",
],
[
'media.purge',
{ action: 'media.purge', target: null, params: { merged: 3, articles_updated: 5 } },
'Purged duplicate images: 3 merged, 5 articles updated',
'Doublons purgés : 3 fusionnées, 5 articles mis à jour',
],
[
'media.purge with failed groups',
{
action: 'media.purge',
target: null,
params: { merged: 3, articles_updated: 1, failed_groups: 1 },
},
'Purged duplicate images: 3 merged, 1 article updated, 1 group failed',
'Doublons purgés : 3 fusionnées, 1 article mis à jour, 1 groupe en échec',
],
[
'system.media_purge with several failed groups',
{
action: 'system.media_purge',
target: null,
params: { merged: 10, articles_updated: 12, failed_groups: 3 },
},
'Ran the scheduled duplicate purge: 10 merged, 12 articles updated, 3 groups failed',
'Purge automatique des doublons : 10 fusionnées, 12 articles mis à jour, 3 groupes en échec',
],
[
'system.activity_cleanup singular',
{ action: 'system.activity_cleanup', target: null, params: { removed: 1 } },
'Removed 1 old event from the activity log',
"1 ancien événement supprimé du journal d'activité",
],
[
'system.activity_cleanup plural',
{ action: 'system.activity_cleanup', target: null, params: { removed: 1284 } },
'Removed 1,284 old events from the activity log',
"1 284 anciens événements supprimés du journal d'activité",
],
[
'system.activity_retention_changed',
{
action: 'system.activity_retention_changed',
target: null,
params: { from: 90, to: 0 },
},
'Changed the activity retention from 90 days to Forever',
"Durée de conservation de l'activité modifiée : 90 jours → Toujours",
],
[
'system.restart_requested update',
{
action: 'system.restart_requested',
target: { type: 'release', id: 'v1.4.0', name: 'v1.4.0', exists: true },
params: { kind: 'update', tag: 'v1.4.0' },
},
'Restarted to apply the update v1.4.0',
'Redémarrage pour appliquer la mise à jour v1.4.0',
],
[
'system.restart_requested restart',
{ action: 'system.restart_requested', target: null, params: { kind: 'restart' } },
'Requested a restart',
'Redémarrage demandé',
],
[
'media.upload failed without a target',
{ action: 'media.upload', target: null, outcome: 'failed' },
'Failed to upload the image',
"Échec du téléversement de l'image",
],
[
'article.publish without a target',
{ target: null },
'Published the article',
'Article publié',
],
[
'auth.login_failed uses the actor name',
{
action: 'auth.login_failed',
target: null,
outcome: 'failed',
actor: { type: 'anonymous', id: null, name: 'alex@example.com' },
},
'Failed login attempt for alex@example.com',
'Échec de connexion pour alex@example.com',
],
[
'auth.login failed renders its success sentence',
{ action: 'auth.login', target: null, outcome: 'failed' },
'Logged in',
'Connexion',
],
[
'unknown action code',
{ action: 'article.archive' },
'article.archive',
'article.archive',
],
]
it.each(cases)('%s', (_, overrides, english, french) => {
const subject = item(overrides)
expect(sentenceText(subject)).toBe(english)
setLocale('fr')
expect(sentenceText(subject)).toBe(french)
})
it('inserts the target name untranslated as the semibold last segment', () => {
const segments = composeSentence(item({}))
expect(segments).toEqual([
{ text: 'Published the article ', strong: false },
{ text: 'Spring release', strong: true },
])
setLocale('fr')
expect(composeSentence(item({}))).toEqual([
{ text: 'Article publié : ', strong: false },
{ text: 'Spring release', strong: true },
])
})
it('keeps a target name that looks like a template slot', () => {
const subject = item({
target: { type: 'article', id: '1', name: '{0} {1}', exists: true },
})
expect(sentenceText(subject)).toBe('Published the article {0} {1}')
})
})
describe('The catalogue is complete in both languages', () => {
const templateKeys = SENTENCE_ACTIONS.flatMap((action) => {
const template = sentenceTemplates(action)!
return [template.success, ...(template.failed ? [template.failed] : [])]
})
it.each(Array.from(new Set(templateKeys)))('has a French entry for %s', (key) => {
expect(messages).toHaveProperty([key as MessageKey])
expect(messages[key as MessageKey]).not.toBe('')
expect(messages[key as MessageKey]).not.toBe(key)
})
it('covers every action the producers record', () => {
expect(SENTENCE_ACTIONS.sort()).toEqual(
[
'article.create',
'article.edit',
'article.publish',
'article.unpublish',
'article.delete',
'media.upload',
'media.rename',
'media.delete',
'media.merge',
'media.purge',
'system.media_purge',
'user.register',
'user.verify',
'user.update_profile',
'invite.create',
'invite.cancel',
'invite.renew',
'invite.accept',
'api_key.create',
'api_key.revoke',
'auth.login',
'auth.login_failed',
'auth.logout',
'auth.password_reset_requested',
'auth.password_reset_completed',
'system.update_checked',
'system.update_downloaded',
'system.restart_requested',
'system.activity_retention_changed',
'system.activity_cleanup',
].sort(),
)
})
it('formats a template with placeholders through t', () => {
expect(t('Revoked API key {0}', ['Mobile app'])).toBe('Revoked API key Mobile app')
})
})
+350
View File
@@ -0,0 +1,350 @@
import { formatNumber, t } from '@/i18n'
import { formatDateRange } from '@/lib/activityTime'
export const ACTIVITY_CATEGORIES = [
'articles',
'media',
'users',
'api_keys',
'auth',
'system',
] as const
export type ActivityCategory = (typeof ACTIVITY_CATEGORIES)[number]
export type ActivityOutcome = 'success' | 'failed'
export type OutcomeFilter = 'all' | ActivityOutcome
export type ActorType = 'user' | 'api_key' | 'system' | 'anonymous'
export type ActivitySource = 'web' | 'api_key' | 'system'
export type RangeKey = 'today' | '7d' | '30d' | 'custom'
export const PAGE_SIZES = [25, 50, 100] as const
export const DEFAULT_PAGE_SIZE = 25
export const DEFAULT_RANGE: RangeKey = '7d'
export const RANGE_KEYS: RangeKey[] = ['today', '7d', '30d', 'custom']
export const OUTCOME_FILTERS: OutcomeFilter[] = ['all', 'success', 'failed']
export interface ActivityActor {
type: ActorType
id: number | null
name: string
}
export interface ActivityTarget {
type: string
id: string
name: string
exists: boolean
}
export type ActivityChange =
| { field: string; kind: 'text'; before: string; after: string }
| { field: string; kind: 'body'; delta: number }
export interface ActivityItem {
id: number
created_at: string
action: string
category: ActivityCategory
actor: ActivityActor
target: ActivityTarget | null
outcome: ActivityOutcome
failure_reason: string | null
security: boolean
source: ActivitySource
ip: string | null
user_agent: string | null
params: Record<string, unknown>
changes: ActivityChange[]
}
export interface ActivityPage {
items: ActivityItem[]
total: number
page: number
size: number
}
export interface ActivitySummary {
total: number
previous_total: number
active_people: { users: number; api_keys: number }
failed: number
security: number
series: { total: number[]; active: number[]; failed: number[]; security: number[] }
}
export interface ActivityFilters {
q: string
actor: string
actorLabel: string
category: ActivityCategory | ''
outcome: OutcomeFilter
security: boolean
range: RangeKey
from: string
to: string
}
export type ChipKey = 'q' | 'actor' | 'category' | 'outcome' | 'security' | 'range'
export interface ActivityChip {
key: ChipKey
label: string
}
export function defaultFilters(): ActivityFilters {
return {
q: '',
actor: '',
actorLabel: '',
category: '',
outcome: 'all',
security: false,
range: DEFAULT_RANGE,
from: '',
to: '',
}
}
export function summaryRangeChanged(a: ActivityFilters, b: ActivityFilters) {
return a.range !== b.range || a.from !== b.from || a.to !== b.to
}
function filterParams(filters: ActivityFilters) {
const params = new URLSearchParams()
if (filters.q) params.set('q', filters.q)
if (filters.actor) params.set('actor', filters.actor)
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)
}
return params
}
export function listQuery(filters: ActivityFilters, page: number, size: number, withActor = true) {
const params = filterParams(withActor ? filters : { ...filters, actor: '' })
params.set('page', String(page))
params.set('size', String(size))
return params.toString()
}
export function exportQuery(filters: ActivityFilters) {
return filterParams(filters).toString()
}
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()
}
export function categoryLabel(category: ActivityCategory) {
switch (category) {
case 'articles':
return t('Articles')
case 'media':
return t('Media')
case 'users':
return t('Users and invites')
case 'api_keys':
return t('API keys')
case 'auth':
return t('Authentication')
case 'system':
return t('System')
}
}
export function categoryIcon(category: ActivityCategory) {
switch (category) {
case 'articles':
return 'i-lucide-file-text'
case 'media':
return 'i-lucide-image'
case 'users':
return 'i-lucide-users'
case 'api_keys':
return 'i-lucide-key-round'
case 'auth':
return 'i-lucide-shield-check'
case 'system':
return 'i-lucide-server-cog'
}
}
export function outcomeFilterLabel(outcome: OutcomeFilter) {
switch (outcome) {
case 'all':
return t('All')
case 'success':
return t('Succeeded')
case 'failed':
return t('Failed')
}
}
export function outcomeOptions() {
return OUTCOME_FILTERS.map((value) => ({ value, label: outcomeFilterLabel(value) }))
}
export function outcomeBadge(outcome: ActivityOutcome) {
return outcome === 'success'
? { tone: 'ok' as const, label: t('Succeeded') }
: { tone: 'fail' as const, label: t('Failed') }
}
export function rowSurface(selected: boolean, security: boolean) {
return [selected ? 'bg-elevated' : 'bg-default', security ? 'shadow-flag' : '']
}
export function rangeLabel(range: RangeKey) {
switch (range) {
case 'today':
return t('Today')
case '7d':
return t('7 days')
case '30d':
return t('30 days')
case 'custom':
return t('Custom')
}
}
export function activeChips(filters: ActivityFilters): ActivityChip[] {
const chips: ActivityChip[] = []
if (filters.q) chips.push({ key: 'q', label: t('Search: {0}', [filters.q]) })
if (filters.actor) chips.push({ key: 'actor', label: t('Person: {0}', [filters.actorLabel]) })
if (filters.category)
chips.push({
key: 'category',
label: t('Category: {0}', [categoryLabel(filters.category)]),
})
if (filters.outcome !== 'all')
chips.push({
key: 'outcome',
label: t('Outcome: {0}', [outcomeFilterLabel(filters.outcome)]),
})
if (filters.security) chips.push({ key: 'security', label: t('Security events') })
if (filters.range !== DEFAULT_RANGE) {
const value =
filters.range === 'custom'
? formatDateRange(filters.from, filters.to)
: rangeLabel(filters.range)
chips.push({ key: 'range', label: t('Dates: {0}', [value]) })
}
return chips
}
export function clearChip(filters: ActivityFilters, key: ChipKey): ActivityFilters {
const defaults = defaultFilters()
switch (key) {
case 'q':
return { ...filters, q: '' }
case 'actor':
return { ...filters, actor: '', actorLabel: '' }
case 'category':
return { ...filters, category: '' }
case 'outcome':
return { ...filters, outcome: 'all' }
case 'security':
return { ...filters, security: false }
case 'range':
return { ...filters, range: defaults.range, from: '', to: '' }
}
}
export function initials(name: string) {
return name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((word) => Array.from(word)[0])
.join('')
.toUpperCase()
}
export function sourceLabel(source: ActivitySource) {
switch (source) {
case 'web':
return t('Web app')
case 'api_key':
return t('API key')
case 'system':
return t('System task')
}
}
export function targetTypeLabel(type: string) {
switch (type) {
case 'article':
return t('Article')
case 'image':
return t('Image')
case 'user':
return t('User')
case 'invite':
return t('Invite')
case 'api_key':
return t('API key')
case 'release':
return t('Release')
default:
return type
}
}
export function targetLink(target: ActivityTarget | null) {
if (!target?.exists) return null
switch (target.type) {
case 'article':
return `/article/${target.id}/edit`
case 'image':
return '/media'
case 'user':
case 'invite':
return '/users'
case 'api_key':
return '/integration/api-keys'
default:
return null
}
}
export function fieldLabel(field: string) {
switch (field) {
case 'title':
return t('Title')
case 'online':
return t('Publish state')
case 'body':
return t('Body')
case 'filename':
return t('File name')
case 'display_name':
return t('Display name')
default:
return field
}
}
export function changeValue(field: string, value: string) {
if (field !== 'online') return value
if (value === 'false') return t('Draft')
if (value === 'true') return t('Published')
return value
}
export function bodyDeltaLabel(delta: number) {
const sign = delta < 0 ? '-' : '+'
const amount = `${sign}${formatNumber(Math.abs(delta))}`
return Math.abs(delta) === 1
? t('Body edited ({0} character)', [amount])
: t('Body edited ({0} characters)', [amount])
}
+209
View File
@@ -0,0 +1,209 @@
import { formatNumber, t } from '@/i18n'
import type { MessageKey } from '@/i18n/messages'
import type { ActivityItem } from '@/lib/activity'
export interface SentenceSegment {
text: string
strong: boolean
}
interface Template {
success: MessageKey
failed?: MessageKey
}
const TARGET_SLOT = '\u0000'
const DROPPED_SLOT = new RegExp(`[\\s\\u00a0]*:?[\\s\\u00a0]*${TARGET_SLOT}`)
const PURGE_ACTIONS = ['media.purge', 'system.media_purge']
const CATALOGUE: Record<string, Template> = {
'article.create': {
success: 'Created the article {0}',
failed: 'Failed to create the article {0}',
},
'article.edit': { success: 'Edited the article {0}', failed: 'Failed to edit the article {0}' },
'article.publish': {
success: 'Published the article {0}',
failed: 'Failed to publish the article {0}',
},
'article.unpublish': {
success: 'Unpublished the article {0}',
failed: 'Failed to unpublish the article {0}',
},
'article.delete': {
success: 'Deleted the article {0}',
failed: 'Failed to delete the article {0}',
},
'media.upload': {
success: 'Uploaded the image {0}',
failed: 'Failed to upload the image {0}',
},
'media.rename': {
success: 'Renamed the image {0}',
failed: 'Failed to rename the image {0}',
},
'media.delete': { success: 'Deleted the image {0}', failed: 'Failed to delete the image {0}' },
'media.merge': {
success: 'Merged duplicates into the image {0}',
failed: 'Failed to merge duplicates into the image {0}',
},
'media.purge': {
success: 'Purged duplicate images: {0}',
failed: 'Failed to purge duplicate images',
},
'system.media_purge': {
success: 'Ran the scheduled duplicate purge: {0}',
failed: 'Failed to run the scheduled duplicate purge',
},
'user.register': {
success: 'Registered the account {0}',
failed: 'Failed to register the account {0}',
},
'user.verify': {
success: 'Verified the account {0}',
failed: 'Failed to verify the account {0}',
},
'user.update_profile': {
success: 'Updated the profile of {0}',
failed: 'Failed to update the profile of {0}',
},
'invite.create': { success: 'Invited {0}', failed: 'Failed to invite {0}' },
'invite.cancel': {
success: 'Cancelled the invite for {0}',
failed: 'Failed to cancel the invite for {0}',
},
'invite.renew': {
success: 'Renewed the invite for {0}',
failed: 'Failed to renew the invite for {0}',
},
'invite.accept': {
success: 'Accepted the invite for {0}',
failed: 'Failed to accept the invite for {0}',
},
'api_key.create': {
success: 'Created API key {0}',
failed: 'Failed to create API key {0}',
},
'api_key.revoke': {
success: 'Revoked API key {0}',
failed: 'Failed to revoke API key {0}',
},
'auth.login': { success: 'Logged in' },
'auth.login_failed': {
success: 'Failed login attempt for {0}',
failed: 'Failed login attempt for {0}',
},
'auth.logout': { success: 'Logged out' },
'auth.password_reset_requested': { success: 'Requested a password reset' },
'auth.password_reset_completed': { success: 'Reset the password' },
'system.update_checked': {
success: 'Checked for updates',
failed: 'Failed to check for updates',
},
'system.update_downloaded': {
success: 'Downloaded the update {0}',
failed: 'Failed to download the update {0}',
},
'system.restart_requested': { success: 'Requested a restart', failed: 'Failed to restart' },
'system.activity_retention_changed': {
success: 'Changed the activity retention from {0} to {1}',
},
'system.activity_cleanup': { success: 'Removed {0} old events from the activity log' },
}
function count(value: unknown) {
return typeof value === 'number' && Number.isFinite(value) ? value : 0
}
function purgeSummary(params: Record<string, unknown>) {
const articles = count(params.articles_updated)
const failedGroups = count(params.failed_groups)
const parts = [
t('{0} merged', [formatNumber(count(params.merged))]),
articles === 1
? t('1 article updated')
: t('{0} articles updated', [formatNumber(articles)]),
]
if (failedGroups > 0) {
parts.push(
failedGroups === 1
? t('1 group failed')
: t('{0} groups failed', [formatNumber(failedGroups)]),
)
}
return parts.join(', ')
}
function retentionDays(value: unknown) {
const days = count(value)
return days === 0 ? t('Forever') : t('{0} days', [formatNumber(days)])
}
function resolveTemplate(item: ActivityItem): MessageKey | null {
const template = CATALOGUE[item.action]
if (!template) return null
if (item.action === 'system.restart_requested' && item.outcome === 'success') {
return item.params.kind === 'update'
? 'Restarted to apply the update {0}'
: 'Requested a restart'
}
if (item.outcome === 'failed') return template.failed ?? template.success
return template.success
}
function slotValues(item: ActivityItem, template: MessageKey) {
if (PURGE_ACTIONS.includes(item.action) && item.outcome === 'success') {
return [purgeSummary(item.params)]
}
if (item.action === 'system.activity_retention_changed') {
return [retentionDays(item.params.from), retentionDays(item.params.to)]
}
if (item.action === 'system.activity_cleanup') {
return [formatNumber(count(item.params.removed))]
}
return template.includes('{0}') ? [TARGET_SLOT] : []
}
function targetName(item: ActivityItem) {
if (item.action === 'auth.login_failed') return item.actor.name
return item.target?.name ?? ''
}
function usesTargetSlot(values: string[]) {
return values.length === 1 && values[0] === TARGET_SLOT
}
export function composeSentence(item: ActivityItem): SentenceSegment[] {
const template = resolveTemplate(item)
if (template === null) return [{ text: item.action, strong: false }]
if (item.action === 'system.activity_cleanup' && count(item.params.removed) === 1) {
return [{ text: t('Removed 1 old event from the activity log'), strong: false }]
}
const values = slotValues(item, template)
const name = targetName(item)
const rendered = t(template, values)
if (!usesTargetSlot(values)) return [{ text: rendered, strong: false }]
if (!name) return [{ text: rendered.replace(DROPPED_SLOT, ''), strong: false }]
const [before, after] = rendered.split(TARGET_SLOT)
const segments: SentenceSegment[] = [
{ text: before ?? '', strong: false },
{ text: name, strong: true },
]
if (after) segments.push({ text: after, strong: false })
return segments.filter((segment) => segment.text !== '')
}
export function sentenceText(item: ActivityItem) {
return composeSentence(item)
.map((segment) => segment.text)
.join('')
}
export const SENTENCE_ACTIONS = Object.keys(CATALOGUE)
export function sentenceTemplates(action: string) {
return CATALOGUE[action]
}
+109
View File
@@ -0,0 +1,109 @@
import { formatNumber, t } from '@/i18n'
import type { ActivitySummary, RangeKey } from '@/lib/activity'
export type StripKey = 'total' | 'active' | 'failed' | 'security'
export type StripTone = 'neutral' | 'error' | 'warning' | 'calm'
export interface StripCard {
key: StripKey
label: string
value: string
subline: string
icon: string | null
tone: StripTone
series: number[]
}
export const STRIP_KEYS: StripKey[] = ['total', 'active', 'failed', 'security']
export const BAR_COUNT = 7
export const MIN_BAR_HEIGHT = 2
function percent(part: number, whole: number) {
return Math.round((Math.abs(part - whole) / whole) * 100)
}
function totalSubline(summary: ActivitySummary, range: RangeKey) {
if (summary.total === 0) return { text: t('No data yet'), icon: null }
if (summary.previous_total === 0) return { text: t('No earlier data'), icon: null }
if (summary.total === summary.previous_total) return { text: t('No change'), icon: null }
const value = formatNumber(percent(summary.total, summary.previous_total))
const text =
range === 'today'
? t('{0}% vs previous day', [value])
: range === '30d'
? t('{0}% vs previous 30 days', [value])
: range === 'custom'
? t('{0}% vs previous period', [value])
: t('{0}% vs previous 7 days', [value])
const icon =
summary.total >= summary.previous_total ? 'i-lucide-trending-up' : 'i-lucide-trending-down'
return { text, icon }
}
function peopleSubline(summary: ActivitySummary) {
if (summary.total === 0) return t('No data yet')
const { users, api_keys: keys } = summary.active_people
const people = users === 1 ? t('1 person') : t('{0} people', [formatNumber(users)])
const apiKeys = keys === 1 ? t('1 API key') : t('{0} API keys', [formatNumber(keys)])
return `${people}, ${apiKeys}`
}
function failedSubline(summary: ActivitySummary) {
const ratio = (summary.failed / summary.total) * 100
return t('{0}% of all actions', [
formatNumber(ratio, { minimumFractionDigits: 1, maximumFractionDigits: 1 }),
])
}
function flaggedSubline(count: number) {
return count === 1 ? t('1 flagged') : t('{0} flagged', [formatNumber(count)])
}
export function stripCards(summary: ActivitySummary, range: RangeKey): StripCard[] {
const total = totalSubline(summary, range)
const failedCalm = summary.failed === 0
const securityCalm = summary.security === 0
return [
{
key: 'total',
label: t('Total actions'),
value: formatNumber(summary.total),
subline: total.text,
icon: total.icon,
tone: 'neutral',
series: summary.series.total,
},
{
key: 'active',
label: t('Active people'),
value: formatNumber(summary.active_people.users + summary.active_people.api_keys),
subline: peopleSubline(summary),
icon: null,
tone: 'neutral',
series: summary.series.active,
},
{
key: 'failed',
label: t('Failed actions'),
value: formatNumber(summary.failed),
subline: failedCalm ? t('Nothing failed') : failedSubline(summary),
icon: failedCalm ? 'i-lucide-circle-check' : 'i-lucide-circle-x',
tone: failedCalm ? 'calm' : 'error',
series: summary.series.failed,
},
{
key: 'security',
label: t('Security events'),
value: formatNumber(summary.security),
subline: securityCalm ? t('Nothing to flag') : flaggedSubline(summary.security),
icon: securityCalm ? 'i-lucide-circle-check' : 'i-lucide-shield-alert',
tone: securityCalm ? 'calm' : 'warning',
series: summary.series.security,
},
]
}
export function barHeights(series: number[], height: number) {
const peak = Math.max(...series, 1)
return series.map((value) => Math.max(MIN_BAR_HEIGHT, Math.round((value / peak) * height)))
}
+110
View File
@@ -0,0 +1,110 @@
import { locale, t } from '@/i18n'
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
const MINUTE_MS = 60 * 1000
const HOUR_MS = 60 * MINUTE_MS
const ISO_DAY = /^(\d{4})-(\d{2})-(\d{2})$/
export interface DayGroup<T> {
key: string
label: string
items: T[]
}
function pad(value: number) {
return String(value).padStart(2, '0')
}
function monthShort(date: Date, timeZone?: 'UTC') {
if (locale.value === 'fr') {
return new Intl.DateTimeFormat('fr', { month: 'short', timeZone }).format(date)
}
return MONTHS[timeZone ? date.getUTCMonth() : date.getMonth()]
}
export function formatAbsoluteDay(date: Date, timeZone?: 'UTC') {
const day = timeZone ? date.getUTCDate() : date.getDate()
const year = timeZone ? date.getUTCFullYear() : date.getFullYear()
return `${day} ${monthShort(date, timeZone)} ${year}`
}
function clock(date: Date, withSeconds: boolean, utc: boolean) {
const hours = utc ? date.getUTCHours() : date.getHours()
const minutes = utc ? date.getUTCMinutes() : date.getMinutes()
const seconds = utc ? date.getUTCSeconds() : date.getSeconds()
const base = `${pad(hours)}:${pad(minutes)}`
return withSeconds ? `${base}:${pad(seconds)}` : base
}
export function localDayKey(date: Date) {
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
}
function startOfDay(date: Date) {
return new Date(date.getFullYear(), date.getMonth(), date.getDate())
}
export function dayLabel(iso: string, now: Date = new Date()) {
const date = new Date(iso)
const today = startOfDay(now)
const yesterday = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 1)
const key = localDayKey(date)
if (key === localDayKey(today)) return t('Today')
if (key === localDayKey(yesterday)) return t('Yesterday')
return formatAbsoluteDay(date)
}
export function whenLabel(iso: string, now: Date = new Date()) {
const date = new Date(iso)
if (localDayKey(date) !== localDayKey(now)) return clock(date, false, false)
const elapsed = Math.max(0, now.getTime() - date.getTime())
if (elapsed < HOUR_MS) {
return t('{0} min ago', [Math.max(1, Math.floor(elapsed / MINUTE_MS))])
}
return t('{0} h ago', [Math.floor(elapsed / HOUR_MS)])
}
export function fullLocalTime(iso: string) {
const date = new Date(iso)
return `${formatAbsoluteDay(date)}, ${clock(date, true, false)}`
}
export function fullUtcTime(iso: string) {
const date = new Date(iso)
return `${formatAbsoluteDay(date, 'UTC')}, ${clock(date, true, true)}`
}
export function groupByDay<T extends { created_at: string }>(
items: T[],
now: Date = new Date(),
): DayGroup<T>[] {
const groups: DayGroup<T>[] = []
for (const item of items) {
const key = localDayKey(new Date(item.created_at))
const last = groups[groups.length - 1]
if (last && last.key === key) {
last.items.push(item)
continue
}
groups.push({ key, label: dayLabel(item.created_at, now), items: [item] })
}
return groups
}
function parseIsoDay(value: string) {
const match = ISO_DAY.exec(value)
if (!match) return null
return new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]), 12)
}
export function formatDateRange(from: string, to: string) {
const start = parseIsoDay(from)
const end = parseIsoDay(to)
if (!start || !end) return ''
const sameYear = start.getFullYear() === end.getFullYear()
const sameMonth = sameYear && start.getMonth() === end.getMonth()
if (sameMonth && start.getDate() === end.getDate()) return formatAbsoluteDay(end)
if (sameMonth) return `${start.getDate()} – ${formatAbsoluteDay(end)}`
if (sameYear) return `${start.getDate()} ${monthShort(start)} – ${formatAbsoluteDay(end)}`
return `${formatAbsoluteDay(start)} – ${formatAbsoluteDay(end)}`
}
+15
View File
@@ -0,0 +1,15 @@
const SEGMENT_BUTTON =
'h-10.5 gap-1.5 px-3 text-sm ring-0 md:h-7.5 md:px-2.5 rounded-none whitespace-nowrap'
const SEGMENT_SELECTED =
'bg-secondary-200 font-medium text-primary-900 hover:bg-secondary-200 dark:bg-secondary-300 dark:hover:bg-secondary-300'
const SEGMENT_IDLE = 'bg-default font-normal text-toned'
export function segmentClass(selected: boolean, block = false) {
return [
SEGMENT_BUTTON,
selected ? SEGMENT_SELECTED : SEGMENT_IDLE,
block ? 'flex-1 justify-center' : '',
]
}
export const SEGMENT_GROUP = 'flex shrink-0 border border-accented'
+5
View File
@@ -81,6 +81,11 @@ const router = createRouter({
component: () => import('@/views/UsersView.vue'),
meta: { auth: 'verified', title: 'Users' },
},
{
path: '/activity',
component: () => import('@/views/ActivityView.vue'),
meta: { auth: 'verified', title: 'Activity' },
},
{
path: '/profile',
component: () => import('@/views/ProfileView.vue'),
+296
View File
@@ -0,0 +1,296 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useMediaQuery } from '@vueuse/core'
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 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 { useActivityPage } from '@/composables/useActivityPage'
import { useSessionStore } from '@/stores/session'
import { defaultFilters, type ActivityFilters } from '@/lib/activity'
import type { StripKey } from '@/lib/activitySummary'
const DESKTOP_QUERY = '(min-width: 48rem)'
const router = useRouter()
const session = useSessionStore()
const isDesktop = useMediaQuery(DESKTOP_QUERY)
const activity = useActivityPage()
const {
filters,
items,
total,
page,
size,
listState,
summary,
summaryState,
people,
chips,
isFullView,
isEmpty,
exporting,
exportDisabled,
showListSkeleton,
showSummarySkeleton,
selectedId,
selectedItem,
position,
canPrevious,
canNext,
} = activity
const sheetOpen = ref(false)
let started = false
let opener: HTMLElement | null = null
const sessionReady = computed(() => session.status === 'ready' && !!session.session?.verified)
const title = computed(() =>
isFullView.value || session.status !== 'ready' ? t('Activity') : t('My activity'),
)
const subtitle = computed(() =>
isFullView.value
? t('Who did what, and when, across your site.')
: t("Everything you've done on this site."),
)
const forbidden = computed(() => listState.value === 'forbidden')
const showStrip = computed(
() =>
isFullView.value &&
!forbidden.value &&
((summaryState.value !== 'failed' && summary.value !== null) || showSummarySkeleton.value),
)
const showRows = computed(
() =>
listState.value === 'ready' ||
(listState.value === 'loading' && (showListSkeleton.value || items.value.length > 0)),
)
const showPager = computed(
() => showRows.value && !showListSkeleton.value && !isEmpty.value && items.value.length > 0,
)
const emptyIsFiltered = computed(() => chips.value.length > 0)
watch(
sessionReady,
(ready) => {
if (ready && !started) {
started = true
activity.start()
}
},
{ immediate: true },
)
function onFilters(next: ActivityFilters) {
activity.applyFilters(next)
}
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 })
}
function onOpen(item: { id: number }, element: HTMLElement) {
opener = element
activity.openDetails(item.id)
}
async function onClose() {
const id = selectedId.value
activity.closeDetails()
await nextTick()
const row = document.querySelector<HTMLElement>(`[data-activity-id="${id}"]`)
;(row ?? opener)?.focus()
}
function goHome() {
router.push('/home')
}
</script>
<template>
<DashboardShell compact-padding :mobile-title="title">
<template #breadcrumb>
<UBreadcrumb
:items="[{ label: title }]"
color="neutral"
class="hidden lg:block"
:ui="{ link: 'text-sm font-medium' }"
/>
</template>
<div class="flex flex-col gap-4">
<div class="flex flex-wrap items-end gap-4">
<div class="flex min-w-45 flex-1 flex-col gap-0.5">
<h1 class="text-xl font-semibold text-highlighted">{{ title }}</h1>
<p class="text-muted">{{ subtitle }}</p>
</div>
<UButton
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>
</div>
<ActivityEmptyPanel
v-if="forbidden"
icon="i-lucide-lock"
:title="t('You can\'t view this activity')"
:description="
t(
'Your role doesn\'t include this page. Ask an owner or admin if you need access.',
)
"
:action-label="t('Back to home')"
@action="goHome"
/>
<template v-else>
<ActivitySummaryStrip
v-if="showStrip"
:summary="summary"
:range="filters.range"
:loading="showSummarySkeleton"
:is-desktop="isDesktop"
:outcome="filters.outcome"
:security="filters.security"
@apply-filter="onStripCard"
/>
<ActivityFilterBar
:filters="filters"
:people="people"
:can-filter-people="isFullView"
:is-desktop="isDesktop"
:chip-count="chips.length"
@update:filters="onFilters"
@open-people="activity.ensurePeople"
@open-sheet="sheetOpen = true"
/>
<ActivityChips
:chips="chips"
@remove="activity.removeChip"
@clear="activity.clearAll"
/>
<UAlert
v-if="listState === 'error'"
role="alert"
color="error"
variant="soft"
orientation="vertical"
:close="false"
icon="i-lucide-circle-x"
:title="t('Couldn\'t load activity')"
:description="t('Check your connection and try again.')"
:ui="{ actions: 'mt-2' }"
>
<template #actions>
<UButton
color="neutral"
variant="outline"
class="h-11 border border-accented bg-default px-3 text-xs text-highlighted ring-0 md:h-7"
@click="activity.retry"
>
{{ t('Try again') }}
</UButton>
</template>
</UAlert>
<ActivityEmptyPanel
v-else-if="isEmpty && !emptyIsFiltered"
icon="i-lucide-history"
:title="t('No activity yet')"
:description="
isFullView
? t('Actions on articles, media, users and API keys will appear here.')
: t('Your actions will appear here once you start working on the site.')
"
/>
<ActivityEmptyPanel
v-else-if="isEmpty"
icon="i-lucide-search-x"
:title="t('No activity matches these filters')"
:description="t('Try a different search or a wider date range.')"
:action-label="t('Clear filters')"
@action="activity.clearAll"
/>
<template v-else-if="showRows">
<ActivityTable
v-if="isDesktop"
:items="items"
:loading="showListSkeleton"
:selected-id="selectedId"
:show-actor="isFullView"
@open="onOpen"
/>
<ActivityCardList
v-else
:items="items"
:loading="showListSkeleton"
:selected-id="selectedId"
:show-actor="isFullView"
@open="onOpen"
/>
</template>
<ActivityPager
v-if="showPager"
:page="page"
:page-count="activity.pageCount.value"
:total="total"
:size="size"
:from="activity.rangeStart.value"
:to="activity.rangeEnd.value"
:is-desktop="isDesktop"
@update:page="activity.goToPage"
@update:size="activity.setSize"
/>
</template>
</div>
<ActivityDetailsPanel
:item="selectedItem"
:position="position"
:total="total"
:can-previous="canPrevious"
:can-next="canNext"
:is-desktop="isDesktop"
@previous="activity.previous"
@next="activity.next"
@close="onClose"
/>
<ActivityFiltersSheet
v-if="!isDesktop && !forbidden"
v-model:open="sheetOpen"
:filters="filters"
:people="people"
:can-filter-people="isFullView"
:chip-count="chips.length"
:total="total"
@update:filters="onFilters"
@open-people="activity.ensurePeople"
@clear="activity.clearAll"
/>
</DashboardShell>
</template>
@@ -0,0 +1,488 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import router from '@/router'
import { ApiError, useApi } from '@/composables/useApi'
import { initializeLocale } from '@/i18n'
import ActivityPager from '@/components/ActivityPager.vue'
import type { ActivityChange, ActivityItem } from '@/lib/activity'
import {
LIST_PATH,
callsTo,
click,
expectIcon,
findButton,
harness,
mountActivity,
queryObject,
rows,
textOf,
unmountActivity,
} from './activityHarness'
import {
activityItems,
anonymous,
makeItem,
pageOf,
system,
target,
LONG_VALUE,
} 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() }) }
})
beforeEach(() => {
localStorage.clear()
initializeLocale()
})
afterEach(async () => {
vi.useRealTimers()
unmountActivity()
vi.mocked(useApi).mockReset()
await router.push('/login')
})
function panel() {
return document.body.querySelector<HTMLElement>('[role="dialog"]')
}
function details() {
return document.body.querySelector<HTMLElement>('[data-testid="activity-details"]')
}
async function openRow(index: number) {
const row = rows()[index]!
row.focus()
row.click()
await flushPromises()
await vi.waitFor(() => expect(details()).toBeTruthy())
return row
}
async function openWith(item: ActivityItem, desktop = true) {
await mountActivity({ desktop, list: () => pageOf([item]) })
await openRow(0)
}
function pressEscape() {
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
}
describe('Details: a row opens the slide-over and focus returns on close', () => {
it('opens from a click, marks the row selected and closes with Escape', async () => {
await mountActivity()
const row = await openRow(1)
expect(textOf(panel()?.querySelector('[data-slot="title"], h2'))).toContain(
'Activity details',
)
expect(row.getAttribute('aria-selected')).toBe('true')
expect(row.className).toContain('bg-elevated')
expect(textOf(panel())).toContain('Event 2 of 1,284')
pressEscape()
await flushPromises()
await vi.waitFor(() => expect(details()).toBeNull())
expect(document.activeElement).toBe(row)
expect(row.getAttribute('aria-selected')).toBe('false')
})
it('opens from Enter on a focused row and closes with the close button', async () => {
await mountActivity()
const row = rows()[0]!
row.focus()
row.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
await flushPromises()
await vi.waitFor(() => expect(details()).toBeTruthy())
await click(panel()!.querySelector('button[aria-label="Close"]'))
await vi.waitFor(() => expect(details()).toBeNull())
expect(document.activeElement).toBe(row)
})
it('closes when the scrim is pressed', async () => {
await mountActivity()
const row = await openRow(0)
document.body.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }))
document.body.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await flushPromises()
await vi.waitFor(() => expect(details()).toBeNull())
expect(document.activeElement).toBe(row)
})
})
describe('Details: content', () => {
it('shows the edit with a diff and never the body text', async () => {
await mountActivity()
await openRow(1)
expect(textOf(details()!.querySelector('[data-testid="activity-details-sentence"]'))).toBe(
'Edited the article Why we reuse original floorboards',
)
const changes = Array.from(details()!.querySelectorAll('[data-testid="activity-change"]'))
expect(changes).toHaveLength(2)
expect(textOf(changes[0])).toContain('Title')
expect(textOf(changes[0]!.querySelector('[data-testid="activity-before"]'))).toBe(
'Why we reuse floorboards',
)
expect(textOf(changes[0]!.querySelector('[data-testid="activity-after"]'))).toBe(
'Why we reuse original floorboards',
)
expect(textOf(changes[1])).toBe('Body Body edited (+312 characters)')
expect(changes[1]!.querySelector('[data-testid="activity-before"]')).toBeNull()
})
it.each([
[1, 'Body edited (+1 character)'],
[-48, 'Body edited (-48 characters)'],
])('words a body delta of %i', async (delta, text) => {
await openWith(makeItem({ id: 1, changes: [{ field: 'body', kind: 'body', delta }] }))
expect(textOf(details()!.querySelector('[data-testid="activity-body-change"]'))).toBe(text)
})
it('labels the fields, reads online as Draft and Published, and keeps unknown codes', async () => {
const changes: ActivityChange[] = [
{ field: 'online', kind: 'text', before: 'false', after: 'true' },
{ field: 'filename', kind: 'text', before: 'a.png', after: 'b.png' },
{ field: 'display_name', kind: 'text', before: 'Sam', after: 'Samuel' },
{ field: 'tagline', kind: 'text', before: 'x', after: 'y' },
]
await openWith(makeItem({ id: 1, changes }))
const rowsText = Array.from(
details()!.querySelectorAll('[data-testid="activity-change"]'),
).map(textOf)
expect(rowsText).toEqual([
'Publish stateBeforeDraftAfterPublished',
'File nameBeforea.pngAfterb.png',
'Display nameBeforeSamAfterSamuel',
'taglineBeforexAftery',
])
})
it('clamps long values and toggles Show more on that row only', async () => {
const scrollHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollHeight')
const clientHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientHeight')
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
configurable: true,
get() {
return (this.textContent ?? '').length > 100 ? 80 : 20
},
})
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
get() {
return 40
},
})
try {
await openWith(
makeItem({
id: 1,
changes: [
{
field: 'title',
kind: 'text',
before: LONG_VALUE,
after: `${LONG_VALUE}more`,
},
{ field: 'filename', kind: 'text', before: 'a.png', after: 'b.png' },
],
}),
)
await vi.waitFor(() => expect(findButton('Show more')).toBeTruthy())
const changes = Array.from(
details()!.querySelectorAll('[data-testid="activity-change"]'),
)
expect(changes[0]!.querySelector('.line-clamp-2')).toBeTruthy()
expect(changes[1]!.querySelector('button')).toBeNull()
expect(
Array.from(document.body.querySelectorAll('button')).filter(
(button) => textOf(button) === 'Show more',
),
).toHaveLength(1)
await click(findButton('Show more'))
expect(changes[0]!.querySelector('.line-clamp-2')).toBeNull()
expect(findButton('Show less')).toBeTruthy()
await click(findButton('Show less'))
expect(changes[0]!.querySelector('.line-clamp-2')).toBeTruthy()
} finally {
if (scrollHeight)
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', scrollHeight)
if (clientHeight)
Object.defineProperty(HTMLElement.prototype, 'clientHeight', clientHeight)
}
})
it('shows the failure reason and wraps a very long target title', async () => {
await openWith(
makeItem({
id: 1,
outcome: 'failed',
failure_reason: 'The article has no cover image. Add one, then publish again.',
target: target('article', '6', 'T'.repeat(150)),
}),
)
const sentence = details()!.querySelector('[data-testid="activity-details-sentence"]')!
expect(sentence.className).toContain('wrap-anywhere')
expect(textOf(sentence.querySelector('.font-semibold'))).toBe('T'.repeat(150))
expect(textOf(details())).toContain('Failed')
const alert = details()!.querySelector('[role="alert"]')!
expect(textOf(alert)).toContain('Why it failed')
expect(textOf(alert)).toContain(
'The article has no cover image. Add one, then publish again.',
)
expect(
details()!.querySelector('[data-testid="activity-target-section"] .wrap-anywhere'),
).toBeTruthy()
})
it('shows no Why it failed alert for a failed event without a reason', async () => {
await openWith(makeItem({ id: 1, outcome: 'failed', failure_reason: null }))
expect(details()!.querySelector('[role="alert"]')).toBeNull()
})
it('shows a deleted target struck through with its explanation and no Changes', async () => {
await openWith(
makeItem({
id: 1,
action: 'media.delete',
category: 'media',
target: target('image', '9', 'old-banner.png', false),
}),
)
const section = details()!.querySelector('[data-testid="activity-target-section"]')!
expect(section.querySelector('.line-through')?.textContent).toBe('old-banner.png')
expect(textOf(section)).toContain('Deleted')
expect(textOf(section)).toContain('Image · 9')
expect(textOf(section)).toContain('This item was deleted. Its name is kept for reference.')
expect(textOf(details())).not.toContain('Changes')
})
it('shows a living target as a link with its type and id', async () => {
await openWith(makeItem({ id: 1 }))
const section = details()!.querySelector('[data-testid="activity-target-section"]')!
expect(section.querySelector('a')?.getAttribute('href')).toBe('/article/8/edit')
expect(textOf(section)).toContain('Article · 8')
await expectIcon(section, 'arrow-up-right')
})
it('omits the Target section when there is no target', async () => {
await openWith(makeItem({ id: 1, action: 'auth.login', target: null }))
expect(details()!.querySelector('[data-testid="activity-target-section"]')).toBeNull()
})
it('shows a system event without a human actor', async () => {
await openWith(
makeItem({
id: 1,
action: 'system.media_purge',
category: 'system',
actor: system,
target: null,
source: 'system',
ip: null,
user_agent: null,
params: { merged: 3, articles_updated: 5 },
}),
)
const text = textOf(details())
expect(textOf(details()!.querySelector('[data-testid="activity-actor"]'))).toBe(
'SystemSystem',
)
await expectIcon(details()!.querySelector('[data-testid="activity-actor"]'), 'cog')
expect(text).toContain('System task')
expect(text.match(/Not applicable/g)).toHaveLength(2)
})
it('shows a failed login as not signed in with the reason, IP and browser', async () => {
await openWith(
makeItem({
id: 1,
action: 'auth.login_failed',
category: 'auth',
actor: anonymous('alex@example.com'),
target: null,
outcome: 'failed',
security: true,
failure_reason: 'Wrong username or password.',
ip: '198.51.100.77',
user_agent: 'Chrome 129 on Windows',
}),
)
expect(textOf(details()!.querySelector('[data-testid="activity-details-sentence"]'))).toBe(
'Failed login attempt for alex@example.com',
)
const text = textOf(details())
expect(text).toContain('Security')
expect(text).toContain('Failed')
expect(textOf(details()!.querySelector('[data-testid="activity-actor"]'))).toBe(
'Not signed in',
)
expect(textOf(details()!.querySelector('[role="alert"]'))).toContain(
'Wrong username or password.',
)
expect(text).toContain('198.51.100.77')
expect(text).toContain('Chrome 129 on Windows')
})
it('shows the time in your time zone and in UTC', async () => {
await openWith(makeItem({ id: 1, created_at: '2026-10-06T10:42:18Z' }))
const when = Array.from(details()!.querySelectorAll('dt')).find(
(term) => textOf(term) === 'When',
)!.nextElementSibling!
const lines = Array.from(when.querySelectorAll('span')).map(textOf)
expect(lines[0]).toMatch(/^6 Oct 2026, \d{2}:\d{2}:\d{2} \(your time\)$/)
expect(lines[1]).toBe('6 Oct 2026, 10:42:18 UTC')
})
it.each([
['web', 'Web app'],
['api_key', 'API key'],
['system', 'System task'],
] as const)('reads source %s as %s', async (source, label) => {
await openWith(makeItem({ id: 1, source }))
const term = Array.from(details()!.querySelectorAll('dt')).find(
(candidate) => textOf(candidate) === 'Source',
)!
expect(textOf(term.nextElementSibling)).toBe(label)
})
})
describe('Details: Previous and Next across pages', () => {
function numbered(page: number) {
return Array.from({ length: 25 }, (_, index) =>
makeItem({ id: (page - 1) * 25 + index + 1, created_at: '2026-10-03T12:30:00Z' }),
)
}
function pagedList(size = 25) {
return (query: URLSearchParams) => {
const page = Number(query.get('page'))
return pageOf(numbered(page), { page, size })
}
}
const footer = () => textOf(panel())
const previous = () => findButton('Previous event')!
const next = () => findButton('Next event')!
it('moves through the list, fetches the neighbour pages and stops at the ends', async () => {
await mountActivity({ list: pagedList() })
await openRow(0)
expect(previous().disabled).toBe(true)
expect(footer()).toContain('Event 1 of 1,284')
for (let step = 0; step < 24; step++) await click(next())
expect(footer()).toContain('Event 25 of 1,284')
expect(callsTo(LIST_PATH)).toHaveLength(1)
await click(next())
expect(queryObject(callsTo(LIST_PATH).at(-1))).toEqual({
range: '7d',
page: '2',
size: '25',
})
expect(footer()).toContain('Event 26 of 1,284')
expect(textOf(document.body.querySelector('[data-testid="activity-pager"]'))).toContain(
'Showing 26–50 of 1,284',
)
expect(
textOf(details()!.querySelector('[data-testid="activity-details-sentence"]')),
).toContain('Spring release')
await click(previous())
expect(queryObject(callsTo(LIST_PATH).at(-1))).toMatchObject({ page: '1' })
expect(footer()).toContain('Event 25 of 1,284')
expect(
rows()
.map((row) => row.getAttribute('data-activity-id'))
.at(-1),
).toBe('25')
})
it('disables Next on the very last event', async () => {
await mountActivity({
list: () => pageOf([makeItem({ id: 1 }), makeItem({ id: 2 })], { total: 2 }),
})
await openRow(1)
expect(next().disabled).toBe(true)
expect(footer()).toContain('Event 2 of 2')
})
it('keeps the panel on the current event when the neighbour page fails', async () => {
await mountActivity({
list: (query) => {
if (query.get('page') === '2') throw new ApiError(500)
return pageOf(numbered(1))
},
})
await openRow(24)
const ids = rows().map((row) => row.getAttribute('data-activity-id'))
await click(next())
expect(footer()).toContain('Event 25 of 1,284')
expect(next().disabled).toBe(false)
expect(rows().map((row) => row.getAttribute('data-activity-id'))).toEqual(ids)
expect(document.body.querySelector('[role="alert"]')).toBeNull()
})
it('numbers events from the page size', async () => {
await mountActivity({ list: pagedList(50) })
harness.wrapper!.findComponent(ActivityPager).vm.$emit('update:size', 50)
await flushPromises()
harness.wrapper!.findComponent(ActivityPager).vm.$emit('update:page', 2)
await flushPromises()
await openRow(0)
expect(footer()).toContain('Event 51 of 1,284')
})
})
describe('Details below md: a bottom sheet', () => {
it('shows the position in the header and full-width buttons without footer text', async () => {
await mountActivity({ desktop: false })
await openRow(1)
const dialog = panel()!
expect(textOf(dialog)).toContain('Activity details')
expect(textOf(dialog)).toContain('2 of 1,284')
expect(textOf(dialog)).not.toContain('Event 2 of 1,284')
expect(findButton('Previous event')!.className).toContain('max-md:flex-1')
expect(findButton('Next event')!.className).toContain('max-md:flex-1')
expect(dialog.getAttribute('data-side') ?? dialog.className).toContain('bottom')
expect(dialog.className).toContain('h-[88%]')
})
})
describe('Details: counts', () => {
it('counts positions against the filtered total', async () => {
await mountActivity({ list: () => pageOf(activityItems, { total: 41 }) })
await openRow(0)
expect(textOf(panel())).toContain('Event 1 of 41')
})
})
@@ -0,0 +1,509 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { flushPromises } from '@vue/test-utils'
import { CalendarDate } from '@internationalized/date'
import UCalendar from '@nuxt/ui/components/Calendar.vue'
import USelectMenu from '@nuxt/ui/components/SelectMenu.vue'
import router from '@/router'
import { useApi } from '@/composables/useApi'
import { initializeLocale } from '@/i18n'
import ActivityCategorySelect from '@/components/ActivityCategorySelect.vue'
import ActivityPager from '@/components/ActivityPager.vue'
import ActivityPersonSelect from '@/components/ActivityPersonSelect.vue'
import {
KEYS_PATH,
LIST_PATH,
SUMMARY_PATH,
USERS_PATH,
callsTo,
chipTexts,
click,
clickButton,
findButton,
harness,
lastListQuery,
mountActivity,
queryObject,
rows,
stripCards,
textOf,
unmountActivity,
} from './activityHarness'
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() }
})
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' }
beforeEach(() => {
localStorage.clear()
initializeLocale()
})
afterEach(async () => {
vi.useRealTimers()
unmountActivity()
vi.mocked(useApi).mockReset()
await router.push('/login')
})
function searchInput() {
return document.body.querySelector<HTMLInputElement>('input[type="text"]')!
}
async function typeSearch(value: string) {
const input = searchInput()
input.value = value
input.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
return input
}
async function goToPage(page: number) {
harness.wrapper!.findComponent(ActivityPager).vm.$emit('update:page', page)
await flushPromises()
}
function group(label: string) {
return document.body.querySelector(`[role="group"][aria-label="${label}"]`)!
}
async function clickIn(label: string, text: string) {
const button = Array.from(group(label).querySelectorAll('button')).find(
(candidate) => textOf(candidate) === text,
)
await click(button)
}
describe('Filter bar: each control sends its query parameter and resets the page', () => {
beforeEach(() => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
})
async function onPage3() {
await mountActivity({
list: (query) => pageOf(activityItems, { page: Number(query.get('page')) }),
})
await goToPage(3)
expect(queryObject(lastListQuery())).toEqual({ ...DEFAULT_QUERY, page: '3' })
}
it('types "spring" and waits 300 ms', async () => {
await onPage3()
const before = callsTo(LIST_PATH).length
await typeSearch('spring')
vi.advanceTimersByTime(299)
await flushPromises()
expect(callsTo(LIST_PATH)).toHaveLength(before)
vi.advanceTimersByTime(1)
await flushPromises()
expect(callsTo(LIST_PATH)).toHaveLength(before + 1)
expect(queryObject(lastListQuery())).toEqual({ ...DEFAULT_QUERY, q: 'spring' })
})
it('types "spring" and presses Enter at once', async () => {
await onPage3()
const before = callsTo(LIST_PATH).length
const input = await typeSearch('spring')
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
await flushPromises()
expect(callsTo(LIST_PATH)).toHaveLength(before + 1)
expect(queryObject(lastListQuery())).toEqual({ ...DEFAULT_QUERY, q: 'spring' })
vi.advanceTimersByTime(500)
await flushPromises()
expect(callsTo(LIST_PATH)).toHaveLength(before + 1)
})
it.each([
['Marta Alves (user 3)', 'user:3', 'Marta Alves', { actor: 'user:3' }],
['Website sync (API key 7)', 'key:7', 'Website sync', { actor: 'key:7' }],
['System', 'system', 'System', { actor: 'system' }],
])('chooses Person %s', async (_, value, label, expected) => {
await onPage3()
harness.wrapper!.findComponent(ActivityPersonSelect).vm.$emit('select', value, label)
await flushPromises()
expect(queryObject(lastListQuery())).toEqual({ ...DEFAULT_QUERY, ...expected })
expect(chipTexts()).toEqual([`Person: ${label}`])
})
it('chooses Category "Users and invites"', async () => {
await onPage3()
harness.wrapper!.findComponent(ActivityCategorySelect).vm.$emit('select', 'users')
await flushPromises()
expect(queryObject(lastListQuery())).toEqual({ ...DEFAULT_QUERY, category: 'users' })
})
it('chooses Outcome "Succeeded", then "All"', async () => {
await onPage3()
await clickIn('Outcome', 'Succeeded')
expect(queryObject(lastListQuery())).toEqual({ ...DEFAULT_QUERY, outcome: 'success' })
expect(chipTexts()).toEqual(['Outcome: Succeeded'])
await clickIn('Outcome', 'All')
expect(queryObject(lastListQuery())).toEqual(DEFAULT_QUERY)
})
it.each([
['Today', { range: 'today', page: '1', size: '25' }],
['30 days', { range: '30d', page: '1', size: '25' }],
])('chooses "%s"', async (label, expected) => {
await onPage3()
await clickIn('Dates', label)
expect(queryObject(lastListQuery())).toEqual(expected)
expect(queryObject(callsTo(SUMMARY_PATH).at(-1))).toEqual({ range: expected.range })
})
})
describe('Filter bar: custom dates', () => {
it('applies on the second date and shows the range chip', async () => {
await mountActivity()
await clickIn('Dates', 'Custom')
await vi.waitFor(() =>
expect(harness.wrapper!.findComponent(UCalendar).exists()).toBe(true),
)
const calendar = harness.wrapper!.findComponent(UCalendar)
calendar.vm.$emit('update:modelValue', {
start: new CalendarDate(2026, 9, 1),
end: undefined,
})
await flushPromises()
expect(callsTo(LIST_PATH)).toHaveLength(1)
expect(callsTo(SUMMARY_PATH)).toHaveLength(1)
calendar.vm.$emit('update:modelValue', {
start: new CalendarDate(2026, 9, 1),
end: new CalendarDate(2026, 9, 30),
})
await flushPromises()
const expected = { range: 'custom', from: '2026-09-01', to: '2026-09-30' }
expect(queryObject(lastListQuery())).toEqual({ ...expected, page: '1', size: '25' })
expect(queryObject(callsTo(SUMMARY_PATH).at(-1))).toEqual(expected)
expect(chipTexts()).toEqual(['Dates: 1 – 30 Sep 2026'])
})
it('words a range across months', async () => {
await mountActivity()
await clickIn('Dates', 'Custom')
await vi.waitFor(() =>
expect(harness.wrapper!.findComponent(UCalendar).exists()).toBe(true),
)
harness.wrapper!.findComponent(UCalendar).vm.$emit('update:modelValue', {
start: new CalendarDate(2026, 9, 28),
end: new CalendarDate(2026, 10, 3),
})
await flushPromises()
expect(chipTexts()).toEqual(['Dates: 28 Sep – 3 Oct 2026'])
})
})
describe('Chips', () => {
it('removes one filter at a time and clears all', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
await mountActivity()
await typeSearch('spring')
vi.advanceTimersByTime(300)
await flushPromises()
await clickIn('Outcome', 'Failed')
await clickIn('Dates', '30 days')
expect(chipTexts()).toEqual(['Search: spring', 'Outcome: Failed', 'Dates: 30 days'])
expect(callsTo(SUMMARY_PATH)).toHaveLength(2)
await click(findButton('Remove filter Outcome: Failed'))
expect(queryObject(lastListQuery())).toEqual({
range: '30d',
page: '1',
size: '25',
q: 'spring',
})
expect(chipTexts()).toEqual(['Search: spring', 'Dates: 30 days'])
expect(callsTo(SUMMARY_PATH)).toHaveLength(2)
await clickButton('Clear all')
expect(queryObject(lastListQuery())).toEqual(DEFAULT_QUERY)
expect(queryObject(callsTo(SUMMARY_PATH).at(-1))).toEqual({ range: '7d' })
expect(chipTexts()).toEqual([])
expect(findButton('Clear all')).toBeUndefined()
expect(searchInput().value).toBe('')
})
})
describe('Strip cards apply their filter', () => {
it('clears every filter from the Total card', async () => {
await mountActivity()
await clickIn('Outcome', 'Failed')
expect(chipTexts()).toEqual(['Outcome: Failed'])
await click(stripCards()[0])
expect(queryObject(lastListQuery())).toEqual(DEFAULT_QUERY)
expect(chipTexts()).toEqual([])
stripCards().forEach((card) => expect(card.getAttribute('aria-pressed')).not.toBe('true'))
})
it('does nothing from the Active people card', async () => {
await mountActivity()
const before = harness.calls.length
await click(stripCards()[1])
expect(harness.calls).toHaveLength(before)
expect(stripCards()[1]!.hasAttribute('aria-pressed')).toBe(false)
})
it('applies outcome=failed from the Failed card and marks it pressed', async () => {
await mountActivity()
await click(stripCards()[2])
expect(queryObject(lastListQuery())).toEqual({ ...DEFAULT_QUERY, outcome: 'failed' })
expect(chipTexts()).toEqual(['Outcome: Failed'])
expect(findButton('Clear all')?.disabled).toBe(false)
expect(stripCards()[2]!.getAttribute('aria-pressed')).toBe('true')
expect(stripCards()[2]!.className).toContain('border-primary')
})
it('applies security=true from the Security card and marks it pressed', async () => {
await mountActivity()
await click(stripCards()[3])
expect(queryObject(lastListQuery())).toEqual({ ...DEFAULT_QUERY, security: 'true' })
expect(chipTexts()).toEqual(['Security events'])
expect(stripCards()[3]!.getAttribute('aria-pressed')).toBe('true')
})
it('stacks the Failed card on other filters without asking for the summary again', async () => {
await mountActivity({
list: (query) =>
pageOf([makeItem({ id: 3 })], { total: query.get('outcome') ? 6 : 1284 }),
})
harness.wrapper!.findComponent(ActivityCategorySelect).vm.$emit('select', 'auth')
await flushPromises()
await clickIn('Dates', '30 days')
await click(stripCards()[2])
expect(chipTexts()).toEqual([
'Category: Authentication',
'Outcome: Failed',
'Dates: 30 days',
])
expect(callsTo(SUMMARY_PATH)).toHaveLength(2)
expect(queryObject(callsTo(SUMMARY_PATH).at(-1))).toEqual({ range: '30d' })
expect(textOf(document.body.querySelector('[data-testid="activity-pager"]'))).toContain(
'Showing 1–6 of 6',
)
})
})
describe('The Person menu', () => {
it('loads its options once, on first open, and lists users, keys then System', async () => {
await mountActivity({
users: () => [
{
kind: 'user',
id: 3,
username: 'marta',
display_name: 'Marta Alves',
role: 'owner',
},
{ kind: 'user', id: 4, username: 'jonas', display_name: '', role: 'admin' },
{ kind: 'invite', id: 9, email: 'x@example.com', status: 'pending' },
],
keys: () => [{ id: 7, name: 'Website sync' }],
})
expect(callsTo(USERS_PATH)).toHaveLength(0)
expect(callsTo(KEYS_PATH)).toHaveLength(0)
const person = harness.wrapper!.findComponent(ActivityPersonSelect)
person.findComponent(USelectMenu).vm.$emit('update:open', true)
await flushPromises()
expect(callsTo(USERS_PATH)).toHaveLength(1)
expect(callsTo(KEYS_PATH)).toHaveLength(1)
const groups = person.findComponent(USelectMenu).props('items') as Record<
string,
string
>[][]
expect(groups.map((items) => items.map((item) => item.label))).toEqual([
['Users', 'Marta Alves', 'jonas'],
['API keys', 'Website sync'],
['System'],
])
expect(
groups
.flat()
.map((item) => item.value)
.filter(Boolean),
).toEqual(['user:3', 'user:4', 'key:7', 'system'])
person.findComponent(USelectMenu).vm.$emit('update:open', true)
await flushPromises()
expect(callsTo(USERS_PATH)).toHaveLength(1)
expect(callsTo(KEYS_PATH)).toHaveLength(1)
})
})
describe('Pagination', () => {
it('moves to page 2, changes the page size and reaches the last page', async () => {
await mountActivity({
list: (query) =>
pageOf(activityItems, {
page: Number(query.get('page')),
size: Number(query.get('size')),
}),
})
const pager = () => textOf(document.body.querySelector('[data-testid="activity-pager"]'))
await goToPage(2)
expect(queryObject(lastListQuery())).toEqual({ ...DEFAULT_QUERY, page: '2' })
expect(pager()).toContain('Showing 26–50 of 1,284')
const activePage = document.body.querySelector(
'[data-testid="activity-pager"] [aria-current="page"]',
)
expect(textOf(activePage)).toBe('2')
harness.wrapper!.findComponent(ActivityPager).vm.$emit('update:size', 50)
await flushPromises()
expect(queryObject(lastListQuery())).toEqual({ ...DEFAULT_QUERY, page: '1', size: '50' })
expect(pager()).toContain('Showing 1–50 of 1,284')
harness.wrapper!.findComponent(ActivityPager).vm.$emit('update:size', 25)
await flushPromises()
await goToPage(52)
expect(pager()).toContain('Showing 1,276–1,284 of 1,284')
expect(harness.wrapper!.findComponent(ActivityPager).props('page')).toBe(52)
expect(rows()).toHaveLength(activityItems.length)
})
})
describe('Mobile: the list is cards and the controls collapse into a sheet', () => {
function mobileList() {
return mountActivity({
desktop: false,
list: () => pageOf(activityItems, { total: 6 }),
})
}
async function applyAuthFailed() {
harness.wrapper!.findComponent(ActivityCategorySelect).vm.$emit('select', 'auth')
await flushPromises()
}
it('shows search and Filters with a badge, cards, and the short pager', async () => {
await mobileList()
const sheet = () => document.body.querySelector('[data-testid="activity-filters-sheet"]')
expect(document.body.querySelector('[role="group"][aria-label="Outcome"]')).toBeNull()
expect(document.body.querySelector('[data-testid="activity-filter-count"]')).toBeNull()
expect(textOf(findButton('Filters'))).toBe('Filters')
expect(sheet()).toBeNull()
await click(findButton('Filters'))
harness.wrapper!.findComponent(ActivityCategorySelect).vm.$emit('select', 'auth')
await flushPromises()
await clickIn('Outcome', 'Failed')
expect(textOf(document.body.querySelector('[data-testid="activity-filter-count"]'))).toBe(
'2',
)
expect(rows().every((row) => row.getAttribute('data-testid') === 'activity-card')).toBe(
true,
)
expect(document.body.querySelector('[role="table"]')).toBeNull()
expect(textOf(document.body.querySelector('[data-testid="activity-pager"]'))).toContain(
'Page 1 of 1',
)
expect(textOf(document.body.querySelector('[data-testid="activity-pager"]'))).toContain(
'1–6 of 6',
)
})
it('draws the sheet with its footer and closes it from "Show 6 results"', async () => {
await mobileList()
await click(findButton('Filters'))
await applyAuthFailed()
const sheet = document.body.querySelector('[data-testid="activity-filters-sheet"]')!
expect(document.body.querySelector('[role="dialog"]')?.textContent).toContain('Filters')
expect(textOf(sheet)).toContain('Person')
expect(textOf(sheet)).toContain('Category')
expect(textOf(sheet)).toContain('Outcome')
expect(textOf(sheet)).toContain('Dates')
expect(findButton('Clear all')?.disabled).toBe(false)
expect(sheet.querySelectorAll('input[type="date"]')).toHaveLength(0)
await clickIn('Dates', 'Custom')
expect(sheet.querySelectorAll('input[type="date"]')).toHaveLength(2)
await clickButton('Show 6 results')
await vi.waitFor(() =>
expect(
document.body.querySelector('[data-testid="activity-filters-sheet"]'),
).toBeNull(),
)
expect(chipTexts()).toEqual(['Category: Authentication'])
})
it('applies the two custom dates from the sheet', async () => {
await mobileList()
await click(findButton('Filters'))
await clickIn('Dates', 'Custom')
const [from, to] = Array.from(
document.body.querySelectorAll<HTMLInputElement>('input[type="date"]'),
)
from!.value = '2026-09-01'
from!.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
expect(callsTo(LIST_PATH)).toHaveLength(1)
to!.value = '2026-09-30'
to!.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
expect(queryObject(lastListQuery())).toEqual({
range: 'custom',
from: '2026-09-01',
to: '2026-09-30',
page: '1',
size: '25',
})
})
it('words the footer for a single result and disables Clear all at 0 filters', async () => {
await mountActivity({
desktop: false,
list: () => pageOf([activityItems[0]!], { total: 1 }),
})
await click(findButton('Filters'))
expect(textOf(findButton('Show 1 result'))).toBe('Show 1 result')
expect(findButton('Clear all')?.disabled).toBe(true)
})
})
describe('Summary follows only the date range', () => {
it('does not request the summary again for the other filters', async () => {
await mountActivity({ summary: () => summaryDefault })
await clickIn('Outcome', 'Failed')
harness.wrapper!.findComponent(ActivityCategorySelect).vm.$emit('select', 'media')
await flushPromises()
expect(callsTo(SUMMARY_PATH)).toHaveLength(1)
})
})
@@ -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é')
})
})
@@ -0,0 +1,666 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { flushPromises } from '@vue/test-utils'
import router from '@/router'
import { ApiError, NetworkError, useApi } from '@/composables/useApi'
import { initializeLocale } from '@/i18n'
import type { ActivityItem } from '@/lib/activity'
import {
LIST_PATH,
SUMMARY_PATH,
callsTo,
chipTexts,
clickButton,
days,
findButton,
expectIcon,
mountActivity,
queryObject,
rows,
stripCards,
textOf,
unmountActivity,
} from './activityHarness'
import {
activityItems,
anonymous,
apiKey,
makeItem,
pageOf,
summaryDefault,
summaryNone,
summaryZero,
system,
target,
user,
} 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 }) }
})
beforeEach(() => {
toastAdd = vi.fn()
localStorage.clear()
initializeLocale()
})
afterEach(async () => {
vi.useRealTimers()
unmountActivity()
vi.mocked(useApi).mockReset()
await router.push('/login')
})
function pageText() {
return textOf(document.body)
}
function stripTexts() {
return stripCards().map(textOf)
}
describe('Navigation', () => {
it('lists Activity between Users and Integration with the history icon and the current page', async () => {
await mountActivity()
const links = Array.from(
document.body.querySelectorAll<HTMLAnchorElement>('nav[aria-label="Main"] a'),
)
expect(links.map((link) => link.getAttribute('href'))).toEqual([
'/home',
'/article',
'/media',
'/users',
'/activity',
'/integration',
'/settings',
])
const activity = links[4]!
expect(activity.getAttribute('aria-current')).toBe('page')
await expectIcon(activity, 'history')
expect(textOf(document.body.querySelector('nav[aria-label="breadcrumb"]'))).toBe('Activity')
expect(router.currentRoute.value.meta).toEqual({ auth: 'verified', title: 'Activity' })
})
})
describe('Default: an owner sees the strip, the filter bar and a day-grouped table', () => {
it('draws the header, the strip, the filter bar, the rows and the pager', async () => {
await mountActivity()
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(stripTexts()).toEqual([
expect.stringContaining('Total actions'),
expect.stringContaining('Active people'),
expect.stringContaining('Failed actions'),
expect.stringContaining('Security events'),
])
expect(stripTexts()[0]).toContain('1,284')
expect(stripTexts()[0]).toContain('12% vs previous 7 days')
expect(stripTexts()[1]).toContain('9')
expect(stripTexts()[1]).toContain('7 people, 2 API keys')
expect(stripTexts()[2]).toContain('0.5% of all actions')
expect(stripTexts()[3]).toContain('11 flagged')
await expectIcon(stripCards()[0], 'trending-up')
await expectIcon(stripCards()[2], 'circle-x')
await expectIcon(stripCards()[3], 'shield-alert')
const search = document.body.querySelector<HTMLInputElement>('input[type="text"]')!
expect(search.placeholder).toBe('Search activity')
expect(pageText()).toContain('All people')
expect(pageText()).toContain('All categories')
const outcome = document.body.querySelector('[role="group"][aria-label="Outcome"]')!
expect(outcome.querySelector('[aria-pressed="true"]')?.textContent?.trim()).toBe('All')
const dates = document.body.querySelector('[role="group"][aria-label="Dates"]')!
expect(dates.querySelector('[aria-pressed="true"]')?.textContent?.trim()).toBe('7 days')
expect(chipTexts()).toEqual([])
expect(rows()).toHaveLength(activityItems.length)
expect(days()).toEqual(['3 Oct 2026', '2 Oct 2026'])
expect(textOf(document.body.querySelector('[data-testid="activity-pager"]'))).toContain(
'Showing 1–25 of 1,284',
)
expect(textOf(document.body.querySelector('[data-testid="activity-pager"]'))).toContain(
'Rows per page',
)
})
it('requests the list and the summary once each with the default query', async () => {
await mountActivity()
expect(callsTo(LIST_PATH)).toHaveLength(1)
expect(queryObject(callsTo(LIST_PATH)[0])).toEqual({ range: '7d', page: '1', size: '25' })
expect(callsTo(SUMMARY_PATH)).toHaveLength(1)
expect(queryObject(callsTo(SUMMARY_PATH)[0])).toEqual({ range: '7d' })
})
it('gives every When cell the full local time with seconds as its title', async () => {
await mountActivity()
const cells = Array.from(
document.body.querySelectorAll<HTMLElement>('[data-testid="activity-when"]'),
)
expect(cells).toHaveLength(activityItems.length)
for (const cell of cells) {
expect(cell.textContent?.trim()).toMatch(/^\d{2}:\d{2}$/)
expect(cell.title).toMatch(/^\d{1,2} \w{3} 2026, \d{2}:\d{2}:\d{2}$/)
}
})
})
describe('Rows: the Actor cell and the sentence follow the event', () => {
const cases: {
name: string
item: ActivityItem
actor: string
sentence: string
badge: string
shield: boolean
}[] = [
{
name: 'user, success',
item: makeItem({ id: 1 }),
actor: 'Marta Alves',
sentence: 'Published the article Spring release',
badge: 'Succeeded',
shield: false,
},
{
name: 'api key',
item: makeItem({
id: 2,
action: 'media.upload',
category: 'media',
actor: apiKey(7, 'Website sync'),
target: target('image', '4', 'north-wall.jpg'),
}),
actor: 'Website sync API key',
sentence: 'Uploaded the image north-wall.jpg',
badge: 'Succeeded',
shield: false,
},
{
name: 'system',
item: makeItem({
id: 3,
action: 'system.media_purge',
category: 'system',
actor: system,
target: null,
params: { merged: 3, articles_updated: 5 },
}),
actor: 'System System',
sentence: 'Ran the scheduled duplicate purge: 3 merged, 5 articles updated',
badge: 'Succeeded',
shield: false,
},
{
name: 'anonymous failed login',
item: makeItem({
id: 4,
action: 'auth.login_failed',
category: 'auth',
actor: anonymous('alex@example.com'),
target: null,
outcome: 'failed',
security: true,
}),
actor: 'Not signed in',
sentence: 'Failed login attempt for alex@example.com',
badge: 'Failed',
shield: true,
},
{
name: 'failed publish',
item: makeItem({ id: 5, actor: user(4, 'Inès Duarte'), outcome: 'failed' }),
actor: 'Inès Duarte',
sentence: 'Failed to publish the article Spring release',
badge: 'Failed',
shield: false,
},
{
name: 'security revoke',
item: makeItem({
id: 6,
action: 'api_key.revoke',
category: 'api_keys',
actor: user(2, 'Jonas Weber'),
target: target('api_key', '71', 'Mobile app'),
security: true,
}),
actor: 'Jonas Weber',
sentence: 'Revoked API key Mobile app',
badge: 'Succeeded',
shield: true,
},
]
it.each(cases)('$name', async ({ item, actor, sentence, badge, shield }) => {
await mountActivity({ list: () => pageOf([item]) })
const row = rows()[0]!
const cells = row.querySelectorAll('[role="cell"]')
expect(textOf(cells[1])).toContain(actor.split(' ')[0])
if (actor === 'Not signed in') {
expect(textOf(cells[1])).toBe('Not signed in')
}
expect(textOf(row.querySelector('[data-testid="activity-sentence"]'))).toBe(sentence)
const strong = Array.from(
row.querySelectorAll('[data-testid="activity-sentence"] .font-semibold'),
).map(textOf)
const name = item.action === 'auth.login_failed' ? item.actor.name : item.target?.name
expect(strong).toEqual(name ? [name] : [])
expect(textOf(cells[cells.length - 1])).toBe(badge)
expect(!!row.querySelector('[aria-label="Security"]')).toBe(shield)
expect(row.className.includes('shadow-flag')).toBe(shield)
})
it('shows the actor initials, the key and the system badge', async () => {
await mountActivity({ list: () => pageOf(activityItems) })
const actorCells = Array.from(
document.body.querySelectorAll('[data-testid="activity-actor"]'),
)
expect(textOf(actorCells[0])).toBe('MAMarta Alves')
const keyCell = actorCells.find((cell) => textOf(cell).includes('Website sync'))!
expect(textOf(keyCell)).toContain('API key')
await expectIcon(keyCell, 'key-round')
const systemCell = actorCells.find((cell) => textOf(cell).startsWith('System'))!
expect(textOf(systemCell)).toBe('SystemSystem')
await expectIcon(systemCell, 'cog')
const anonymousCell = actorCells.find((cell) => textOf(cell) === 'Not signed in')!
await expectIcon(anonymousCell, 'user-round-x')
})
})
describe('Rows: targets', () => {
it('strikes a deleted target through with a Deleted badge and links a living one', async () => {
await mountActivity({
list: () =>
pageOf([
makeItem({
id: 1,
action: 'media.delete',
category: 'media',
target: target('image', '9', 'old-banner.png', false),
}),
makeItem({ id: 2 }),
]),
})
const targets = Array.from(
document.body.querySelectorAll('[data-testid="activity-target"]'),
)
expect(targets[0]!.querySelector('.line-through')?.textContent).toBe('old-banner.png')
expect(textOf(targets[0])).toContain('Deleted')
expect(textOf(targets[0])).toContain('Image')
const link = targets[1]!.querySelector('a')!
expect(link.getAttribute('href')).toBe('/article/8/edit')
expect(textOf(targets[1])).toContain('Article')
})
it('navigates from the target link without opening the details', async () => {
await mountActivity()
const link = document.body.querySelector<HTMLAnchorElement>(
'[data-testid="activity-row"] a[href="/article/8/edit"]',
)!
link.click()
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/article/8/edit'))
expect(document.body.querySelector('[data-testid="activity-details"]')).toBeNull()
})
it.each([
['article', '8', '/article/8/edit'],
['image', '4', '/media'],
['user', '3', '/users'],
['invite', '2', '/users'],
['api_key', '71', '/integration/api-keys'],
])('links a %s target to %s', async (type, id, href) => {
await mountActivity({
list: () => pageOf([makeItem({ id: 1, target: target(type, id, 'Some name') })]),
})
expect(
document.body.querySelector('[data-testid="activity-target"] a')?.getAttribute('href'),
).toBe(href)
})
it('shows a target type with no page as plain text', async () => {
await mountActivity({
list: () =>
pageOf([
makeItem({
id: 1,
action: 'system.update_downloaded',
category: 'system',
actor: system,
target: target('release', 'v1.4.0', 'v1.4.0'),
}),
]),
})
const cell = document.body.querySelector('[data-testid="activity-target"]')!
expect(cell.querySelector('a')).toBeNull()
expect(textOf(cell)).toBe('v1.4.0Release')
})
it('shows a muted dash when there is no target', async () => {
await mountActivity({
list: () => pageOf([makeItem({ id: 1, target: null, action: 'auth.login' })]),
})
const cell = document.body.querySelector(
'[data-testid="activity-row"] [role="cell"]:nth-child(4)',
)!
expect(textOf(cell)).toBe('—')
expect(cell.querySelector('.text-muted')).toBeTruthy()
})
it('renders an unknown action code as it is and still opens the details', async () => {
await mountActivity({
list: () => pageOf([makeItem({ id: 1, action: 'article.archive', target: null })]),
})
const row = rows()[0]!
expect(textOf(row.querySelector('[data-testid="activity-sentence"]'))).toBe(
'article.archive',
)
row.click()
await flushPromises()
expect(document.body.querySelector('[data-testid="activity-details"]')).toBeTruthy()
})
})
describe('Day grouping', () => {
it('groups by local day in API order under separators', async () => {
await mountActivity({
list: () =>
pageOf([
makeItem({ id: 1, created_at: '2026-10-03T12:30:00Z' }),
makeItem({ id: 2, created_at: '2026-10-03T12:10:00Z' }),
makeItem({ id: 3, created_at: '2026-10-02T12:40:00Z' }),
]),
})
expect(days()).toEqual(['3 Oct 2026', '2 Oct 2026'])
const order = Array.from(
document.body.querySelectorAll(
'[data-testid="activity-day"], [data-testid="activity-row"]',
),
).map((element) => element.getAttribute('data-activity-id') ?? textOf(element))
expect(order).toEqual(['3 Oct 2026', '1', '2', '2 Oct 2026', '3'])
})
})
describe('Loading: skeletons after 150 ms', () => {
it('draws nothing in place for 150 ms, then 4 skeleton cards and 9 skeleton rows', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const never = new Promise(() => undefined)
await mountActivity({ list: () => never, summary: () => never })
expect(document.body.querySelector('h1')?.textContent).toBe('Activity')
expect(findButton('Export CSV')?.disabled).toBe(false)
expect(
document.body
.querySelector('[data-testid="activity-filter-bar"] input')
?.getAttribute('aria-label'),
).toBe('Search activity')
expect(document.body.querySelector('[data-testid="activity-strip-skeleton"]')).toBeNull()
expect(document.body.querySelector('[data-testid="activity-skeleton-row"]')).toBeNull()
vi.advanceTimersByTime(149)
await flushPromises()
expect(document.body.querySelector('[data-testid="activity-skeleton-row"]')).toBeNull()
vi.advanceTimersByTime(1)
await flushPromises()
expect(
document.body.querySelectorAll('[data-testid="activity-strip-skeleton"]'),
).toHaveLength(4)
expect(
document.body.querySelectorAll('[data-testid="activity-skeleton-row"]'),
).toHaveLength(9)
expect(document.body.querySelector('[role="table"]')?.getAttribute('aria-busy')).toBe(
'true',
)
expect(document.body.querySelector('[data-testid="activity-pager"]')).toBeNull()
})
it('draws 5 skeleton cards below md and replaces them with the data on resolve', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
let resolveList: (value: unknown) => void = () => undefined
const pending = new Promise((resolve) => (resolveList = resolve))
await mountActivity({ desktop: false, list: () => pending })
vi.advanceTimersByTime(150)
await flushPromises()
expect(
document.body.querySelectorAll('[data-testid="activity-skeleton-card"]'),
).toHaveLength(5)
resolveList(pageOf(activityItems))
await flushPromises()
expect(
document.body.querySelectorAll('[data-testid="activity-skeleton-card"]'),
).toHaveLength(0)
expect(rows()).toHaveLength(activityItems.length)
})
})
describe('Strip', () => {
it('shows a calm 0 for a quiet period', async () => {
await mountActivity({ summary: () => summaryZero })
const texts = stripTexts()
expect(texts[0]).toContain('214')
expect(texts[0]).toContain('4% vs previous 7 days')
await expectIcon(stripCards()[0], 'trending-down')
expect(texts[1]).toContain('5')
expect(texts[1]).toContain('4 people, 1 API key')
expect(texts[2]).toContain('Nothing failed')
expect(texts[3]).toContain('Nothing to flag')
await expectIcon(stripCards()[2], 'circle-check')
await expectIcon(stripCards()[3], 'circle-check')
expect(stripCards()[3]!.className).not.toContain('rc-warning-soft')
expect(stripCards()[2]!.className).not.toContain('rc-error-soft')
const bars = stripCards()[2]!.querySelectorAll<HTMLElement>(
'[data-testid="activity-bars"] span',
)
expect(Array.from(bars).map((bar) => bar.style.height)).toEqual(Array(7).fill('2px'))
})
it.each([
[40, 0, 'No earlier data'],
[40, 40, 'No change'],
])('reads the Total sub-line for %i against %i as %s', async (total, previous, subline) => {
await mountActivity({
summary: () => ({ ...summaryDefault, total, previous_total: previous }),
})
expect(stripTexts()[0]).toContain(subline)
await expectIcon(stripCards()[0], 'trending-up', false)
await expectIcon(stripCards()[0], 'trending-down', false)
})
it('sizes the bars from the series on desktop and mobile', async () => {
await mountActivity()
const heights = Array.from(
stripCards()[0]!.querySelectorAll<HTMLElement>('[data-testid="activity-bars"] span'),
).map((bar) => bar.style.height)
expect(heights).toEqual(['9px', '13px', '11px', '18px', '16px', '20px', '13px'])
unmountActivity()
await mountActivity({ desktop: false })
const mobile = Array.from(
stripCards()[0]!.querySelectorAll<HTMLElement>('[data-testid="activity-bars"] span'),
).map((bar) => bar.style.height)
expect(mobile).toEqual(['7px', '11px', '9px', '14px', '12px', '16px', '11px'])
})
it('is not drawn when the summary fails, and the list still works', async () => {
for (const failure of [new ApiError(500), new ApiError(403), new NetworkError()]) {
await mountActivity({
summary: () => {
throw failure
},
})
expect(stripCards()).toHaveLength(0)
expect(document.body.querySelector('[data-testid="activity-strip"]')).toBeNull()
expect(document.body.querySelector('[role="alert"]')).toBeNull()
expect(rows()).toHaveLength(activityItems.length)
expect(textOf(document.body.querySelector('[data-testid="activity-pager"]'))).toContain(
'Showing 1–25 of 1,284',
)
unmountActivity()
}
})
})
describe('Empty states', () => {
it('shows No activity yet with Export disabled and no table or pager', async () => {
await mountActivity({ list: () => pageOf([], { total: 0 }), summary: () => summaryNone })
expect(pageText()).toContain('No activity yet')
expect(pageText()).toContain(
'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(document.body.querySelector('[role="table"]')).toBeNull()
expect(document.body.querySelector('[data-testid="activity-pager"]')).toBeNull()
const texts = stripTexts()
expect(texts[0]).toContain('No data yet')
expect(texts[1]).toContain('No data yet')
expect(texts[2]).toContain('Nothing failed')
expect(texts[3]).toContain('Nothing to flag')
})
it('offers Clear filters when filters match nothing and resets them', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
let empty = false
await mountActivity({
list: () => (empty ? pageOf([], { total: 0 }) : pageOf(activityItems)),
})
empty = true
const search = document.body.querySelector<HTMLInputElement>('input[type="text"]')!
search.value = 'sitemap'
search.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
vi.advanceTimersByTime(300)
await flushPromises()
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(callsTo(SUMMARY_PATH)).toHaveLength(1)
empty = false
await clickButton('Clear filters')
expect(queryObject(callsTo(LIST_PATH).at(-1))).toEqual({
range: '7d',
page: '1',
size: '25',
})
expect(rows()).toHaveLength(activityItems.length)
expect(chipTexts()).toEqual([])
})
})
describe('Error with retry', () => {
it.each([
['a 500 response', () => new ApiError(500)],
['a 400 response', () => new ApiError(400)],
['a network failure', () => new NetworkError()],
])('replaces the table with an alert after %s and retries', async (_, failure) => {
let failing = true
await mountActivity({
list: () => {
if (failing) throw failure()
return pageOf(activityItems)
},
})
expect(stripCards()).toHaveLength(4)
expect(
document.body
.querySelector('[data-testid="activity-filter-bar"] input')
?.getAttribute('aria-label'),
).toBe('Search activity')
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(document.body.querySelector('[role="table"]')).toBeNull()
expect(document.body.querySelector('[data-testid="activity-pager"]')).toBeNull()
failing = false
await clickButton('Try again')
expect(callsTo(LIST_PATH)).toHaveLength(2)
expect(queryObject(callsTo(LIST_PATH)[1])).toEqual(queryObject(callsTo(LIST_PATH)[0]))
expect(rows()).toHaveLength(activityItems.length)
expect(document.body.querySelector('[role="alert"]')).toBeNull()
})
})
describe('Forbidden (403)', () => {
it('shows only the header and the lock panel, and goes home', async () => {
await mountActivity({
list: () => {
throw new ApiError(403)
},
})
expect(textOf(document.body.querySelector('h1'))).toBe('Activity')
expect(findButton('Export CSV')).toBeUndefined()
expect(stripCards()).toHaveLength(0)
expect(document.body.querySelector('[data-testid="activity-filter-bar"]')).toBeNull()
expect(document.body.querySelector('[data-testid="activity-chips"]')).toBeNull()
expect(document.body.querySelector('[role="table"]')).toBeNull()
expect(document.body.querySelector('[data-testid="activity-pager"]')).toBeNull()
await expectIcon(document.body.querySelector('main, #dashboard-content'), 'lock')
expect(pageText()).toContain("You can't view this activity")
expect(pageText()).toContain(
"Your role doesn't include this page. Ask an owner or admin if you need access.",
)
await clickButton('Back to home')
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/home'))
})
it('shows no panel for a 401, which the API client handles globally', async () => {
await mountActivity({
list: () => {
throw new ApiError(401)
},
})
expect(pageText()).not.toContain("You can't view this activity")
expect(pageText()).not.toContain("Couldn't load activity")
expect(document.body.querySelector('[role="table"]')).toBeNull()
})
it('shows no panel for a 403 on an unverified session', async () => {
await mountActivity({
verified: false,
list: () => {
throw new ApiError(403)
},
})
expect(callsTo(LIST_PATH)).toHaveLength(0)
expect(pageText()).not.toContain("You can't view this activity")
})
})
@@ -1503,6 +1503,7 @@ describe('Sidebar', () => {
'/article',
'/media',
'/users',
'/activity',
'/integration',
'/settings',
])
+2 -2
View File
@@ -279,11 +279,11 @@ describe('Default: the usual first visit with no display name', () => {
expect(request).not.toHaveBeenCalled()
})
it('marks none of the six navigation items as the current page', async () => {
it('marks none of the seven navigation items as the current page', async () => {
await mountApp()
const links = document.body.querySelectorAll('nav[aria-label="Main"] a')
expect(links).toHaveLength(6)
expect(links).toHaveLength(7)
links.forEach((link) => expect(link.getAttribute('aria-current')).toBeNull())
})
+205
View File
@@ -0,0 +1,205 @@
import type {
ActivityActor,
ActivityChange,
ActivityItem,
ActivityPage,
ActivitySummary,
ActivityTarget,
} from '@/lib/activity'
const LONG_TITLE =
'The complete guide to restoring a nineteenth-century timber-framed farmhouse: structural surveys, lime mortar, breathable insulation and what it all costs'
const WEB_IP = '203.0.113.24'
const WEB_AGENT = 'Firefox 131 on macOS'
export const user = (id: number, name: string): ActivityActor => ({ type: 'user', id, name })
export const apiKey = (id: number, name: string): ActivityActor => ({
type: 'api_key',
id,
name,
})
export const system: ActivityActor = { type: 'system', id: null, name: 'System' }
export const anonymous = (name: string): ActivityActor => ({ type: 'anonymous', id: null, name })
export const target = (type: string, id: string, name: string, exists = true): ActivityTarget => ({
type,
id,
name,
exists,
})
export function makeItem(overrides: Partial<ActivityItem> & { id: number }): ActivityItem {
return {
created_at: '2026-10-03T12:30:00Z',
action: 'article.publish',
category: 'articles',
actor: user(3, 'Marta Alves'),
target: target('article', '8', 'Spring release'),
outcome: 'success',
failure_reason: null,
security: false,
source: 'web',
ip: WEB_IP,
user_agent: WEB_AGENT,
params: {},
changes: [],
...overrides,
}
}
const titleChange: ActivityChange = {
field: 'title',
kind: 'text',
before: 'Why we reuse floorboards',
after: 'Why we reuse original floorboards',
}
const bodyChange: ActivityChange = { field: 'body', kind: 'body', delta: 312 }
export const activityItems: ActivityItem[] = [
makeItem({
id: 1,
created_at: '2026-10-03T12:58:00Z',
changes: [{ field: 'online', kind: 'text', before: 'false', after: 'true' }],
}),
makeItem({
id: 2,
created_at: '2026-10-03T12:45:00Z',
action: 'article.edit',
target: target('article', '5', 'Why we reuse original floorboards'),
changes: [titleChange, bodyChange],
}),
makeItem({
id: 3,
created_at: '2026-10-03T12:40:00Z',
action: 'auth.login_failed',
category: 'auth',
actor: anonymous('alex@example.com'),
target: null,
outcome: 'failed',
failure_reason: 'The password did not match. 3 attempts in 10 minutes.',
security: true,
ip: '198.51.100.77',
user_agent: 'Chrome 129 on Windows',
}),
makeItem({
id: 4,
created_at: '2026-10-03T12:35:00Z',
action: 'api_key.revoke',
category: 'api_keys',
actor: user(2, 'Jonas Weber'),
target: target('api_key', '71', 'Mobile app'),
security: true,
}),
makeItem({
id: 5,
created_at: '2026-10-03T12:20:00Z',
action: 'system.media_purge',
category: 'system',
actor: system,
target: null,
source: 'system',
ip: null,
user_agent: null,
params: { merged: 3, articles_updated: 5 },
}),
makeItem({
id: 6,
created_at: '2026-10-03T12:10:00Z',
action: 'media.upload',
category: 'media',
actor: apiKey(7, 'Website sync'),
target: target('image', '4', 'north-wall.jpg'),
source: 'api_key',
}),
makeItem({
id: 7,
created_at: '2026-10-03T12:05:00Z',
action: 'media.delete',
category: 'media',
actor: user(3, 'Marta Alves'),
target: target('image', '9', 'old-banner.png', false),
}),
makeItem({
id: 8,
created_at: '2026-10-03T12:00:00Z',
outcome: 'failed',
failure_reason: 'The article has no cover image. Add one, then publish again.',
actor: user(4, 'Inès Duarte'),
target: target('article', '6', LONG_TITLE),
}),
makeItem({
id: 9,
created_at: '2026-10-02T12:50:00Z',
action: 'system.activity_cleanup',
category: 'system',
actor: system,
target: null,
source: 'system',
ip: null,
user_agent: null,
params: { removed: 3 },
}),
makeItem({
id: 10,
created_at: '2026-10-02T12:40:00Z',
action: 'user.update_profile',
category: 'users',
actor: user(4, 'Inès Duarte'),
target: target('user', '8', 'Sam Rivera'),
changes: [
{ field: 'display_name', kind: 'text', before: 'Sam Rivera', after: 'Samuel Rivera' },
],
}),
]
export const LONG_VALUE = 'Renovation notes and practical guides from the Renew studio. '.repeat(3)
export const TOTAL = 1284
export const SIZE = 25
export function pageOf(
items: ActivityItem[],
{ total = TOTAL, page = 1, size = SIZE }: { total?: number; page?: number; size?: number } = {},
): ActivityPage {
return { items, total, page, size }
}
export const summaryDefault: ActivitySummary = {
total: 1284,
previous_total: 1146,
active_people: { users: 7, api_keys: 2 },
failed: 6,
security: 11,
series: {
total: [4, 6, 5, 8, 7, 9, 6],
active: [5, 6, 6, 7, 7, 8, 9],
failed: [0, 1, 0, 2, 1, 0, 2],
security: [1, 0, 2, 1, 3, 2, 2],
},
}
export const summaryZero: ActivitySummary = {
total: 214,
previous_total: 223,
active_people: { users: 4, api_keys: 1 },
failed: 0,
security: 0,
series: {
total: [3, 5, 2, 6, 4, 1, 2],
active: [2, 3, 2, 3, 2, 1, 2],
failed: [0, 0, 0, 0, 0, 0, 0],
security: [0, 0, 0, 0, 0, 0, 0],
},
}
const ZEROS = [0, 0, 0, 0, 0, 0, 0]
export const summaryNone: ActivitySummary = {
total: 0,
previous_total: 0,
active_people: { users: 0, api_keys: 0 },
failed: 0,
security: 0,
series: { total: ZEROS, active: ZEROS, failed: ZEROS, security: ZEROS },
}
+206
View File
@@ -0,0 +1,206 @@
import { expect, vi } from 'vitest'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import { addCollection } from '@iconify/vue'
import { getIconData } from '@iconify/utils'
import lucide from 'virtual:lucide-subset'
import router from '@/router'
import { useApi } from '@/composables/useApi'
import { useSessionStore } from '@/stores/session'
import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
import ActivityView from '../ActivityView.vue'
import { activityItems, pageOf, summaryDefault } from './activityFixtures'
addCollection(lucide)
export type Responder = (query: URLSearchParams, call: number) => unknown
export interface ActivityApiMock {
list?: Responder
summary?: Responder
exportFile?: Responder
users?: Responder
keys?: Responder
}
export interface MountOptions extends ActivityApiMock {
role?: string
desktop?: boolean
verified?: boolean
}
export interface RecordedCall {
path: string
query: URLSearchParams
}
export const LIST_PATH = '/v1/activity'
export const SUMMARY_PATH = '/v1/activity/summary'
export const EXPORT_PATH = '/v1/activity/export'
export const USERS_PATH = '/v1/user/'
export const KEYS_PATH = '/v1/api-key/'
export const harness = {
calls: [] as RecordedCall[],
wrapper: null as VueWrapper | null,
}
const counters = new Map<string, number>()
function respond(responder: Responder | undefined, query: URLSearchParams, key: string) {
if (!responder) throw new Error(`Unexpected request: ${key}`)
const call = (counters.get(key) ?? 0) + 1
counters.set(key, call)
return responder(query, call)
}
export function callsTo(path: string) {
return harness.calls.filter((call) => call.path === path).map((call) => call.query)
}
export async function mountActivity(options: MountOptions = {}) {
const { desktop = true, verified = true } = options
const role = 'role' in options ? options.role : 'owner'
const api: ActivityApiMock = {
list: () => pageOf(activityItems),
summary: () => summaryDefault,
exportFile: () => new Blob(['when,actor'], { type: 'text/csv' }),
users: () => [],
keys: () => [],
...options,
}
harness.calls = []
counters.clear()
setViewportWidth(desktop ? DESKTOP_WIDTH : MOBILE_WIDTH)
setActivePinia(createPinia())
const session = useSessionStore()
session.session = {
id: 2,
verified,
username: 'ines',
displayName: 'Inès Duarte',
role: role as 'owner' | undefined,
}
session.status = 'ready'
const record = (path: string) => {
const url = new URL(path, 'http://localhost')
harness.calls.push({ path: url.pathname, query: url.searchParams })
return url
}
const request = vi.fn(async (path: string) => {
const url = record(path)
const key = url.pathname
switch (key) {
case LIST_PATH:
return respond(api.list, url.searchParams, key)
case SUMMARY_PATH:
return respond(api.summary, url.searchParams, key)
case USERS_PATH:
return respond(api.users, url.searchParams, key)
case KEYS_PATH:
return respond(api.keys, url.searchParams, key)
default:
throw new Error(`Unexpected request: ${path}`)
}
})
const requestBlob = vi.fn(async (path: string) => {
const url = record(path)
return respond(api.exportFile, url.searchParams, url.pathname) as Blob
})
vi.mocked(useApi).mockReturnValue({
getMe: vi.fn().mockResolvedValue({ verified, role }),
request,
requestBlob,
} as unknown as ReturnType<typeof useApi>)
await router.push('/activity')
harness.wrapper = mount(ActivityView, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
await flushPromises()
return harness.wrapper
}
export function unmountActivity() {
harness.wrapper?.unmount()
harness.wrapper = null
document.body.innerHTML = ''
}
export function textOf(element: Element | null | undefined) {
return (element?.textContent ?? '').replace(/\s+/g, ' ').trim()
}
export function rows() {
return Array.from(
document.body.querySelectorAll<HTMLElement>(
'[data-testid="activity-row"], [data-testid="activity-card"]',
),
)
}
export function days() {
return Array.from(document.body.querySelectorAll('[data-testid="activity-day"]')).map(textOf)
}
export function findButton(text: string) {
return Array.from(document.body.querySelectorAll<HTMLButtonElement>('button')).find(
(button) => textOf(button) === text || button.getAttribute('aria-label') === text,
)
}
export async function click(element: Element | null | undefined) {
;(element as HTMLElement | undefined)?.click()
await flushPromises()
}
export async function clickButton(text: string) {
await click(findButton(text))
}
export function chipTexts() {
return Array.from(document.body.querySelectorAll('[data-testid="activity-chip"]')).map(textOf)
}
export function stripCards() {
return Array.from(
document.body.querySelectorAll<HTMLElement>('[data-testid="activity-strip-card"]'),
)
}
export async function setSearch(value: string) {
const input = document.body.querySelector<HTMLInputElement>('input[type="text"]')!
input.value = value
input.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
return input
}
export function lastListQuery() {
const queries = callsTo(LIST_PATH)
return queries[queries.length - 1]
}
export function queryObject(query: URLSearchParams | undefined) {
return Object.fromEntries(query?.entries() ?? [])
}
export { DESKTOP_WIDTH, MOBILE_WIDTH }
export function hasIcon(root: Element | null | undefined, name: string) {
const body = getIconData(lucide, name)?.body?.replace(/<(\w+)([^>]*?)\/>/g, '<$1$2></$1>')
if (!root || !body) return false
return Array.from(root.querySelectorAll('svg')).some((svg) => svg.innerHTML === body)
}
export async function expectIcon(root: Element | null | undefined, name: string, present = true) {
if (present) {
await vi.waitFor(() => expect(hasIcon(root, name)).toBe(true))
return
}
await new Promise((resolve) => setTimeout(resolve, 50))
expect(hasIcon(root, name)).toBe(false)
}
+377
View File
@@ -2576,7 +2576,384 @@ const mediaPurgeStates: Record<string, VisualState> = {
'Offline: button disabled': mediaState(mediaDuplicatesApi(), goOffline, mediaDuplicatesPath),
}
const activityOwnerSession: ApiMock = {
status: 200,
body: {
id: 2,
is_verified: true,
username: 'ines',
email: 'ines.duarte@renew.example',
display_name: 'Inès Duarte',
role: 'owner',
},
}
const activityEditorSession: ApiMock = {
status: 200,
body: {
id: 3,
is_verified: true,
username: 'marta',
email: 'marta@renew.example',
display_name: 'Marta Alves',
role: 'editor',
},
}
const activitySummaryDefault = {
total: 1284,
previous_total: 1146,
active_people: { users: 7, api_keys: 2 },
failed: 6,
security: 11,
series: {
total: [4, 6, 5, 8, 7, 9, 6],
active: [5, 6, 6, 7, 7, 8, 9],
failed: [0, 1, 0, 2, 1, 0, 2],
security: [1, 0, 2, 1, 3, 2, 2],
},
}
const activitySummaryNone = {
total: 0,
previous_total: 0,
active_people: { users: 0, api_keys: 0 },
failed: 0,
security: 0,
series: {
total: [0, 0, 0, 0, 0, 0, 0],
active: [0, 0, 0, 0, 0, 0, 0],
failed: [0, 0, 0, 0, 0, 0, 0],
security: [0, 0, 0, 0, 0, 0, 0],
},
}
const activityLongTitle =
'The complete guide to restoring a nineteenth-century timber-framed farmhouse: structural surveys, lime mortar, breathable insulation and what it all costs'
function activityEvent(
id: number,
createdAt: string,
action: string,
category: string,
actor: { type: string; id: number | null; name: string },
extra: Record<string, unknown> = {},
) {
return {
id,
created_at: createdAt,
action,
category,
actor,
target: null,
outcome: 'success',
failure_reason: null,
security: false,
source: 'web',
ip: '203.0.113.24',
user_agent: 'Firefox 131 on macOS',
params: {},
changes: [],
...extra,
}
}
const activityTarget = (type: string, id: string, name: string, exists = true) => ({
type,
id,
name,
exists,
})
const activityBoardEvents = [
activityEvent(
1,
'2026-10-06T09:42:18Z',
'article.publish',
'articles',
{ type: 'user', id: 3, name: 'Marta Alves' },
{
target: activityTarget('article', '8', 'Spring release'),
changes: [{ field: 'online', kind: 'text', before: 'false', after: 'true' }],
},
),
activityEvent(
2,
'2026-10-06T09:15:04Z',
'article.edit',
'articles',
{ type: 'user', id: 3, name: 'Marta Alves' },
{
target: activityTarget('article', '5', 'Why we reuse original floorboards'),
changes: [
{
field: 'title',
kind: 'text',
before: 'Why we reuse floorboards',
after: 'Why we reuse original floorboards',
},
{ field: 'body', kind: 'body', delta: 312 },
],
},
),
activityEvent(
3,
'2026-10-06T08:31:47Z',
'auth.login_failed',
'auth',
{ type: 'anonymous', id: null, name: 'alex@example.com' },
{
outcome: 'failed',
security: true,
failure_reason: 'The password did not match. 3 attempts in 10 minutes.',
ip: '198.51.100.77',
user_agent: 'Chrome 129 on Windows',
},
),
activityEvent(
4,
'2026-10-06T07:50:09Z',
'api_key.revoke',
'api_keys',
{ type: 'user', id: 2, name: 'Jonas Weber' },
{
target: activityTarget('api_key', '71', 'Mobile app'),
security: true,
ip: '203.0.113.90',
user_agent: 'Safari 18 on iOS',
},
),
activityEvent(
5,
'2026-10-06T02:00:02Z',
'system.media_purge',
'system',
{ type: 'system', id: null, name: 'System' },
{
source: 'system',
ip: null,
user_agent: null,
params: { merged: 3, articles_updated: 5 },
},
),
activityEvent(
6,
'2026-10-05T17:20:36Z',
'invite.accept',
'users',
{ type: 'user', id: 4, name: 'Inès Duarte' },
{ target: activityTarget('invite', '2', 'jonas@renew.example'), security: true },
),
activityEvent(
7,
'2026-10-05T15:05:51Z',
'media.upload',
'media',
{ type: 'api_key', id: 7, name: 'Website sync' },
{
target: activityTarget('image', '4', 'north-wall.jpg'),
source: 'api_key',
ip: '192.0.2.18',
user_agent: 'node-fetch 3.3',
},
),
activityEvent(
8,
'2026-10-05T13:40:13Z',
'media.delete',
'media',
{ type: 'user', id: 3, name: 'Marta Alves' },
{ target: activityTarget('image', '9', 'old-banner.png', false) },
),
activityEvent(
9,
'2026-10-05T10:12:27Z',
'article.publish',
'articles',
{ type: 'user', id: 4, name: 'Inès Duarte' },
{
target: activityTarget('article', '6', activityLongTitle),
outcome: 'failed',
failure_reason: 'The article has no cover image. Add one, then publish again.',
},
),
activityEvent(
10,
'2026-10-05T02:00:01Z',
'system.activity_cleanup',
'system',
{ type: 'system', id: null, name: 'System' },
{ source: 'system', ip: null, user_agent: null, params: { removed: 142 } },
),
activityEvent(
11,
'2026-10-03T15:48:22Z',
'article.edit',
'articles',
{ type: 'user', id: 4, name: 'Inès Duarte' },
{
target: activityTarget('article', '2', 'Choosing lime plaster for old stone walls'),
changes: [
{
field: 'title',
kind: 'text',
before: 'Renovation notes, project write-ups and practical guides from the Renew studio team in Lisbon, covering timber, lime plaster and stone restoration for older homes.',
after: 'Renovation notes, project write-ups and practical guides from the Renew studio team in Lisbon, covering timber, lime plaster and stone restoration for older homes, plus a seasonal maintenance checklist.',
},
],
},
),
activityEvent(
12,
'2026-10-03T14:02:40Z',
'api_key.create',
'api_keys',
{ type: 'user', id: 2, name: 'Jonas Weber' },
{
target: activityTarget('api_key', '71', 'Mobile app', false),
security: true,
ip: '203.0.113.90',
},
),
activityEvent(
13,
'2026-10-03T13:20:05Z',
'user.update_profile',
'users',
{ type: 'user', id: 4, name: 'Inès Duarte' },
{
target: activityTarget('user', '8', 'Sam Rivera'),
changes: [
{
field: 'display_name',
kind: 'text',
before: 'Sam Rivera',
after: 'Samuel Rivera',
},
],
},
),
activityEvent(
14,
'2026-10-03T10:36:58Z',
'media.upload',
'media',
{ type: 'user', id: 3, name: 'Marta Alves' },
{ target: activityTarget('image', '5', 'floorboards-copy.jpg') },
),
activityEvent(
15,
'2026-10-03T08:05:12Z',
'auth.password_reset_completed',
'auth',
{ type: 'user', id: 4, name: 'Inès Duarte' },
{ target: activityTarget('user', '8', 'Sam Rivera'), security: true },
),
]
function activityPage(items: unknown[], total: number) {
return { items, total, page: 1, size: 25 }
}
function activityApi(
overrides: Record<string, ApiMock> = {},
session: ApiMock = activityOwnerSession,
): Record<string, ApiMock> {
return {
'GET /v1/auth/me': session,
'GET /v1/activity': { status: 200, body: activityPage(activityBoardEvents, 1284) },
'GET /v1/activity/summary': { status: 200, body: activitySummaryDefault },
...overrides,
}
}
const activityClockSensitive: VisualState = { path: '/activity', skip: 'clock-sensitive' }
async function filterActivityToNothing(page: Page) {
const mobile = (page.viewportSize()?.width ?? 0) < 768
const search = page.getByPlaceholder('Search activity')
await search.fill('sitemap')
await search.press('Enter')
if (mobile) {
await page.getByRole('button', { name: /^Filters/ }).click()
}
await page.getByRole('button', { name: 'Category', exact: true }).click()
await page.getByRole('option', { name: 'Media', exact: true }).click()
await page
.getByRole('group', { name: 'Outcome' })
.getByRole('button', { name: 'Failed', exact: true })
.click()
if (mobile) {
await page.getByRole('button', { name: /^Show \d+ results?$/ }).click()
}
await page.getByText('No activity matches these filters').waitFor()
await page.mouse.move(0, 0)
}
const activityStates: Record<string, VisualState> = {
'Default, large dataset': activityClockSensitive,
Loading: {
path: '/activity',
api: activityApi({
'GET /v1/activity': { status: 200, body: activityPage([], 0), hang: true },
'GET /v1/activity/summary': { status: 200, body: activitySummaryDefault, hang: true },
}),
steps: async (page) => {
await page.waitForTimeout(400)
},
},
'Zero-value summary strip': activityClockSensitive,
'Filtered, card applied': activityClockSensitive,
'Mobile filters sheet': activityClockSensitive,
'Empty, no activity yet': {
path: '/activity',
api: activityApi({
'GET /v1/activity': { status: 200, body: activityPage([], 0) },
'GET /v1/activity/summary': { status: 200, body: activitySummaryNone },
}),
},
'Empty, filters match nothing': {
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: filterActivityToNothing,
},
'Error with retry': {
path: '/activity',
api: activityApi({ 'GET /v1/activity': { status: 500 } }),
},
'Forbidden (403)': {
path: '/activity',
api: activityApi({ 'GET /v1/activity': { status: 403 } }, activityEditorSession),
},
'Edit with a diff': activityClockSensitive,
'Settings diff, long text': activityClockSensitive,
'Failed action, very long target title': activityClockSensitive,
'Deleted target, no diff': activityClockSensitive,
'System-initiated, no human actor': activityClockSensitive,
'Security event, failed login': activityClockSensitive,
Default: activityClockSensitive,
Empty: {
path: '/activity',
api: activityApi(
{ 'GET /v1/activity': { status: 200, body: activityPage([], 0) } },
activityEditorSession,
),
},
Details: activityClockSensitive,
}
export const visualStates: Record<string, Record<string, VisualState>> = {
activity: activityStates,
profile: {
'Account menu': {
path: '/home',