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

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

No files matched your search

@@ -239,3 +239,30 @@ describe('getMe maps the account fields', () => {
expect(me.displayName).toBe('')
})
})
describe('useApi abort and blob handling', () => {
it('rethrows an AbortError without flagging the app offline', async () => {
fetchMock.mockRejectedValue(new DOMException('aborted', 'AbortError'))
await expect(useApi().request('/v1/x')).rejects.toMatchObject({ name: 'AbortError' })
expect(isOffline.value).toBe(false)
})
it('requestBlob returns the response blob', async () => {
const blob = new Blob(['a,b'])
fetchMock.mockResolvedValue({ ...mockResponse(200), blob: async () => blob })
await expect(useApi().requestBlob('/v1/activity/export')).resolves.toBe(blob)
})
it('requestBlob reuses the 401 handling and redirects to login', async () => {
fetchMock.mockResolvedValue(mockResponse(401))
await expectApiError(useApi().requestBlob('/v1/activity/export'), { status: 401 })
await settle()
expect(router.currentRoute.value.path).toBe('/login')
expect(router.currentRoute.value.query.reason).toBe('expired')
})
})
+19 -2
View File
@@ -1,6 +1,8 @@
import { useApi } from '@/composables/useApi'
import {
exportAllQuery,
exportQuery,
type ExportRange,
listQuery,
summaryQuery,
type ActivityFilters,
@@ -17,7 +19,6 @@ export interface PersonOptions {
export const ACTIVITY_SKELETON_ROWS = 9
export const ACTIVITY_SKELETON_CARDS = 5
export const EXPORT_FILE_NAME = 'activity.csv'
export function useActivity() {
const { request, requestBlob } = useApi()
@@ -43,6 +44,15 @@ export function useActivity() {
return requestBlob(`/v1/activity/export?${exportQuery(filters)}`)
}
function exportAllActivity(
range: ExportRange,
from: string,
to: string,
signal: AbortSignal,
): Promise<Blob> {
return requestBlob(`/v1/activity/export?${exportAllQuery(range, from, to)}`, { signal })
}
async function listPeople(): Promise<PersonOptions> {
const [users, keys] = await Promise.all([
request<UserListItem[]>('/v1/user/'),
@@ -58,7 +68,14 @@ export function useActivity() {
}
}
return { listActivity, listRecentActivity, getSummary, exportActivity, listPeople }
return {
listActivity,
listRecentActivity,
getSummary,
exportActivity,
exportAllActivity,
listPeople,
}
}
export function downloadBlob(blob: Blob, fileName: string) {
+201
View File
@@ -0,0 +1,201 @@
import { computed, onBeforeUnmount, ref, toValue, type MaybeRefOrGetter } from 'vue'
import { t } from '@/i18n'
import { useLocalizedToast } from '@/i18n/presentation'
import { ApiError, isAbort } from '@/composables/useApi'
import { downloadBlob, useActivity } from '@/composables/useActivity'
import { useSessionStore } from '@/stores/session'
import {
allActivityFileName,
currentViewFileName,
isExportRangeValid,
isHeaderOnlyCsv,
readBlobText,
type ActivityFilters,
NOTHING_TO_EXPORT_COPY,
OWNERS_ONLY_COPY,
type ExportRange,
} from '@/lib/activity'
export type ExportStage = 'configure' | 'preparing' | 'started' | 'failed' | 'empty' | 'denied'
const HTTP_UNAUTHORIZED = 401
const HTTP_FORBIDDEN = 403
const HTTP_BAD_REQUEST = 400
const SERVER_ERROR_COPY =
'Something went wrong on our side. Nothing was changed. Try again in a moment.'
const BAD_REQUEST_COPY = "That request wasn't valid. Check the fields and try again."
export function useActivityExport(filters: MaybeRefOrGetter<ActivityFilters>) {
const session = useSessionStore()
const toast = useLocalizedToast()
const { exportActivity, exportAllActivity } = useActivity()
const forbidden = ref(false)
const currentBusy = ref(false)
const dialogOpen = ref(false)
const stage = ref<ExportStage>('configure')
const range = ref<ExportRange>('all')
const from = ref('')
const to = ref('')
const startedFileName = ref('')
let controller: AbortController | null = null
const canSubmit = computed(
() => stage.value === 'configure' && isExportRangeValid(range.value, from.value, to.value),
)
function notify(
color: 'success' | 'error' | 'neutral',
icon: string,
title: () => string,
description?: () => string,
) {
toast.add(() => ({
color,
icon,
get title() {
return title()
},
get description() {
return description?.()
},
}))
}
function notifyStarted(fileName: string) {
notify(
'success',
'i-lucide-circle-check',
() => t('Download started'),
() => t('{0} is downloading.', [fileName]),
)
}
function isGlobalFailure(error: unknown) {
if (!(error instanceof ApiError)) return false
if (error.status === HTTP_UNAUTHORIZED) return true
return error.status === HTTP_FORBIDDEN && session.session?.verified === false
}
async function hasRows(blob: Blob) {
return !isHeaderOnlyCsv(await readBlobText(blob))
}
async function exportCurrentView() {
if (currentBusy.value) return
currentBusy.value = true
try {
const blob = await exportActivity(toValue(filters))
if (!(await hasRows(blob))) {
notify(
'neutral',
'i-lucide-file-x',
() => t('Nothing to export'),
() => t(NOTHING_TO_EXPORT_COPY),
)
return
}
const fileName = currentViewFileName()
downloadBlob(blob, fileName)
notifyStarted(fileName)
} catch (error) {
failCurrentView(error)
} finally {
currentBusy.value = false
}
}
function failCurrentView(error: unknown) {
if (isGlobalFailure(error)) return
if (error instanceof ApiError && error.status === HTTP_FORBIDDEN) {
forbidden.value = true
notify(
'error',
'i-lucide-lock',
() => t("You can't export all activity"),
() => t(OWNERS_ONLY_COPY),
)
return
}
const copy =
error instanceof ApiError && error.status === HTTP_BAD_REQUEST
? BAD_REQUEST_COPY
: SERVER_ERROR_COPY
notify(
'error',
'i-lucide-circle-x',
() => t("Couldn't prepare the download"),
() => t(copy),
)
}
function openDialog() {
stage.value = 'configure'
range.value = 'all'
from.value = ''
to.value = ''
dialogOpen.value = true
}
async function submit() {
if (stage.value !== 'configure' && stage.value !== 'failed') return
if (!isExportRangeValid(range.value, from.value, to.value)) return
stage.value = 'preparing'
const current = new AbortController()
controller = current
try {
const blob = await exportAllActivity(range.value, from.value, to.value, current.signal)
if (current.signal.aborted) return
if (!(await hasRows(blob))) {
stage.value = 'empty'
return
}
startedFileName.value = allActivityFileName()
downloadBlob(blob, startedFileName.value)
stage.value = 'started'
} catch (error) {
if (current.signal.aborted || isAbort(error)) return
failDialog(error)
} finally {
if (controller === current) controller = null
}
}
function failDialog(error: unknown) {
if (isGlobalFailure(error)) {
dialogOpen.value = false
return
}
if (error instanceof ApiError && error.status === HTTP_FORBIDDEN) {
forbidden.value = true
stage.value = 'denied'
return
}
stage.value = 'failed'
}
function closeDialog() {
controller?.abort()
controller = null
if (stage.value === 'started') notifyStarted(startedFileName.value)
dialogOpen.value = false
}
onBeforeUnmount(() => controller?.abort())
return {
forbidden,
currentBusy,
dialogOpen,
stage,
range,
from,
to,
startedFileName,
canSubmit,
exportCurrentView,
openDialog,
submit,
closeDialog,
}
}
+14 -45
View File
@@ -1,13 +1,6 @@
import { computed, onBeforeUnmount, ref } from 'vue'
import { t } from '@/i18n'
import { useLocalizedToast } from '@/i18n/presentation'
import { ApiError } from '@/composables/useApi'
import {
downloadBlob,
EXPORT_FILE_NAME,
useActivity,
type PersonOptions,
} from '@/composables/useActivity'
import { useActivity, type PersonOptions } from '@/composables/useActivity'
import { useSessionStore } from '@/stores/session'
import {
activeChips,
@@ -52,8 +45,7 @@ function delayedFlag() {
export function useActivityPage() {
const session = useSessionStore()
const toast = useLocalizedToast()
const { listActivity, getSummary, exportActivity, listPeople } = useActivity()
const { listActivity, getSummary, listPeople } = useActivity()
const filters = ref<ActivityFilters>(defaultFilters())
const page = ref(1)
@@ -64,7 +56,6 @@ export function useActivityPage() {
const summary = ref<ActivitySummary | null>(null)
const summaryState = ref<SummaryState>('idle')
const people = ref<PersonOptions | null>(null)
const exporting = ref(false)
const selectedId = ref<number | null>(null)
const navigating = ref(false)
const listSkeleton = delayedFlag()
@@ -85,10 +76,7 @@ export function useActivityPage() {
)
const isEmpty = computed(() => listState.value === 'ready' && total.value === 0)
const exportDisabled = computed(
() =>
exporting.value ||
listState.value === 'error' ||
(isEmpty.value && chips.value.length === 0),
() => listState.value === 'error' || (isEmpty.value && chips.value.length === 0),
)
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / size.value)))
const rangeStart = computed(() => (total.value === 0 ? 0 : (page.value - 1) * size.value + 1))
@@ -190,6 +178,15 @@ export function useActivityPage() {
applyFilters(defaultFilters(), true)
}
function toggleFailed() {
const outcome = filters.value.outcome === 'failed' ? 'all' : 'failed'
applyFilters({ ...filters.value, outcome })
}
function toggleSecurity() {
applyFilters({ ...filters.value, security: !filters.value.security })
}
function goToPage(next: number) {
if (next === page.value || next < 1 || next > pageCount.value) return
fetchPage(next)
@@ -215,34 +212,6 @@ export function useActivityPage() {
}
}
async function runExport() {
if (exportDisabled.value) return
exporting.value = true
try {
const blob = await exportActivity(filters.value)
downloadBlob(blob, EXPORT_FILE_NAME)
toast.add(() => ({
color: 'success',
icon: 'i-lucide-circle-check',
get title() {
return t('Export started. Your file will download shortly.')
},
}))
} catch (error) {
if (!(error instanceof ApiError && error.status === 401)) {
toast.add(() => ({
color: 'error',
icon: 'i-lucide-circle-x',
get title() {
return t("Couldn't export activity. Try again.")
},
}))
}
} finally {
exporting.value = false
}
}
function openDetails(id: number) {
selectedId.value = id
}
@@ -284,7 +253,6 @@ export function useActivityPage() {
summary,
summaryState,
people,
exporting,
chips,
isFullView,
isEmpty,
@@ -303,11 +271,12 @@ export function useActivityPage() {
applyFilters,
removeChip,
clearAll,
toggleFailed,
toggleSecurity,
goToPage,
setSize,
retry,
ensurePeople,
runExport,
openDetails,
closeDetails,
previous: () => step(-1),
+8 -1
View File
@@ -40,6 +40,10 @@ export interface RequestOptions extends RequestInit {
onForbidden?: OnForbidden
}
export function isAbort(error: unknown) {
return error instanceof DOMException && error.name === 'AbortError'
}
export const isOffline = ref(false)
export const authRedirectPending = ref(false)
@@ -107,7 +111,10 @@ export function useApi() {
let response: Response
try {
response = await fetch(path, { credentials: 'include', ...init })
} catch {
} catch (error) {
if (isAbort(error)) {
throw error
}
isOffline.value = true
throw new NetworkError()
}