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
@@ -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()
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user