ファイル
btrc-hub/frontend/src/pages/counts/CountsResourcePage.tsx
T
2026-11-01 18:18:54 +09:00

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