feat: activity screen in the SPA, route /activity
This commit is contained in:
1 parent
e37a46a027
commit
35db442cd3
47 files changed
+7582
-11
No files matched your search
@@ -0,0 +1,69 @@
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import {
|
||||
exportQuery,
|
||||
listQuery,
|
||||
summaryQuery,
|
||||
type ActivityFilters,
|
||||
type ActivityPage,
|
||||
type ActivitySummary,
|
||||
} from '@/lib/activity'
|
||||
import type { UserListItem } from '@/composables/useUsers'
|
||||
import type { ApiKey } from '@/composables/useApiKeys'
|
||||
|
||||
export interface PersonOptions {
|
||||
users: { id: number; name: string }[]
|
||||
keys: { id: number; name: string }[]
|
||||
}
|
||||
|
||||
export const ACTIVITY_SKELETON_ROWS = 9
|
||||
export const ACTIVITY_SKELETON_CARDS = 5
|
||||
export const EXPORT_FILE_NAME = 'activity.csv'
|
||||
|
||||
export function useActivity() {
|
||||
const { request, requestBlob } = useApi()
|
||||
|
||||
function listActivity(
|
||||
filters: ActivityFilters,
|
||||
page: number,
|
||||
size: number,
|
||||
withActor = true,
|
||||
): Promise<ActivityPage> {
|
||||
return request<ActivityPage>(`/v1/activity?${listQuery(filters, page, size, withActor)}`)
|
||||
}
|
||||
|
||||
function getSummary(filters: ActivityFilters): Promise<ActivitySummary> {
|
||||
return request<ActivitySummary>(`/v1/activity/summary?${summaryQuery(filters)}`)
|
||||
}
|
||||
|
||||
function exportActivity(filters: ActivityFilters): Promise<Blob> {
|
||||
return requestBlob(`/v1/activity/export?${exportQuery(filters)}`)
|
||||
}
|
||||
|
||||
async function listPeople(): Promise<PersonOptions> {
|
||||
const [users, keys] = await Promise.all([
|
||||
request<UserListItem[]>('/v1/user/'),
|
||||
request<ApiKey[]>('/v1/api-key/'),
|
||||
])
|
||||
return {
|
||||
users: users.flatMap((item) =>
|
||||
item.kind === 'user'
|
||||
? [{ id: item.id, name: item.display_name || item.username }]
|
||||
: [],
|
||||
),
|
||||
keys: keys.map((key) => ({ id: key.id, name: key.name })),
|
||||
}
|
||||
}
|
||||
|
||||
return { listActivity, getSummary, exportActivity, listPeople }
|
||||
}
|
||||
|
||||
export function downloadBlob(blob: Blob, fileName: string) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = fileName
|
||||
document.body.appendChild(anchor)
|
||||
anchor.click()
|
||||
anchor.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||
import { t } from '@/i18n'
|
||||
import { useLocalizedToast } from '@/i18n/presentation'
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
import {
|
||||
downloadBlob,
|
||||
EXPORT_FILE_NAME,
|
||||
useActivity,
|
||||
type PersonOptions,
|
||||
} from '@/composables/useActivity'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import {
|
||||
activeChips,
|
||||
clearChip,
|
||||
defaultFilters,
|
||||
DEFAULT_PAGE_SIZE,
|
||||
summaryRangeChanged,
|
||||
type ActivityFilters,
|
||||
type ActivityItem,
|
||||
type ActivitySummary,
|
||||
type ChipKey,
|
||||
} from '@/lib/activity'
|
||||
|
||||
export type ListState = 'idle' | 'loading' | 'ready' | 'error' | 'forbidden'
|
||||
export type SummaryState = 'idle' | 'loading' | 'ready' | 'failed'
|
||||
|
||||
const SKELETON_DELAY_MS = 150
|
||||
const HANDLED_STATUSES = [401]
|
||||
|
||||
function delayedFlag() {
|
||||
const flag = ref(false)
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function stop() {
|
||||
if (timer !== null) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
flag.value = false
|
||||
}
|
||||
|
||||
function arm() {
|
||||
stop()
|
||||
timer = setTimeout(() => {
|
||||
timer = null
|
||||
flag.value = true
|
||||
}, SKELETON_DELAY_MS)
|
||||
}
|
||||
|
||||
return { flag, arm, stop }
|
||||
}
|
||||
|
||||
export function useActivityPage() {
|
||||
const session = useSessionStore()
|
||||
const toast = useLocalizedToast()
|
||||
const { listActivity, getSummary, exportActivity, listPeople } = useActivity()
|
||||
|
||||
const filters = ref<ActivityFilters>(defaultFilters())
|
||||
const page = ref(1)
|
||||
const size = ref(DEFAULT_PAGE_SIZE)
|
||||
const items = ref<ActivityItem[]>([])
|
||||
const total = ref(0)
|
||||
const listState = ref<ListState>('idle')
|
||||
const summary = ref<ActivitySummary | null>(null)
|
||||
const summaryState = ref<SummaryState>('idle')
|
||||
const people = ref<PersonOptions | null>(null)
|
||||
const exporting = ref(false)
|
||||
const selectedId = ref<number | null>(null)
|
||||
const navigating = ref(false)
|
||||
const listSkeleton = delayedFlag()
|
||||
const summarySkeleton = delayedFlag()
|
||||
let listSequence = 0
|
||||
let summarySequence = 0
|
||||
let peopleRequested = false
|
||||
let requestedPage = 1
|
||||
|
||||
const role = computed(() => session.session?.role as string | undefined)
|
||||
const isFullView = computed(() => role.value === 'owner' || role.value === 'admin')
|
||||
const chips = computed(() => activeChips(filters.value))
|
||||
const showListSkeleton = computed(
|
||||
() => listState.value === 'loading' && listSkeleton.flag.value,
|
||||
)
|
||||
const showSummarySkeleton = computed(
|
||||
() => summaryState.value === 'loading' && summarySkeleton.flag.value,
|
||||
)
|
||||
const isEmpty = computed(() => listState.value === 'ready' && total.value === 0)
|
||||
const exportDisabled = computed(
|
||||
() =>
|
||||
exporting.value ||
|
||||
listState.value === 'error' ||
|
||||
(isEmpty.value && chips.value.length === 0),
|
||||
)
|
||||
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / size.value)))
|
||||
const rangeStart = computed(() => (total.value === 0 ? 0 : (page.value - 1) * size.value + 1))
|
||||
const rangeEnd = computed(() => Math.min(total.value, page.value * size.value))
|
||||
|
||||
const selectedIndex = computed(() =>
|
||||
items.value.findIndex((item) => item.id === selectedId.value),
|
||||
)
|
||||
const selectedItem = computed(() => items.value[selectedIndex.value] ?? null)
|
||||
const position = computed(() =>
|
||||
selectedIndex.value < 0 ? 0 : (page.value - 1) * size.value + selectedIndex.value + 1,
|
||||
)
|
||||
const canPrevious = computed(() => position.value > 1 && !navigating.value)
|
||||
const canNext = computed(
|
||||
() => position.value > 0 && position.value < total.value && !navigating.value,
|
||||
)
|
||||
|
||||
function failureState(error: unknown): ListState {
|
||||
if (error instanceof ApiError && HANDLED_STATUSES.includes(error.status)) return 'idle'
|
||||
if (error instanceof ApiError && error.status === 403) {
|
||||
return session.session?.verified === false ? 'idle' : 'forbidden'
|
||||
}
|
||||
return 'error'
|
||||
}
|
||||
|
||||
async function fetchPage(
|
||||
nextPage: number,
|
||||
options: { silent?: boolean; select?: 'first' | 'last' } = {},
|
||||
) {
|
||||
const sequence = ++listSequence
|
||||
if (!options.silent) {
|
||||
requestedPage = nextPage
|
||||
listState.value = 'loading'
|
||||
listSkeleton.arm()
|
||||
}
|
||||
try {
|
||||
const result = await listActivity(filters.value, nextPage, size.value, isFullView.value)
|
||||
if (sequence !== listSequence) return false
|
||||
items.value = result.items
|
||||
total.value = result.total
|
||||
page.value = nextPage
|
||||
listState.value = 'ready'
|
||||
if (options.select && result.items.length > 0) {
|
||||
const target = options.select === 'first' ? 0 : result.items.length - 1
|
||||
selectedId.value = result.items[target]!.id
|
||||
}
|
||||
return true
|
||||
} catch (error) {
|
||||
if (sequence !== listSequence) return false
|
||||
if (!options.silent) listState.value = failureState(error)
|
||||
return false
|
||||
} finally {
|
||||
if (sequence === listSequence) listSkeleton.stop()
|
||||
}
|
||||
}
|
||||
|
||||
function loadList() {
|
||||
return fetchPage(1)
|
||||
}
|
||||
|
||||
async function loadSummary() {
|
||||
if (!isFullView.value) return
|
||||
const sequence = ++summarySequence
|
||||
summaryState.value = 'loading'
|
||||
summarySkeleton.arm()
|
||||
try {
|
||||
const result = await getSummary(filters.value)
|
||||
if (sequence !== summarySequence) return
|
||||
summary.value = result
|
||||
summaryState.value = 'ready'
|
||||
} catch {
|
||||
if (sequence !== summarySequence) return
|
||||
summary.value = null
|
||||
summaryState.value = 'failed'
|
||||
} finally {
|
||||
if (sequence === summarySequence) summarySkeleton.stop()
|
||||
}
|
||||
}
|
||||
|
||||
function start() {
|
||||
loadList()
|
||||
loadSummary()
|
||||
}
|
||||
|
||||
function applyFilters(next: ActivityFilters, force = false) {
|
||||
const current = filters.value
|
||||
if (!force && JSON.stringify(next) === JSON.stringify(current)) return
|
||||
const rangeChanged = summaryRangeChanged(current, next)
|
||||
filters.value = next
|
||||
loadList()
|
||||
if (rangeChanged) loadSummary()
|
||||
}
|
||||
|
||||
function removeChip(key: ChipKey) {
|
||||
applyFilters(clearChip(filters.value, key))
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
applyFilters(defaultFilters(), true)
|
||||
}
|
||||
|
||||
function goToPage(next: number) {
|
||||
if (next === page.value || next < 1 || next > pageCount.value) return
|
||||
fetchPage(next)
|
||||
}
|
||||
|
||||
function setSize(next: number) {
|
||||
if (next === size.value) return
|
||||
size.value = next
|
||||
fetchPage(1)
|
||||
}
|
||||
|
||||
function retry() {
|
||||
fetchPage(requestedPage)
|
||||
}
|
||||
|
||||
async function ensurePeople() {
|
||||
if (peopleRequested) return
|
||||
peopleRequested = true
|
||||
try {
|
||||
people.value = await listPeople()
|
||||
} catch {
|
||||
peopleRequested = false
|
||||
}
|
||||
}
|
||||
|
||||
async function runExport() {
|
||||
if (exportDisabled.value) return
|
||||
exporting.value = true
|
||||
try {
|
||||
const blob = await exportActivity(filters.value)
|
||||
downloadBlob(blob, EXPORT_FILE_NAME)
|
||||
toast.add(() => ({
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
get title() {
|
||||
return t('Export started. Your file will download shortly.')
|
||||
},
|
||||
}))
|
||||
} catch (error) {
|
||||
if (!(error instanceof ApiError && error.status === 401)) {
|
||||
toast.add(() => ({
|
||||
color: 'error',
|
||||
icon: 'i-lucide-circle-x',
|
||||
get title() {
|
||||
return t("Couldn't export activity. Try again.")
|
||||
},
|
||||
}))
|
||||
}
|
||||
} finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openDetails(id: number) {
|
||||
selectedId.value = id
|
||||
}
|
||||
|
||||
function closeDetails() {
|
||||
selectedId.value = null
|
||||
}
|
||||
|
||||
async function step(direction: -1 | 1) {
|
||||
const index = selectedIndex.value
|
||||
if (index < 0 || navigating.value || listState.value === 'loading') return
|
||||
const target = index + direction
|
||||
const neighbour = items.value[target]
|
||||
if (neighbour) {
|
||||
selectedId.value = neighbour.id
|
||||
return
|
||||
}
|
||||
if (direction === 1 ? position.value >= total.value : page.value <= 1) return
|
||||
navigating.value = true
|
||||
await fetchPage(page.value + direction, {
|
||||
silent: true,
|
||||
select: direction === 1 ? 'first' : 'last',
|
||||
})
|
||||
navigating.value = false
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
listSkeleton.stop()
|
||||
summarySkeleton.stop()
|
||||
})
|
||||
|
||||
return {
|
||||
filters,
|
||||
page,
|
||||
size,
|
||||
items,
|
||||
total,
|
||||
listState,
|
||||
summary,
|
||||
summaryState,
|
||||
people,
|
||||
exporting,
|
||||
chips,
|
||||
isFullView,
|
||||
isEmpty,
|
||||
exportDisabled,
|
||||
showListSkeleton,
|
||||
showSummarySkeleton,
|
||||
pageCount,
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
selectedId,
|
||||
selectedItem,
|
||||
position,
|
||||
canPrevious,
|
||||
canNext,
|
||||
start,
|
||||
applyFilters,
|
||||
removeChip,
|
||||
clearAll,
|
||||
goToPage,
|
||||
setSize,
|
||||
retry,
|
||||
ensurePeople,
|
||||
runExport,
|
||||
openDetails,
|
||||
closeDetails,
|
||||
previous: () => step(-1),
|
||||
next: () => step(1),
|
||||
}
|
||||
}
|
||||
@@ -101,7 +101,7 @@ export function redirectUnverified(onForbidden: OnForbidden = 'redirect') {
|
||||
}
|
||||
|
||||
export function useApi() {
|
||||
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
async function send(path: string, options: RequestOptions = {}): Promise<Response> {
|
||||
const { onUnauthorized = 'redirect', onForbidden = 'redirect', ...init } = options
|
||||
|
||||
let response: Response
|
||||
@@ -132,6 +132,12 @@ export function useApi() {
|
||||
throw new ApiError(response.status, undefined, await readErrorBody(response))
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const response = await send(path, options)
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as T
|
||||
}
|
||||
@@ -139,6 +145,11 @@ export function useApi() {
|
||||
return response.json()
|
||||
}
|
||||
|
||||
async function requestBlob(path: string, options: RequestOptions = {}): Promise<Blob> {
|
||||
const response = await send(path, options)
|
||||
return response.blob()
|
||||
}
|
||||
|
||||
async function getMe(): Promise<MeResult> {
|
||||
const body = await request<{
|
||||
id?: number
|
||||
@@ -158,5 +169,5 @@ export function useApi() {
|
||||
}
|
||||
}
|
||||
|
||||
return { getMe, request }
|
||||
return { getMe, request, requestBlob }
|
||||
}
|
||||
Reference in new issue
Block a user