このコミットが含まれているのは:
+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}/>
|
||||
|
||||
@@ -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 })}/>
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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>)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 } })
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
|
||||
新しいイシューから参照
ユーザーをブロックする