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