feat: activity screen in the SPA, route /activity
This commit is contained in:
1 parent
e37a46a027
commit
35db442cd3
47 files changed
+7582
-11
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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)}`
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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'),
|
||||
|
||||
@@ -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',
|
||||
])
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
|
||||
|
||||
@@ -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 },
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user