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