このコミットが含まれているのは:
@@ -15,12 +15,12 @@ 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 { countsRequestParams, defaultCountsMode, countsExportPayload, fieldsForCounts, fetchCounts,
|
||||
import { countsRequestParams, countsDefinitionParams, countsExportPayload, fieldsForCounts, fetchCounts,
|
||||
isCountsState, normaliseCountsParams, selectedCountColumns } from '@/lib/counts'
|
||||
import { countsExportLabels } from '@/lib/countsDisplay'
|
||||
import { CountsDefinitionContext, countsAxisFields, countsAxisLabel,
|
||||
countsResources, countsFieldLabel,
|
||||
fetchCountsDefinition, useCountsDefinition }
|
||||
fetchCountsDefinition }
|
||||
from '@/lib/countsDefinitions'
|
||||
import { countsKeys } from '@/lib/queryKeys'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -31,19 +31,44 @@ import { useUITranslation } from '@/lib/useUITranslation'
|
||||
|
||||
import { setCountState, readCountState } from '@/lib/queryState'
|
||||
|
||||
import type { CountsAxis } from '@/lib/countsDefinitions'
|
||||
import type { CountsAxis, CountsDefinition } from '@/lib/countsDefinitions'
|
||||
|
||||
import type { CountsResource } from '@/lib/counts'
|
||||
|
||||
type PendingCountsRun = {
|
||||
key: ReturnType<typeof countsKeys.index>
|
||||
origin: string
|
||||
params: URLSearchParams
|
||||
target?: string }
|
||||
|
||||
const CountsResourceContent = ({ resource }: { resource: CountsResource }) => {
|
||||
const definition = useCountsDefinition ()
|
||||
|
||||
const CountsResourceContent = (
|
||||
{ resource, definition, definitionError }: {
|
||||
resource: CountsResource; definition?: CountsDefinition; definitionError: boolean },
|
||||
) => {
|
||||
const queryClient = useQueryClient ()
|
||||
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 ()
|
||||
@@ -57,42 +82,138 @@ const CountsResourceContent = ({ resource }: { resource: CountsResource }) => {
|
||||
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 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 (false)
|
||||
const { data, dataUpdatedAt, isFetching, isPending, isError,
|
||||
isPlaceholderData, refetch } = useQuery ({
|
||||
enabled: params.has ('timezone'),
|
||||
isPlaceholderData } = useQuery ({
|
||||
enabled: definition != null && params.has ('timezone') && !running,
|
||||
staleTime: params.has ('execution_at') ? 5 * 60 * 1000 : 0,
|
||||
queryKey: countsKeys.index (resource, cacheParams.toString (), locale),
|
||||
queryFn: () => fetchCounts (resource, params, locale), retry: false,
|
||||
placeholderData: (previous, query) => {
|
||||
const previousParams = new URLSearchParams (String (query?.queryKey[3] ?? ''))
|
||||
return (query?.queryKey[1] === resource && query?.queryKey[2] === locale
|
||||
&& previousParams.get ('mode') === mode
|
||||
&& previousParams.get ('rows') === params.get ('rows')
|
||||
&& previousParams.get ('cols') === params.get ('cols')
|
||||
&& previousParams.get ('definition') === cacheParams.get ('definition'))
|
||||
? previous : undefined
|
||||
} })
|
||||
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 })
|
||||
|
||||
useEffect (() => {
|
||||
applied.current = params
|
||||
if (data != null && !isPlaceholderData && !isError) 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, isError, isPlaceholderData, params, queryClient, requestKey])
|
||||
|
||||
useEffect (() => {
|
||||
setRunning (false)
|
||||
setRunError (false)
|
||||
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 || isPlaceholderData || isFetching || isError
|
||||
|| !params.has ('timezone')) return
|
||||
const next = new URLSearchParams (canonical)
|
||||
if (!next.has ('timezone'))
|
||||
next.set ('timezone', Intl.DateTimeFormat ().resolvedOptions ().timeZone)
|
||||
if (data?.executionAt != null && !isPlaceholderData && !isFetching && !isError
|
||||
&& params.has ('timezone') && !params.has ('execution_at'))
|
||||
if (data.executionAt != null && !params.has ('execution_at'))
|
||||
{
|
||||
next.set ('execution_at', data.executionAt)
|
||||
// Pin the response under its server-confirmed time before changing the URL.
|
||||
// Commit initial defaults, timezone and server time together after acquisition.
|
||||
queryClient.setQueryData (
|
||||
countsKeys.index (resource, countsRequestParams (next).toString (), locale),
|
||||
data, { updatedAt: dataUpdatedAt })
|
||||
countsKeys.index (resource, countsRequestParams (next).toString (), locale),
|
||||
data, { updatedAt: dataUpdatedAt })
|
||||
}
|
||||
if (source.toString () !== next.toString ())
|
||||
setParams (next, { replace: true })
|
||||
setParams (next, { replace: true, preventScrollReset: true })
|
||||
}, [canonical, data, dataUpdatedAt, isError, isFetching, isPlaceholderData,
|
||||
locale, params, queryClient, resource, setParams, source])
|
||||
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)
|
||||
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
|
||||
{
|
||||
if (pendingRun.current !== pending) return
|
||||
pendingRun.current = null
|
||||
setRunning (false)
|
||||
setRunError (true)
|
||||
}
|
||||
}
|
||||
const resultParams = isPlaceholderData ? retainedParams.current : params
|
||||
const resultMode = resultParams.get ('mode') ?? 'summary'
|
||||
const waiting = running || (!definitionError && (definition == null
|
||||
|| isPending || isFetching || isPlaceholderData
|
||||
|| (!params.has ('execution_at') && !isError)))
|
||||
const loadingParams = pendingRun.current?.params ?? params
|
||||
const compatible = data != null && ['definition', 'mode', 'rows', 'cols'].every (
|
||||
key => loadingParams.get (key) === resultParams.get (key))
|
||||
|
||||
|
||||
const update = (key: string, value: string, extra?: Record<string, string>) => {
|
||||
@@ -110,7 +231,7 @@ const CountsResourceContent = ({ resource }: { resource: CountsResource }) => {
|
||||
setParams (next)
|
||||
}
|
||||
const download = async (format: 'csv' | 'json') => {
|
||||
if (exportInFlight.current) return
|
||||
if (exportInFlight.current || pendingRun.current != null || definition == null) return
|
||||
exportInFlight.current = true
|
||||
setExporting (true)
|
||||
setExportError (false)
|
||||
@@ -160,7 +281,7 @@ const CountsResourceContent = ({ resource }: { resource: CountsResource }) => {
|
||||
<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}
|
||||
<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}>
|
||||
@@ -184,11 +305,8 @@ const CountsResourceContent = ({ resource }: { resource: CountsResource }) => {
|
||||
</button>
|
||||
<AnimatedCollapse open={expanded.has (target)} id={`counts-definition-${target}`}>
|
||||
<ul className="ml-3">
|
||||
{['latest', 'history'].map (definition => {
|
||||
const next = new URLSearchParams ({
|
||||
definition, mode: defaultCountsMode (definition) })
|
||||
for (const key of ['timezone', 'at', 'current', 'row_numbers'])
|
||||
if (params.get (key)) next.set (key, params.get (key)!)
|
||||
{(['latest', 'history'] as const).map (definition => {
|
||||
const next = countsDefinitionParams (definition, params)
|
||||
const active = target === resource
|
||||
&& params.get ('definition') === definition
|
||||
return <li key={definition}><PrefetchLink
|
||||
@@ -203,11 +321,13 @@ const CountsResourceContent = ({ resource }: { resource: CountsResource }) => {
|
||||
</ul>
|
||||
</nav>
|
||||
</SidebarComponent>
|
||||
<MainArea className="flex min-h-0 min-w-0 flex-col p-2">
|
||||
<MainArea animateLayout={false} className="flex min-h-0 min-w-0 flex-col p-2">
|
||||
<PageTitle className="mb-1 text-base">{label (`titles.${resource}`)}</PageTitle>
|
||||
<div className="shrink-0">
|
||||
<CountsControls
|
||||
<div ref={controlsArea} className="shrink-0"
|
||||
style={{ minHeight: definition == null ? controlsHeight : undefined }}>
|
||||
{definition != null && <CountsControls key={`${resource}:${params.get ('definition')}`}
|
||||
resource={resource} applied={params}
|
||||
pending={running || isFetching || !params.has ('execution_at')}
|
||||
open={open} setOpen={setOpen}
|
||||
reorder={value => update ('order', value)}
|
||||
displayJSON={enabled => update ('nested_json', enabled ? '1' : '0')}
|
||||
@@ -217,36 +337,48 @@ const CountsResourceContent = ({ resource }: { resource: CountsResource }) => {
|
||||
.filter (column => column !== 'thumbnail')
|
||||
update ('columns', (enabled ? ['thumbnail', ...columns] : columns).join (' '))
|
||||
}}
|
||||
execute={next => {
|
||||
if (next.toString () === canonical) void refetch ()
|
||||
else setParams (next)
|
||||
}} actions={<div className="ml-auto flex shrink-0 items-center gap-2">
|
||||
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 || isError || isPlaceholderData
|
||||
|| exporting || !params.get ('execution_at')}
|
||||
|| running || isFetching || exporting || !params.get ('execution_at')}
|
||||
onClick={() => void download (format)}>
|
||||
{label (`exportFormats.${format}`)}</Button>)}
|
||||
</div>}/>
|
||||
</div>}/>}
|
||||
|
||||
{exportError && <p role="alert">{label ('common:fetchError')}</p>}
|
||||
</div>
|
||||
{isPending && <p>{label ('shared:messages.loadingEllipsis')}</p>}
|
||||
{isError && <p role="alert">{label ('common:fetchError')}</p>}
|
||||
{data != null && <>
|
||||
<p className="my-1 flex flex-wrap gap-x-3 text-xs">
|
||||
<span>{label ('recordTotal', { count: data.count })}</span>
|
||||
{data.populationCount != null && <span>{label ('total', {
|
||||
count: data.populationCount })}</span>}
|
||||
</p>
|
||||
{mode === 'summary' ? <CountsSummaryResults resource={resource}
|
||||
data={data} params={params}
|
||||
pending={isPlaceholderData} update={update}/> : (
|
||||
<CountsResults resource={resource} data={data} params={params}
|
||||
pending={isPlaceholderData} update={update}/>)}
|
||||
{(definitionError || isError || runError) && <p role="alert">{label ('common:fetchError')}</p>}
|
||||
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden"
|
||||
aria-busy={waiting}>
|
||||
<div inert={waiting} aria-hidden={waiting || undefined}
|
||||
className={cn ('flex min-h-0 flex-1 flex-col',
|
||||
waiting && (compatible ? 'pointer-events-none opacity-40' : 'invisible'))}>
|
||||
{definition != null && data != null && <>
|
||||
{data.excludedCount != null && (
|
||||
<p className="my-1 flex flex-wrap items-baseline gap-x-2 text-sm">
|
||||
<span>{label ('outputTotal', { value: data.outputCount ?? '—' })}</span>
|
||||
<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 (
|
||||
(mode === 'summary' ? data.groupCount ?? 0 : data.count) / data.limit)}/>
|
||||
(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
|
||||
text-sm text-slate-900 dark:border-slate-600
|
||||
dark:bg-slate-950 dark:text-slate-100">
|
||||
{label ('shared:messages.loadingEllipsis')}</span>
|
||||
</div>}
|
||||
</div>
|
||||
</MainArea>
|
||||
</div>
|
||||
</CountsTableLayout>)
|
||||
@@ -254,14 +386,11 @@ const CountsResourceContent = ({ resource }: { resource: CountsResource }) => {
|
||||
|
||||
|
||||
const CountsResourcePage = ({ resource }: { resource: CountsResource }) => {
|
||||
const { label } = useUITranslation ('counts')
|
||||
const { data, isError } = useQuery ({
|
||||
queryKey: countsKeys.definition (resource),
|
||||
queryFn: () => fetchCountsDefinition (resource), staleTime: Infinity })
|
||||
if (data == null) return <p>{label (
|
||||
isError ? 'common:fetchError' : 'shared:messages.loadingEllipsis')}</p>
|
||||
return <CountsDefinitionContext.Provider value={data}>
|
||||
<CountsResourceContent resource={resource}/>
|
||||
return <CountsDefinitionContext.Provider value={data ?? null}>
|
||||
<CountsResourceContent resource={resource} definition={data} definitionError={isError}/>
|
||||
</CountsDefinitionContext.Provider>
|
||||
}
|
||||
|
||||
|
||||
新しいイシューから参照
ユーザーをブロックする