ファイル
btrc-hub/frontend/src/components/counts/CountsControls.tsx
T
2026-10-31 04:08:05 +09:00

304 行
15 KiB
TypeScript
Raw Blame 履歴

このファイルには曖昧(ambiguous)なUnicode文字が含まれています
このファイルには、他の文字と見間違える可能性があるUnicode文字が含まれています。 それが意図的なものと考えられる場合は、この警告を無視して構いません。 それらの文字を表示するにはエスケープボタンを使用します。
import { useEffect, useId, useRef, useState } from 'react'
import CountsAdditionalConditions from '@/components/counts/CountsAdditionalConditions'
import CountsAxes from '@/components/counts/CountsAxes'
import CountsFieldTree from '@/components/counts/CountsFieldTree'
import AnimatedCollapse from '@/components/common/AnimatedCollapse'
import DateTimeField from '@/components/common/DateTimeField'
import FormField from '@/components/common/FormField'
import HelpPopover from '@/components/common/HelpPopover'
import TagInput from '@/components/common/TagInput'
import { Button } from '@/components/ui/button'
import { countsRequestParams, countsUseLatest, fieldsForCounts, normaliseCountsParams, readCountSort,
selectedCountChildren, selectedCountColumns } from '@/lib/counts'
import { countsNestedIsScalar } from '@/lib/countsDisplay'
import { useCountsDefinition } from '@/lib/countsDefinitions'
import { useClientMotion } from '@/lib/useClientMotion'
import { inputClass } from '@/lib/utils'
import { useUITranslation } from '@/lib/useUITranslation'
import { readCountState, setCountState, encodeCountState } from '@/lib/queryState'
import type { ReactNode } from 'react'
import type { CountsResource, CountsResult } from '@/lib/counts'
const CountsControls = (
{ resource, applied, open, setOpen, execute, displayJSON, displayRowNumbers,
displayThumbnail, reorder, actions, conditionLabels, conditionLabelsParams,
pending = false }: {
resource: CountsResource; applied: URLSearchParams; open: boolean
conditionLabels?: CountsResult['conditionLabels']
conditionLabelsParams?: string
pending?: boolean
setOpen: (open: boolean) => void
execute: (params: URLSearchParams) => void
displayJSON: (enabled: boolean) => void
displayRowNumbers: (enabled: boolean) => void
displayThumbnail: (enabled: boolean) => void
reorder: (value: string) => void; actions?: ReactNode },
) => {
const { label, message } = useUITranslation ('counts')
const definition = useCountsDefinition ()
const id = useId ()
const motion = useClientMotion ()
const [draft, setDraft] = useState (() => new URLSearchParams (applied))
const labelParams = conditionLabelsParams == null
? null : new URLSearchParams (conditionLabelsParams)
const thumbnail = selectedCountColumns (resource, applied, definition).includes ('thumbnail')
const previousApplied = useRef ({ resource, params: new URLSearchParams (applied) })
const appliedState = applied.toString ()
const mode = draft.get ('mode') ?? 'summary'
const columns = selectedCountColumns (resource, draft, definition)
const details = mode === 'details'
const nestedFlg = details && fieldsForCounts (resource, definition).some (field =>
columns.includes (field.key) && field.children != null
&& !countsNestedIsScalar (field.children, field.key, draft)
&& field.children.some (child => selectedCountChildren (draft)
.has (`${field.key}.${child.key}`)))
useEffect (() => {
const nextApplied = new URLSearchParams (appliedState)
const previous = previousApplied.current
previousApplied.current = { resource, params: nextApplied }
setDraft (draft => {
if (previous.resource !== resource
|| previous.params.get ('definition') !== nextApplied.get ('definition'))
return nextApplied
const keys = new Set ([...previous.params.keys (), ...nextApplied.keys ()])
const changed = [...keys].filter (key =>
previous.params.get (key) !== nextApplied.get (key))
const before = selectedCountColumns (resource, previous.params, definition)
const after = selectedCountColumns (resource, nextApplied, definition)
const selectionChanged = before.filter (column => column !== 'thumbnail').join (' ')
!== after.filter (column => column !== 'thumbnail').join (' ')
const internal = ['execution_at', 'timezone', 'order', 'nested_json', 'row_numbers']
if (changed.some (key => key === 'columns' ? selectionChanged : !internal.includes (key)))
return nextApplied
// Internal URL enrichment and immediate display changes preserve pending edits.
const next = new URLSearchParams (draft)
for (const key of changed)
{
if (key === 'columns')
{
const columns = selectedCountColumns (resource, draft, definition)
.filter (column => column !== 'thumbnail')
next.set ('columns', (after.includes ('thumbnail')
? ['thumbnail', ...columns] : columns).join (' '))
continue
}
next.delete (key)
for (const value of nextApplied.getAll (key)) next.append (key, value)
}
return next.toString () === draft.toString () ? draft : next
})
}, [appliedState, resource, definition])
const history = draft.get ('definition') === 'history'
const update = (key: string, value: string) => setDraft (previous => {
const next = new URLSearchParams (previous)
setCountState (next, key, value)
if (key === 'tags')
{
const tokens = value.split (/\s+/).filter (token => token
&& token.toLowerCase () !== 'type:or')
const remaining = [...tokens]
const references = readCountState<Record<string, string | number | boolean>[]> (
previous, 'tag_refs', []).flatMap (reference => {
const name = String (reference.name)
const exact = remaining.indexOf (name)
const index = exact >= 0 ? exact : remaining.findIndex (token =>
token.replace (/^not:/i, '') === name.replace (/^not:/i, ''))
if (index < 0) return []
const [token] = remaining.splice (index, 1)
return [token === name ? reference : { ...reference, name: token,
negative: token.toLowerCase ().startsWith ('not:') }]
})
for (const name of [...next.keys ()])
if (name === 'tag_refs' || name.startsWith ('tag_refs.')) next.delete (name)
if (references.length > 0)
setCountState (next, 'tag_refs', encodeCountState ('tag_refs', references))
}
if (key === 'limit') next.delete ('page')
if (next.toString () === previous.toString ()) return previous
if (['rows', 'cols'].includes (key) && next.get ('sort') !== '') next.delete ('sort')
if (key !== 'mode') return next
const resolved = normaliseCountsParams (resource, next, definition)
const axes = [...readCountState<unknown[]> (resolved, 'rows', []),
...readCountState<unknown[]> (resolved, 'cols', [])]
const allowed = value === 'summary'
? ['count', ...axes.map ((_, index) => `a${index}`)]
: fieldsForCounts (resource, definition).map (field => field.key)
if (readCountSort (resolved.get ('sort')).some (sort => !allowed.includes (sort.key)
&& !(value === 'summary' && /^m[0-9]{1,2}$/.test (sort.key))))
next.delete ('sort')
return normaliseCountsParams (resource, next, definition)
})
return <form className="shrink-0" onSubmit={event => {
event.preventDefault ()
if (pending) return
const next = new URLSearchParams (draft)
next.delete ('page')
execute (normaliseCountsParams (resource, next, definition))
}}>
{!history && <div className="flex flex-wrap items-center gap-2 border-b pb-1
dark:border-slate-700">
<div className="flex flex-wrap items-center gap-1 text-sm">
{label ('referenceAt')}<HelpPopover label={message ('referenceAt')}>
{label ('referenceHelp')}</HelpPopover>
<div className="flex min-w-0 max-w-full items-center gap-1">
<DateTimeField precision="second" disabled={countsUseLatest (draft)}
className="min-w-0 flex-1"
value={draft.get ('at') ?? ''}
aria-label={message ('referenceAt').text} lang={message ('referenceAt').language}
onChange={value => update ('at', value ?? '')}/>
<label className="inline-flex shrink-0 items-center gap-1">
<input type="checkbox" checked={countsUseLatest (draft)}
onChange={event => update ('current', event.target.checked ? '1' : '0')}/>
{label ('latest')}</label>
</div>
</div>
</div>}
<div className="flex flex-wrap items-center gap-2 border-b py-1 dark:border-slate-700">
<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={{
transform: open ? 'rotate(90deg)' : 'rotate(0deg)',
transition: `transform ${motion.duration ('collapse')}ms ${motion.easing}` }}>▸</span>
</Button>
<select className={inputClass (false, 'w-auto py-1')}
aria-label={message ('mode').text} lang={message ('mode').language}
value={mode} onChange={event => update ('mode', event.target.value)}>
{['summary', 'details'].map (value => <option key={value} value={value}>
{message (value).text}</option>)}
</select>
{actions}
</div>
<fieldset className="flex flex-wrap items-center gap-x-3 gap-y-1 border-b py-1
dark:border-slate-700">
<legend className="sr-only">{label ('displaySettings')}</legend>
<label className="inline-flex items-center gap-1 text-sm">
{label ('fields.limit')}
<select value={draft.get ('limit') ?? '100'}
aria-label={message ('fields.limit').text}
className={inputClass (false, 'w-20 py-1')}
onChange={event => update ('limit', event.target.value)}>
{[20, 50, 100].map (count => <option key={count} value={count}>
{message ('shared:labels.resultsCount', { count }).text}</option>)}
</select>
</label>
{details && <label className="inline-flex items-center gap-1 text-sm">
<input type="checkbox" checked={draft.get ('row_numbers') === '1'}
onChange={event => {
update ('row_numbers', event.target.checked ? '1' : '0')
displayRowNumbers (event.target.checked)
}}/>{label ('rowNumbers')}
</label>}
{details && resource === 'posts' && <label
className="inline-flex items-center gap-1 text-sm">
<input type="checkbox" checked={thumbnail}
onChange={event => displayThumbnail (event.target.checked)}/>
{label ('columns.thumbnailDisplay')}
</label>}
{nestedFlg && <label className="inline-flex items-center gap-1 text-sm">
<input type="checkbox" checked={draft.get ('nested_json') === '1'}
onChange={event => {
update ('nested_json', event.target.checked ? '1' : '0')
displayJSON (event.target.checked)
}}/>{label ('nested.asJSON')}
</label>}
</fieldset>
<AnimatedCollapse id={id} open={open}>
<div className="grid max-h-[40dvh] auto-rows-max gap-3 overflow-y-auto overscroll-contain
border-b py-2 [direction:ltr] dark:border-slate-700 md:max-h-none md:auto-rows-auto
md:grid-cols-2 md:overflow-visible">
<div className="min-h-0 min-w-0 md:max-h-[40dvh] md:overflow-y-auto
md:overscroll-contain md:[direction:rtl]">
<fieldset className="min-w-0" data-counts-output style={{ direction: 'ltr' }}>
{mode === 'summary' ? <CountsAxes params={draft} update={update}/>
: <>
<legend className="mb-1 font-semibold">{label ('output')}</legend>
<CountsFieldTree resource={resource} params={draft} update={(key, value) => {
update (key, value)
if (key === 'order') reorder (value)
}}/>
</>}
</fieldset>
</div>
<div className="min-h-0 min-w-0 border-t pt-2 dark:border-slate-700
md:max-h-[40dvh] md:overflow-y-auto md:overscroll-contain md:border-t-0 md:pt-0"
style={{ direction: 'ltr' }}>
<fieldset className="min-w-0 space-y-2" data-counts-filters>
<legend className="font-semibold">{label ('filters')}</legend>
{history && <div className="flex min-w-0 max-w-full items-center gap-1">
<DateTimeField precision="second" value={draft.get ('from') ?? ''}
aria-label={message ('from').text} lang={message ('from').language}
className="min-w-0 flex-1" onChange={value => update ('from', value ?? '')}/>
<span className="shrink-0">{label ('periodSeparator')}</span>
<DateTimeField precision="second" value={draft.get ('through') ?? ''}
aria-label={message ('through').text} lang={message ('through').language}
className="min-w-0 flex-1" onChange={value => update ('through', value ?? '')}/>
</div>}
<div className="flex flex-wrap gap-2">
{['resource_id', 'user_id'].map (key => <FormField key={key}
label={label (key === 'resource_id' ? 'columns.resourceId'
: 'fields.actor')}>
{() => <input value={draft.get (key) ?? ''}
aria-label={message (key === 'resource_id' ? 'columns.resourceId'
: 'fields.actor').text}
className={inputClass (false, 'w-28 py-1')}
onChange={event => update (key, event.target.value)}/>}
</FormField>)}
</div>
{resource === 'posts' && <FormField label={label ('columns.tags')}>
{() => <div className="flex flex-wrap items-center gap-2">
<TagInput includeNico includeUnused value={draft.get ('tags') ?? ''}
setValue={value => update ('tags', value)}/>
<select value={(draft.get ('match') === 'any'
|| draft.get ('tags')?.split (/\s+/).some (token =>
token.toLowerCase () === 'type:or')) ? 'any' : 'all'}
className={inputClass (false, 'h-10 w-auto')}
aria-label={message ('columns.tags').text}
onChange={event => {
const match = event.target.value
setDraft (previous => {
const next = new URLSearchParams (previous)
next.set ('match', match)
next.set ('tags', (next.get ('tags') ?? '').split (/\s+/)
.filter (token => token.toLowerCase () !== 'type:or').join (' '))
next.delete ('page')
return next
})
}}>
<option value="all">AND</option><option value="any">OR</option>
</select>
{draft.get ('tags') && <>
<Button type="button" variant="ghost" size="sm"
aria-label={message ('remove').text} onClick={() => setDraft (previous => {
const next = new URLSearchParams (previous)
for (const key of [...next.keys ()])
if (['tags', 'tag', 'kind', 'match', 'tag_refs', 'page'].includes (key)
|| key.startsWith ('tag_refs.')) next.delete (key)
return next
})}>×</Button>
</>}
</div>}
</FormField>}
<CountsAdditionalConditions params={draft} update={update}
conditionLabels={(labelParams != null
&& draft.get ('current') === labelParams.get ('current')
&& draft.get ('at') === labelParams.get ('at')
&& countsRequestParams (draft).toString ()
=== countsRequestParams (labelParams).toString ())
? conditionLabels : undefined}/>
</fieldset>
</div>
</div>
</AnimatedCollapse>
</form>
}
export default CountsControls