304 行
15 KiB
TypeScript
304 行
15 KiB
TypeScript
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
|