450 行
20 KiB
TypeScript
450 行
20 KiB
TypeScript
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
|
import { LoaderCircle } from 'lucide-react'
|
|
import { useEffect, useRef, useState } from 'react'
|
|
import { useSearchParams } from 'react-router-dom'
|
|
|
|
import PrefetchLink from '@/components/PrefetchLink'
|
|
import CountsExportProgress from '@/components/counts/CountsExportProgress'
|
|
import CountsControls from '@/components/counts/CountsControls'
|
|
import CountsSummaryResults from '@/components/counts/CountsSummaryResults'
|
|
import CountsResults from '@/components/counts/CountsResults'
|
|
import CountsTableLayout from '@/components/counts/CountsTableLayout'
|
|
import AnimatedCollapse from '@/components/common/AnimatedCollapse'
|
|
import PageHead from '@/components/common/PageHead'
|
|
import PageTitle from '@/components/common/PageTitle'
|
|
import Pagination from '@/components/common/Pagination'
|
|
import MainArea from '@/components/layout/MainArea'
|
|
import SidebarComponent from '@/components/layout/SidebarComponent'
|
|
import { Button } from '@/components/ui/button'
|
|
import { countsResourcePath, countsRequestParams, countsDefinitionParams,
|
|
countsExportPayload, fieldsForCounts, fetchCounts,
|
|
isCountsState, normaliseCountsParams, selectedCountColumns } from '@/lib/counts'
|
|
import { countsErrorMessage, countsExportLabels } from '@/lib/countsDisplay'
|
|
import { isApiError } from '@/lib/api'
|
|
import { CountsDefinitionContext, countsAxisFields, countsAxisLabel,
|
|
visibleCountsResources, countsFieldLabel,
|
|
fetchCountsDefinition } from '@/lib/countsDefinitions'
|
|
import { countsKeys } from '@/lib/queryKeys'
|
|
import { setCountState, readCountState } from '@/lib/queryState'
|
|
import { cn } from '@/lib/utils'
|
|
import { useClientMotion } from '@/lib/useClientMotion'
|
|
import useDialogue from '@/lib/dialogues/useDialogue'
|
|
import { useLocale } from '@/lib/useLocale'
|
|
import { useUITranslation } from '@/lib/useUITranslation'
|
|
|
|
import type { CountField, CountsResource } from '@/lib/counts'
|
|
import type { CountsAxis, CountsDefinition } from '@/lib/countsDefinitions'
|
|
|
|
type PendingCountsRun = {
|
|
key: ReturnType<typeof countsKeys.index>
|
|
origin: string
|
|
params: URLSearchParams
|
|
target?: string }
|
|
|
|
|
|
const tooManyColumns = (failure: unknown): number | null => {
|
|
if (!isApiError (failure) || failure.response?.status !== 422)
|
|
return null
|
|
const body = (failure.response.data ?? { }) as {
|
|
error?: unknown; maxColumns?: unknown; max_columns?: unknown }
|
|
const maximum = body.maxColumns ?? body.max_columns
|
|
return body.error === 'too_many_columns' && typeof maximum === 'number'
|
|
? maximum : null
|
|
}
|
|
|
|
|
|
const CountsResourceContent = (
|
|
{ resource, definition, definitionError, definitionFailure }: {
|
|
resource: CountsResource
|
|
definition?: CountsDefinition
|
|
definitionError: boolean; definitionFailure?: unknown },
|
|
) => {
|
|
const queryClient = useQueryClient ()
|
|
const [mounted, setMounted] = useState (false)
|
|
useEffect (() => { setMounted (true) }, [])
|
|
const [source, setParams] = useSearchParams ()
|
|
const [timezone, setTimezone] = useState<string | null> (null)
|
|
useEffect (() => {
|
|
setTimezone (Intl.DateTimeFormat ().resolvedOptions ().timeZone)
|
|
}, [])
|
|
const params = normaliseCountsParams (resource, source, definition)
|
|
if (!params.has ('timezone') && timezone != null) params.set ('timezone', timezone)
|
|
const canonical = params.toString ()
|
|
const request = countsRequestParams (params)
|
|
const [open, setOpen] = useState (false)
|
|
const controlsArea = useRef<HTMLDivElement> (null)
|
|
const [controlsHeight, setControlsHeight] = useState (80)
|
|
useEffect (() => {
|
|
const node = controlsArea.current
|
|
if (definition == null || node == null) return
|
|
const measure = () => setControlsHeight (node.getBoundingClientRect ().height)
|
|
measure ()
|
|
if (typeof ResizeObserver === 'undefined') return
|
|
const observer = new ResizeObserver (measure)
|
|
observer.observe (node)
|
|
return () => observer.disconnect ()
|
|
}, [definition])
|
|
const [sidebarOpen, setSidebarOpen] = useState (false)
|
|
const [expanded, setExpanded] = useState (() => new Set<string> ([resource]))
|
|
const motion = useClientMotion ()
|
|
useEffect (() => {
|
|
setExpanded (previous => new Set ([...previous, resource]))
|
|
}, [resource])
|
|
const locale = useLocale ()
|
|
const dialogue = useDialogue ()
|
|
const { label, message } = useUITranslation ('counts')
|
|
const title = message (`titles.${resource}`)
|
|
const mode = params.get ('mode') ?? 'summary'
|
|
const state = isCountsState (params)
|
|
const cacheParams = new URLSearchParams (request)
|
|
cacheParams.set ('definition', state ? 'latest' : 'history')
|
|
const [running, setRunning] = useState (false)
|
|
const [runError, setRunError] = useState (false)
|
|
const [runFailure, setRunFailure] = useState<unknown> (null)
|
|
const [runColumnLimit, setRunColumnLimit] = useState<number | null> (null)
|
|
const pendingRun = useRef<PendingCountsRun | null> (null)
|
|
const applied = useRef (params)
|
|
const retainedParams = useRef (params)
|
|
const requestKey = request.toString ()
|
|
const [exporting, setExporting] = useState (false)
|
|
const exportInFlight = useRef (false)
|
|
const [exportError, setExportError] = useState<{ failure: unknown } | null> (null)
|
|
const { data: cachedData, error: queryError, dataUpdatedAt, isFetching: fetching,
|
|
isPending: pending, isError: errorFlg,
|
|
isPlaceholderData: placeholder } = useQuery ({
|
|
enabled: mounted && definition != null && params.has ('timezone') && !running,
|
|
meta: { clientOnly: true },
|
|
staleTime: params.has ('execution_at') ? 5 * 60 * 1000 : 0,
|
|
queryKey: countsKeys.index (resource, cacheParams.toString (), locale),
|
|
queryFn: ({ signal }) => fetchCounts (resource, params, locale, signal), retry: false,
|
|
placeholderData: (previous, query) =>
|
|
(query?.queryKey[1] === resource && query?.queryKey[2] === locale
|
|
&& queryClient.getQueryData (query.queryKey) === previous)
|
|
? previous : undefined })
|
|
// Match SSR during hydration even when a client cache already contains results.
|
|
const data = mounted ? cachedData : undefined
|
|
|
|
useEffect (() => {
|
|
applied.current = params
|
|
if (data != null && !placeholder && !errorFlg) retainedParams.current = params
|
|
const pending = pendingRun.current
|
|
if (pending == null) return
|
|
if (pending.target === canonical)
|
|
{
|
|
pendingRun.current = null
|
|
setRunning (false)
|
|
}
|
|
else if (pending.origin !== requestKey)
|
|
{
|
|
pendingRun.current = null
|
|
void queryClient.cancelQueries ({ queryKey: pending.key, exact: true })
|
|
setRunning (false)
|
|
}
|
|
}, [canonical, data, errorFlg, placeholder, params, queryClient, requestKey])
|
|
|
|
useEffect (() => {
|
|
setRunning (false)
|
|
setRunError (false)
|
|
setRunColumnLimit (null)
|
|
return () => {
|
|
const pending = pendingRun.current
|
|
pendingRun.current = null
|
|
if (pending != null)
|
|
void queryClient.cancelQueries ({ queryKey: pending.key, exact: true })
|
|
}
|
|
}, [locale, queryClient, resource])
|
|
|
|
useEffect (() => {
|
|
if (running || data == null || placeholder || fetching || errorFlg
|
|
|| !params.has ('timezone')) return
|
|
const next = new URLSearchParams (canonical)
|
|
if (data.executionAt != null && !params.has ('execution_at'))
|
|
{
|
|
next.set ('execution_at', data.executionAt)
|
|
// Commit initial defaults, timezone and server time together after acquisition.
|
|
queryClient.setQueryData (
|
|
countsKeys.index (resource, countsRequestParams (next).toString (), locale),
|
|
data, { updatedAt: dataUpdatedAt })
|
|
}
|
|
if (source.toString () !== next.toString ())
|
|
setParams (next, { replace: true, preventScrollReset: true })
|
|
}, [canonical, data, dataUpdatedAt, errorFlg, fetching, placeholder,
|
|
locale, params, queryClient, resource, running, setParams, source])
|
|
|
|
|
|
const execute = async (draft: URLSearchParams) => {
|
|
if (pendingRun.current != null || definition == null) return
|
|
const next = normaliseCountsParams (resource, draft, definition)
|
|
next.delete ('execution_at')
|
|
if (!next.has ('timezone'))
|
|
next.set ('timezone', Intl.DateTimeFormat ().resolvedOptions ().timeZone)
|
|
const pending: PendingCountsRun = {
|
|
key: countsKeys.index (resource, countsRequestParams (next).toString (), locale),
|
|
origin: requestKey, params: new URLSearchParams (next) }
|
|
const previous = new URLSearchParams (params)
|
|
pendingRun.current = pending
|
|
setRunError (false)
|
|
setRunColumnLimit (null)
|
|
setRunning (true)
|
|
try
|
|
{
|
|
const result = await queryClient.fetchQuery ({ queryKey: pending.key,
|
|
queryFn: ({ signal }) => fetchCounts (resource, next, locale, signal),
|
|
staleTime: 0, retry: false })
|
|
if (pendingRun.current !== pending) return
|
|
if (result.executionAt == null) throw new Error ('Missing Counts execution time')
|
|
// Preserve presentation changes made while this execution was in flight.
|
|
for (const key of ['columns', 'children', 'child_sort', 'order', 'nested_json', 'row_numbers'])
|
|
{
|
|
if (previous.get (key) === applied.current.get (key)) continue
|
|
if (key === 'columns')
|
|
{
|
|
const columns = selectedCountColumns (resource, next, definition)
|
|
.filter (column => column !== 'thumbnail')
|
|
const thumbnail = selectedCountColumns (resource, applied.current, definition)
|
|
.includes ('thumbnail')
|
|
next.set ('columns', (thumbnail ? ['thumbnail', ...columns] : columns).join (' '))
|
|
continue
|
|
}
|
|
next.delete (key)
|
|
for (const value of applied.current.getAll (key)) next.append (key, value)
|
|
}
|
|
const committed = normaliseCountsParams (resource, next, definition)
|
|
committed.set ('execution_at', result.executionAt)
|
|
queryClient.setQueryData (
|
|
countsKeys.index (resource, countsRequestParams (committed).toString (), locale), result)
|
|
pending.target = committed.toString ()
|
|
// Commit only after the complete result is available under the final URL's key.
|
|
if (pending.target === canonical)
|
|
{
|
|
pendingRun.current = null
|
|
setRunning (false)
|
|
}
|
|
else setParams (committed, { preventScrollReset: true })
|
|
}
|
|
catch (failure)
|
|
{
|
|
if (pendingRun.current !== pending)
|
|
return
|
|
pendingRun.current = null
|
|
setRunning (false)
|
|
setRunFailure (failure)
|
|
setRunColumnLimit (tooManyColumns (failure))
|
|
setRunError (true)
|
|
}
|
|
}
|
|
const resultParams = placeholder ? retainedParams.current : params
|
|
const resultMode = resultParams.get ('mode') ?? 'summary'
|
|
const waiting = running || (!definitionError && (definition == null
|
|
|| pending || fetching || placeholder
|
|
|| (!params.has ('execution_at') && !errorFlg)))
|
|
const failure = definitionError ? definitionFailure : (runError ? runFailure : queryError)
|
|
const failureMessage = countsErrorMessage (failure, locale)
|
|
const columnLimit = runColumnLimit ?? tooManyColumns (queryError)
|
|
|
|
|
|
const update = (key: string, value: string, extra?: Record<string, string>) => {
|
|
setRunColumnLimit (null)
|
|
const next = new URLSearchParams (params)
|
|
if (value || ['sort', 'columns', 'children', 'conditions', 'drill'].includes (key))
|
|
setCountState (next, key, value)
|
|
else next.delete (key)
|
|
if (extra != null)
|
|
{
|
|
for (const key of [...next.keys ()])
|
|
if (key === 'measures' || key.startsWith ('measures.')) next.delete (key)
|
|
for (const [key, value] of Object.entries (extra)) next.set (key, value)
|
|
}
|
|
if (key === 'sort')
|
|
next.delete ('page')
|
|
setParams (next)
|
|
}
|
|
const download = async (format: 'csv' | 'json') => {
|
|
if (exportInFlight.current || pendingRun.current != null || definition == null)
|
|
return
|
|
|
|
exportInFlight.current = true
|
|
setExporting (true)
|
|
setExportError (null)
|
|
try
|
|
{
|
|
const axes = [...readCountState<CountsAxis[]> (params, 'rows', []),
|
|
...readCountState<CountsAxis[]> (params, 'cols', [])]
|
|
const fields: CountField[] = mode === 'summary' ? [
|
|
...axes.map (axis => ({
|
|
key: axis.field, label: countsFieldLabel (axis.field) })),
|
|
{ key: 'count', label: 'count' }]
|
|
: selectedCountColumns (resource, params, definition).map (column =>
|
|
fieldsForCounts (resource, definition).find (field => field.key === column)!)
|
|
const headers = mode === 'summary'
|
|
? [...axes.map (axis => countsAxisLabel (axis, definition, locale).text),
|
|
message ('count').text]
|
|
: fields.map (field => message (field.label, field.labelValues).text)
|
|
const payload = countsExportPayload (resource, params, format,
|
|
fields.map (field => field.key), headers,
|
|
{ ...countsExportLabels (resource, locale, definition),
|
|
...Object.fromEntries (countsAxisFields (definition, !state).map (field => [
|
|
field.key, message (field.label).text])), count: message ('count').text },
|
|
definition)
|
|
const handle = { cancel: async () => false }
|
|
await dialogue.form ({ title: label ('exportProgress.title'),
|
|
footerLayout: 'inline', onCancel: () => handle.cancel (),
|
|
body: controls => <CountsExportProgress payload={payload} locale={locale}
|
|
handle={handle} controls={controls}/> })
|
|
|
|
}
|
|
catch (failure)
|
|
{
|
|
setExportError ({ failure })
|
|
}
|
|
finally
|
|
{
|
|
exportInFlight.current = false
|
|
setExporting (false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<CountsTableLayout>
|
|
<div data-sidebar-container
|
|
className="flex min-h-0 flex-1 flex-col overflow-hidden md:flex-row">
|
|
<PageHead title={title}/>
|
|
<Button type="button" variant="ghost" size="sm" className="shrink-0 md:hidden"
|
|
aria-expanded={sidebarOpen} aria-controls="counts-targets"
|
|
onClick={() => setSidebarOpen (!sidebarOpen)}>{label ('target')}</Button>
|
|
<SidebarComponent sidebarKey="counts" maxWidthRatio={.5} animateLayout={false}
|
|
className={cn ('md:block', !sidebarOpen && 'hidden')}>
|
|
<nav id="counts-targets" aria-label={message ('target').text}
|
|
lang={message ('target').language}>
|
|
<h2 className="mb-2 font-semibold">{label ('target')}</h2>
|
|
<ul className="space-y-1">
|
|
{visibleCountsResources.map (target => <li key={target}>
|
|
<button type="button" className="flex w-full items-center gap-1 py-1 text-left
|
|
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500
|
|
dark:focus-visible:ring-blue-400"
|
|
aria-expanded={expanded.has (target)} aria-controls={`counts-definition-${target}`}
|
|
onClick={() => setExpanded (previous => {
|
|
const next = new Set (previous)
|
|
if (next.has (target))
|
|
next.delete (target)
|
|
else
|
|
next.add (target)
|
|
|
|
return next
|
|
})}>
|
|
<span aria-hidden className="inline-block" style={{
|
|
transform: expanded.has (target) ? 'rotate(90deg)' : 'rotate(0deg)',
|
|
transition: `transform ${motion.duration ('collapse')}ms ${motion.easing}` }}>▸</span>
|
|
{label (`resources.${target}`)}
|
|
</button>
|
|
<AnimatedCollapse open={expanded.has (target)} id={`counts-definition-${target}`}>
|
|
<ul className="ml-3">
|
|
{(['latest', 'history'] as const).map (definition => {
|
|
const next = countsDefinitionParams (definition, params)
|
|
const active = target === resource
|
|
&& params.get ('definition') === definition
|
|
return (
|
|
<li key={definition}>
|
|
<PrefetchLink
|
|
to={`${countsResourcePath (target)}?${next}`}
|
|
aria-current={active ? 'page' : undefined}
|
|
className={cn ('block py-1', active && 'font-bold underline')}>
|
|
{label (definition)}
|
|
</PrefetchLink>
|
|
</li>)
|
|
})}
|
|
</ul>
|
|
</AnimatedCollapse>
|
|
</li>)}
|
|
</ul>
|
|
</nav>
|
|
</SidebarComponent>
|
|
<MainArea animateLayout={false} className="flex min-h-0 min-w-0 flex-col p-2">
|
|
<PageTitle className="mb-1 text-base" lang={title.language}>{title.text}</PageTitle>
|
|
<div ref={controlsArea} className="shrink-0"
|
|
style={{ minHeight: definition == null ? controlsHeight : undefined }}>
|
|
{definition != null && <CountsControls key={`${resource}:${params.get ('definition')}`}
|
|
resource={resource} applied={params}
|
|
conditionLabels={errorFlg ? undefined : data?.conditionLabels}
|
|
conditionLabelsParams={resultParams.toString ()}
|
|
pending={running || fetching}
|
|
open={open} setOpen={setOpen}
|
|
reorder={value => update ('order', value)}
|
|
displayJSON={enabled => update ('nested_json', enabled ? '1' : '0')}
|
|
displayRowNumbers={enabled => update ('row_numbers', enabled ? '1' : '0')}
|
|
displayThumbnail={enabled => {
|
|
const columns = selectedCountColumns (resource, params, definition)
|
|
.filter (column => column !== 'thumbnail')
|
|
update ('columns', (enabled ? ['thumbnail', ...columns] : columns).join (' '))
|
|
}}
|
|
execute={next => void execute (next)}
|
|
actions={<div className="ml-auto flex shrink-0 items-center gap-2">
|
|
{(['csv', 'json'] as const).map (format => <Button key={format}
|
|
type="button" variant="outline" size="sm"
|
|
disabled={data == null || errorFlg || placeholder
|
|
|| running || fetching || exporting || !params.get ('execution_at')}
|
|
onClick={() => void download (format)}>
|
|
{label (`exportFormats.${format}`)}</Button>)}
|
|
</div>}/>}
|
|
|
|
{exportError != null && <p role="alert">
|
|
{label ('errors.exportStart')}<br/>
|
|
{countsErrorMessage (exportError.failure, locale).text}</p>}
|
|
</div>
|
|
{columnLimit != null ? <div role="alert">
|
|
<p>{label ('columnLimit.title')}</p>
|
|
<p>{label ('columnLimit.message', { max: columnLimit })}</p>
|
|
</div> : ((definitionError || errorFlg || runError)
|
|
&& <p role="alert" lang={failureMessage.language}
|
|
className="whitespace-pre-line">{failureMessage.text}</p>)}
|
|
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden"
|
|
aria-busy={waiting}>
|
|
<div inert={waiting} aria-hidden={running || undefined}
|
|
className={cn ('flex min-h-0 flex-1 flex-col', running && 'invisible')}>
|
|
{mounted && definition != null && data != null && <>
|
|
<p className="my-1 flex flex-wrap items-baseline gap-x-2 text-sm">
|
|
<span>{label (resultMode === 'summary' ? 'outputRows' : 'outputTotal', { value: data.outputCount ?? '—' })}</span>
|
|
{resultMode === 'details' && data.excludedCountScope === 'current_query'
|
|
&& data.excludedCount != null && data.excludedCount > 0 && (
|
|
<span className="text-xs text-slate-500 dark:text-slate-400">
|
|
{label ('excludedRecords', { count: data.excludedCount })}</span>)}
|
|
</p>
|
|
{resultMode === 'summary' ? <CountsSummaryResults resource={resource}
|
|
data={data} params={resultParams}
|
|
pending={waiting} update={update}/> : (
|
|
<CountsResults resource={resource} data={data} params={resultParams}
|
|
pending={waiting} update={update}/>)}
|
|
<Pagination page={data.page} totalPages={Math.ceil (
|
|
(resultMode === 'summary' ? data.groupCount ?? 0 : data.count) / data.limit)}/>
|
|
</>}
|
|
</div>
|
|
{waiting && <div role="status" aria-live="polite"
|
|
className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
|
<span className="rounded border border-slate-300 bg-white px-2 py-1
|
|
inline-flex items-center gap-2 text-sm text-slate-900 dark:border-slate-600
|
|
dark:bg-slate-950 dark:text-slate-100">
|
|
<LoaderCircle aria-hidden className={cn (
|
|
'h-5 w-5', !motion.reduced && 'animate-spin')}/>
|
|
{label ('shared:messages.loadingEllipsis')}</span>
|
|
</div>}
|
|
</div>
|
|
</MainArea>
|
|
</div>
|
|
</CountsTableLayout>)
|
|
}
|
|
|
|
|
|
const CountsResourcePage = ({ resource }: { resource: CountsResource }) => {
|
|
const locale = useLocale ()
|
|
const { data, error, isError: errorFlg } = useQuery ({
|
|
queryKey: countsKeys.definition (resource, locale),
|
|
queryFn: () => fetchCountsDefinition (resource, locale), staleTime: Infinity })
|
|
return <CountsDefinitionContext.Provider value={data ?? null}>
|
|
<CountsResourceContent resource={resource} definition={data}
|
|
definitionError={errorFlg} definitionFailure={error}/>
|
|
</CountsDefinitionContext.Provider>
|
|
}
|
|
|
|
|
|
export default CountsResourcePage
|