このコミットが含まれているのは:
2026-10-27 01:11:13 +09:00
コミット 30b5fe5f05
17個のファイルの変更、766行の追加、168行の削除
+3 -1
ファイルの表示
@@ -261,6 +261,8 @@ const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => {
const locale = useLocale ()
const route = resolvePageRoute (location.pathname)
const robots = route?.route.handle?.robots
const pagePath = unlocalisedPath (location.pathname)
const countsPage = pagePath === '/counts' || pagePath.startsWith ('/counts/')
const switchLocale = async (targetLocale: LocaleCode): Promise<string> => {
const resolve = route?.route.handle?.switchLocale
return (resolve != null && route != null)
@@ -377,7 +379,7 @@ const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => {
}>
<LayoutGroup>
<motion.div
layout={animationMode === 'off' ? false : 'position'}
layout={(animationMode === 'off' || countsPage) ? false : 'position'}
transition={{ layout: appLayoutTransition }}
className="relative flex h-dvh w-full flex-col overflow-y-hidden">
<TopNav user={user} locales={locales} resolveLocaleSwitch={switchLocale}/>
+39 -26
ファイルの表示
@@ -22,7 +22,9 @@ const ConditionEditor = (
save: (condition: CountsCondition) => void },
) => {
const { label, message } = useUITranslation ('counts')
const fields = countsFilterFields (definition)
const fields = definition.filterFields ?? definition.fields
const fieldName = (key: string) => (fields.find (item => item.key === key)?.labels
?? [countsFieldLabel (key)]).map (key => message (key).text).join (' › ')
const [condition, setCondition] = useState<CountsCondition> (() => {
const next = { ...initial, field: initial?.field ?? 'resource_id',
operator: initial?.operator ?? 'eq' }
@@ -30,7 +32,7 @@ const ConditionEditor = (
return next
})
const { field, operator } = condition
const operators = definition.fields.find (item => item.key === field)?.operators ?? []
const operators = fields.find (item => item.key === field)?.operators ?? []
useEffect (() => {
controls.setActions ([{ label: label (initial == null ? 'add' : 'shared:actions.edit'),
onSelect: () => save (condition) }])
@@ -39,11 +41,11 @@ const ConditionEditor = (
<select value={field} aria-label={message ('field').text} className={inputClass ()}
onChange={event => {
const field = event.target.value
const operator = definition.fields.find (item => item.key === field)?.operators[0]
const operator = fields.find (item => item.key === field)?.operators[0]
if (operator != null) setCondition ({ field, operator })
}}>
{Object.keys (fields).filter (key => fields[key] !== 'json').map (key => (
<option key={key} value={key}>{message (countsFieldLabel (key)).text}</option>))}
{fields.filter (field => field.operators.length > 0).map (field => (
<option key={field.key} value={field.key}>{fieldName (field.key)}</option>))}
</select>
<select value={operator} aria-label={message ('operator').text} className={inputClass ()}
onChange={event => setCondition (previous => {
@@ -68,6 +70,9 @@ const CountsAdditionalConditions = ({ params, update }: {
const definition = useCountsDefinition ()
const dialogue = useDialogue ()
const conditions = readCountState<CountsCondition[]> (params, 'conditions', [])
const metadata = definition.filterFields ?? definition.fields
const fieldName = (key: string) => (metadata.find (item => item.key === key)?.labels
?? [countsFieldLabel (key)]).map (key => message (key).text).join (' › ')
const write = (next: CountsCondition[]) => update ('conditions', encodeCountState ('conditions', next))
const edit = (index?: number) => {
const initial = index == null ? undefined : conditions[index]
@@ -81,37 +86,45 @@ const CountsAdditionalConditions = ({ params, update }: {
}
const patch = (index: number, change: Partial<CountsCondition>) => write (
conditions.map ((item, position) => position === index ? { ...item, ...change } : item))
const valueInput = (condition: CountsCondition, index: number, key: 'value' | 'end') => {
const type = countsFilterFields (definition)[condition.field]
const aria = message (key === 'end' ? 'through' : 'value')
if (type === 'boolean' || condition.field === 'event_type')
{
const options: string[] = type === 'boolean' ? ['true', 'false'] : [...countsEventTypes]
const namespace = type === 'boolean' ? 'truthValues' : 'events'
return <select key={key} value={condition[key] ?? ''} disabled={condition.blank}
className={inputClass (false, 'w-36 py-1')} aria-label={aria.text}
onChange={event => patch (index, { [key]: event.target.value })}>
<option value=""/>
{options.map (value => <option key={value} value={value}
lang={message (`${namespace}.${value}`).language}>
{message (`${namespace}.${value}`).text}</option>)}
</select>
}
if (type === 'datetime')
return <DateTimeField precision="second" key={key}
value={condition[key] ?? ''} disabled={condition.blank}
aria-label={aria.text} lang={aria.language}
onChange={value => patch (index, { [key]: value ?? '' })}/>
return <input key={key} value={condition[key] ?? ''} disabled={condition.blank}
type={(type === 'integer' || type === 'interval') ? 'number' : 'text'}
className={inputClass (false, 'w-36 py-1')}
aria-label={aria.text} lang={aria.language}
onChange={event => patch (index, { [key]: event.target.value })}/>
}
return <fieldset className="space-y-1">
<legend className="flex items-center gap-2">{label ('additionalConditions')}
<Button type="button" variant="outline" size="sm" onClick={() => edit ()}>
{label ('add')}</Button></legend>
{conditions.map ((condition, index) => {
const datetime = countsFilterFields (definition)[condition.field] === 'datetime'
return <div key={index} className="flex flex-wrap items-center gap-1">
<button type="button" onClick={() => edit (index)}>
{label (countsFieldLabel (condition.field))}
{fieldName (condition.field)}
{' · '}{label (`operators.${condition.operator}`)}
</button>
{(condition.operator === 'range' ? ['value', 'end'] as const : ['value'] as const)
.map (key => condition.field === 'event_type' ? (
<select key={key} value={condition[key] ?? ''} disabled={condition.blank}
className={inputClass (false, 'w-36 py-1')}
aria-label={message ('value').text}
onChange={event => patch (index, { [key]: event.target.value })}>
<option value=""/>
{condition[key] && !countsEventTypes.some (event => event === condition[key])
&& <option value={condition[key]}>{condition[key]}</option>}
{countsEventTypes.map (event => <option key={event} value={event}
lang={message (`events.${event}`).language}>
{message (`events.${event}`).text}</option>)}
</select>) : (datetime ? <DateTimeField precision="second" key={key}
value={condition[key] ?? ''} disabled={condition.blank}
aria-label={message (key === 'end' ? 'through' : 'value').text}
onChange={value => patch (index, { [key]: value ?? '' })}/> : (
<input key={key} value={condition[key] ?? ''} disabled={condition.blank}
className={inputClass (false, 'w-36 py-1')}
aria-label={message ('value').text}
onChange={event => patch (index, { [key]: event.target.value })}/>)))}
.map (key => valueInput (condition, index, key))}
<label className="inline-flex items-center gap-1">
<input type="checkbox" checked={condition.blank ?? false}
onChange={event => patch (index, { blank: event.target.checked })}/>
+5 -3
ファイルの表示
@@ -26,8 +26,9 @@ import type { CountsResource } from '@/lib/counts'
const CountsControls = (
{ resource, applied, open, setOpen, execute, displayJSON, displayRowNumbers,
displayThumbnail, reorder, actions }: {
displayThumbnail, reorder, actions, pending = false }: {
resource: CountsResource; applied: URLSearchParams; open: boolean
pending?: boolean
setOpen: (open: boolean) => void
execute: (params: URLSearchParams) => void
displayJSON: (enabled: boolean) => void
@@ -106,9 +107,9 @@ const CountsControls = (
})
return <form className="shrink-0" onSubmit={event => {
event.preventDefault ()
if (pending) return
const next = new URLSearchParams (draft)
next.delete ('page')
next.delete ('execution_at')
execute (normaliseCountsParams (resource, next, definition))
}}>
{!history && <div className="flex flex-wrap items-center gap-2 border-b pb-1
@@ -130,7 +131,8 @@ const CountsControls = (
</div>
</div>}
<div className="flex flex-wrap items-center gap-2 border-b py-1 dark:border-slate-700">
<Button type="submit" size="sm">{label ('run')}</Button>
<Button type="submit" size="sm" aria-disabled={pending}
className="aria-disabled:opacity-50">{label ('run')}</Button>
<Button type="button" size="sm" variant="outline" aria-expanded={open}
aria-controls={id} onClick={() => setOpen (!open)}>
{label ('conditions')}<span aria-hidden className="inline-block" style={{
+4 -3
ファイルの表示
@@ -8,10 +8,11 @@ import type { FC, ReactNode } from 'react'
type Props = {
children: ReactNode
className?: string }
className?: string
animateLayout?: boolean }
const MainArea: FC<Props> = ({ children, className }) => {
const MainArea: FC<Props> = ({ children, className, animateLayout = true }) => {
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const layoutTransition = clientAnimationTransition (
@@ -23,7 +24,7 @@ const MainArea: FC<Props> = ({ children, className }) => {
<motion.main
transition={{ layout: layoutTransition }}
className={cn ('flex-1 overflow-y-auto p-4', className)}
layout={animationMode === 'off' ? false : 'position'}>
layout={(animationMode === 'off' || !animateLayout) ? false : 'position'}>
{children}
</motion.main>)
}
+3 -1
ファイルの表示
@@ -16,6 +16,7 @@ type Props = {
className?: string
maxWidth?: number
maxWidthRatio?: number
animateLayout?: boolean
onWidthChange?: (width: number) => void
sidebarKey: string
side?: SidebarSide }
@@ -69,6 +70,7 @@ const SidebarComponent: FC<Props> = ({
className,
maxWidth,
maxWidthRatio,
animateLayout = true,
onWidthChange,
sidebarKey,
side = 'left',
@@ -316,7 +318,7 @@ const SidebarComponent: FC<Props> = ({
return (
<motion.div
ref={rootRef}
layout={animationMode === 'off' ? false : 'position'}
layout={(animationMode === 'off' || !animateLayout) ? false : 'position'}
transition={{ layout: layoutTransition }}
style={style}
className={cn (
+22 -4
ファイルの表示
@@ -61,6 +61,10 @@ export type CountsPivotHeader = { values: (string | number | null)[]
kind: 'data' | 'subtotal' | 'grand_total' }
export type CountsResult = {
populationCount?: number
excludedCount?: number
outputCount?: number | null
visibleOutputCount?: number
outputCountScope?: 'before_authorisation' | 'unavailable_private_history'
populationCountScope?: string
rowHeaders?: CountsPivotHeader[]
columnHeaders?: CountsPivotHeader[]
@@ -148,7 +152,7 @@ const nested: Partial<Record<CountColumn, CountField[]>> = {
export const columnsForCounts = (
resource: CountsResource,
definition?: CountsDefinition,
): CountColumn[] => [
): CountColumn[] => definition?.outputFields?.map (field => field.key) ?? [
...(resource === 'posts' ? ['thumbnail'] : []),
'resource_id', 'version_no', 'is_latest',
...(resource === 'posts'
@@ -165,7 +169,8 @@ export const columnsForCounts = (
export const fieldsForCounts = (
resource: CountsResource,
definition?: CountsDefinition,
): CountField[] => columnsForCounts (resource, definition).map (key => ({
): CountField[] => definition?.outputFields
?? columnsForCounts (resource, definition).map (key => ({
key,
label: (key === 'parents' && resource === 'tags') ? 'tags:parents'
: (countColumns[key] ?? countsFieldLabel (key)),
@@ -303,6 +308,17 @@ export const defaultCountsMode = (definition: string | null): 'summary' | 'detai
definition === 'history' ? 'details' : 'summary'
export const countsDefinitionParams = (
definition: 'latest' | 'history',
previous: URLSearchParams,
): URLSearchParams => {
const next = new URLSearchParams ({ definition, mode: defaultCountsMode (definition) })
for (const key of ['timezone', 'row_numbers'])
if (previous.has (key)) next.set (key, previous.get (key)!)
return next
}
export const normaliseCountsParams = (
resource: CountsResource,
source: URLSearchParams,
@@ -311,7 +327,8 @@ export const normaliseCountsParams = (
const params = new URLSearchParams (source)
if (!params.has ('definition'))
params.set ('definition', params.get ('basis') === 'history' ? 'history' : 'latest')
if (!params.has ('current')) params.set ('current', params.get ('at') ? '0' : '1')
if (params.get ('definition') === 'latest' && !params.has ('current'))
params.set ('current', params.get ('at') ? '0' : '1')
const legacy = params.get ('mode')
if (['state', 'trend', 'snapshot'].includes (legacy ?? ''))
params.set ('mode', legacy === 'snapshot' ? 'details' : 'summary')
@@ -392,8 +409,9 @@ export const fetchCounts = (
resource: CountsResource,
params: URLSearchParams,
locale: LocaleCode,
signal?: AbortSignal,
): Promise<CountsResult> => apiGet ('/counts/query', {
locale, params: { ...Object.fromEntries (countsRequestParams (params)), resource } })
locale, signal, params: { ...Object.fromEntries (countsRequestParams (params)), resource } })
+7 -3
ファイルの表示
@@ -9,14 +9,18 @@ import type { CountField, CountsResource } from '@/lib/counts'
export type CountsAxis = { field: string; grain?: string }
export type CountsCondition = {
field: string; operator: string; value?: string; end?: string; blank?: boolean }
export type CountsFieldType = 'string' | 'integer' | 'datetime' | 'enum' | 'json' | 'boolean'
export type CountsFieldType = 'string' | 'integer' | 'datetime' | 'enum'
| 'json' | 'boolean' | 'interval'
export const countsResources: CountsResource[] = [
'posts', 'tags', 'nico_tags', 'tag_descriptions', 'tag_groups', 'materials']
export type CountsFieldMetadata = {
key: string; type: CountsFieldType; nullable: boolean; operators: string[] }
key: string; type: CountsFieldType; nullable: boolean; operators: string[]
labels?: string[]; multiple?: boolean }
export type CountsDefinition = {
fields: CountsFieldMetadata[]
outputFields?: CountField[]
filterFields?: CountsFieldMetadata[]
defaults?: { definition: string; mode: string; params: { key: string; value: string }[] }[]
axes: { latest: string[]; history: string[] }
grains: string[] }
@@ -38,7 +42,7 @@ export const fetchCountsDefinition = (resource: CountsResource): Promise<CountsD
export const countsFilterFields = (
definition: CountsDefinition,
): Record<string, CountsFieldType> => Object.fromEntries (
definition.fields.map (field => [field.key, field.type]))
(definition.filterFields ?? definition.fields).map (field => [field.key, field.type]))
export const countsFieldLabel = (key: string): string => {
+6
ファイルの表示
@@ -68,6 +68,10 @@
"limit": "表示件数"
},
"latestOnly": "最新のみ",
"truthValues": {
"true": "真",
"false": "偽"
},
"boolean": {
"true": "はい",
"false": "いいえ"
@@ -168,6 +172,8 @@
"changedField": "変更項目",
"eventType": "異動区分",
"count": "件数",
"outputTotal": "綜件数:{{value}} 件",
"excludedRecords": "(閲覧権限のないレコード {{count}} 件が除外されました)",
"recordTotal": "閲覧可能件数:{{count}}",
"grain": "粒度",
"grains": {
+190 -61
ファイルの表示
@@ -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>
}