このコミットが含まれているのは:
2026-10-26 12:19:43 +09:00
コミット 3e8f209542
31個のファイルの変更、1676行の追加、394行の削除
+95 -177
ファイルの表示
@@ -1,14 +1,15 @@
import { useEffect, useId, useState } from 'react'
import CountsAdditionalConditions from '@/components/counts/CountsAdditionalConditions'
import CountsAxes from '@/components/counts/CountsAxes'
import CountsFieldTree from '@/components/counts/CountsFieldTree'
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 { isCountsState, normaliseCountsParams } from '@/lib/counts'
import { countsSummaryFields } from '@/lib/countsState'
import { fetchTagByName } from '@/lib/tags'
import { normaliseCountsParams } from '@/lib/counts'
import { inputClass } from '@/lib/utils'
import { useLocale } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
import type { ReactNode } from 'react'
@@ -18,17 +19,13 @@ import type { CountsResource } from '@/lib/counts'
const CountsControls = (
{ resource, applied, open, setOpen, execute, displayJSON, reorder, actions }: {
resource: CountsResource
applied: URLSearchParams
open: boolean
setOpen: (open: boolean) => void
execute: (params: URLSearchParams) => void
displayJSON: (enabled: boolean) => void
reorder: (value: string) => void
actions?: ReactNode },
resource: CountsResource; applied: URLSearchParams; open: boolean
setOpen: (open: boolean) => void
execute: (params: URLSearchParams) => void
displayJSON: (enabled: boolean) => void
reorder: (value: string) => void; actions?: ReactNode },
) => {
const { label, message } = useUITranslation ('counts')
const locale = useLocale ()
const id = useId ()
const [draft, setDraft] = useState (() => new URLSearchParams (applied))
const order = applied.get ('order')
@@ -38,175 +35,96 @@ const CountsControls = (
const next = new URLSearchParams (previous)
for (const [key, value] of Object.entries ({ order, nested_json: nestedJSON }))
{
if (value == null) next.delete (key)
else next.set (key, value)
if (value == null) next.delete (key)
else next.set (key, value)
}
return next
})
}, [order, nestedJSON])
const [error, setError] = useState (false)
const [pending, setPending] = useState (false)
const mode = draft.get ('mode') ?? 'summary'
const state = isCountsState (draft)
const axes = (state && resource === 'posts') ? ['tags', 'month', 'user'] : ['month', 'user']
const update = (key: string, value: string) => {
setDraft (previous => {
const next = new URLSearchParams (previous)
if (['mode', 'axis', 'basis'].includes (key)) next.delete ('sort')
if (key === 'axis' || key === 'basis') next.delete ('summary_columns')
if (key === 'basis' && value === 'history' && next.get ('axis') === 'tags')
next.set ('axis', 'month')
if (value || ['columns', 'children', 'summary_columns'].includes (key))
next.set (key, value)
else next.delete (key)
return next
})
}
const submit = async () => {
setError (false)
setPending (true)
try
{
const next = new URLSearchParams (draft)
for (const key of resource === 'posts' ? ['tag'] : ['resource_id'])
{
const value = next.get (key)?.trim () ?? ''
if (value && !/^[1-9][0-9]*$/.test (value))
{
if (state && next.get ('kind') === 'external') throw new Error ()
const tag = await fetchTagByName (value, locale)
if (tag == null)
throw new Error ()
next.set (key, String (tag.id))
}
}
next.delete ('page')
execute (normaliseCountsParams (resource, next))
}
catch
{
setError (true)
}
finally
{
setPending (false)
}
}
return (
<form className="shrink-0" onSubmit={event => {
event.preventDefault ()
void submit ()
}}>
<div className="flex flex-wrap items-center gap-2 border-b pb-2 dark:border-slate-700">
<Button type="submit" size="sm" disabled={pending}>{label ('run')}</Button>
<Button type="button" size="sm" variant="outline" aria-expanded={open}
aria-controls={id} onClick={() => setOpen (!open)}>
{label ('conditions')}<span aria-hidden>{open ? '▴' : '▾'}</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} lang={message (value).language}>
{message (value).text}</option>))}
</select>
{actions}
</div>
{error && <p role="alert">{label ('shared:messages.checkInputModern')}</p>}
<div id={id} hidden={!open}
className="max-h-[40dvh] overflow-y-auto overscroll-contain border-b py-2
dark:border-slate-700 md:max-h-[45dvh]">
<div className="grid gap-4 md:grid-cols-2">
<fieldset className="min-w-0" data-counts-output>
<legend className="mb-2 font-semibold">{label ('output')}</legend>
<label className="mb-2 flex items-center gap-1">
<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>
<FormField label={label ('measure')}>
{() => <select value={state ? 'state' : 'history'}
className={inputClass (false, 'w-auto py-1')}
aria-label={message ('measure').text} lang={message ('measure').language}
onChange={event => update ('basis', event.target.value)}>
<option value="state" lang={message (`state.totals.${resource}`).language}>
{message (`state.totals.${resource}`).text}</option>
<option value="history" lang={message ('metric').language}>
{message ('metric').text}</option>
</select>}
</FormField>
{mode === 'summary' && <label className="my-2 flex flex-wrap items-center gap-2">
{label ('axis')}
<select className={inputClass (false, 'w-auto py-1')}
value={draft.get ('axis')
?? ((state && resource === 'posts') ? 'tags' : 'month')}
aria-label={message ('axis').text} lang={message ('axis').language}
onChange={event => update ('axis', event.target.value)}>
{axes.map (axis => <option key={axis} value={axis}
lang={message (`axes.${axis}`).language}>
{message (`axes.${axis}`).text}</option>)}
</select>
</label>}
<CountsFieldTree resource={resource} params={draft}
fields={mode === 'summary' ? countsSummaryFields (resource, draft) : undefined}
selectionKey={mode === 'summary' ? 'summary_columns' : 'columns'}
parent={mode === 'summary' ? 'summary' : ''} update={(key, value) => {
update (key, value)
if (key === 'order') reorder (value)
}}/>
</fieldset>
<fieldset className="min-w-0 space-y-2" data-counts-filters>
<legend className="mb-2 font-semibold">{label ('filters')}</legend>
<FormField label={label ('state.at')}>
{() => <DateTimeField value={draft.get ('at') ?? ''}
aria-label={message ('state.at').text} lang={message ('state.at').language}
onChange={value => update ('at', value ?? '')}/>}
</FormField>
{!state && <label className="flex items-center gap-1">
<input type="checkbox" checked={draft.get ('latest') !== '0'}
onChange={event => update ('latest', event.target.checked ? '1' : '0')}/>
{label ('latestOnly')}
</label>}
{state && resource === 'posts' && <FormField label={label ('columns.kind')}>
{() => <select value={draft.get ('kind') ?? 'internal'}
className={inputClass (false, 'w-auto py-1')}
aria-label={message ('columns.kind').text} lang={message ('columns.kind').language}
onChange={event => update ('kind', event.target.value)}>
{['internal', 'external'].map (kind => <option key={kind} value={kind}
lang={message (`kinds.${kind}`).language}>
{message (`kinds.${kind}`).text}</option>)}
</select>}
</FormField>}
<div className="grid gap-2 md:grid-cols-2">
{['from', 'before'].map (key => (
<FormField key={key} label={label (`fields.${key}`)}>
{() => <DateTimeField value={draft.get (key) ?? ''}
aria-label={message (`fields.${key}`).text}
lang={message (`fields.${key}`).language}
onChange={value => update (key, value ?? '')}/>}
</FormField>))}
</div>
<div className="grid gap-2 md:grid-cols-2">
{[['resource_id', resource === 'posts' ? 'post' : 'tag'], ['user_id', 'actor'],
...(resource === 'posts' ? [['tag', 'tag']] : []), ['limit', 'limit']]
.map (([key, field]) => (
<FormField key={key} label={label (`fields.${field}`)}>
{() => <input aria-label={message (`fields.${field}`).text}
lang={message (`fields.${field}`).language}
type={key === 'limit' ? 'number' : 'text'} min={1} max={100}
value={draft.get (key) ?? (key === 'limit' ? '20' : '')}
onChange={event => update (key, event.target.value)}
className={inputClass (false, 'py-1')}/>}
</FormField>))}
</div>
</fieldset>
</div>
</div>
</form>)
const history = draft.get ('definition') === 'history'
const update = (key: string, value: string) => setDraft (previous => {
const next = new URLSearchParams (previous)
if (['mode', 'rows', 'cols'].includes (key)) next.delete ('sort')
next.set (key, value)
return next
})
return <form className="shrink-0" onSubmit={event => {
event.preventDefault ()
const next = new URLSearchParams (draft)
next.delete ('page')
next.delete ('execution_at')
execute (normaliseCountsParams (resource, next))
}}>
<div className="flex flex-wrap items-center gap-2 border-b pb-1 dark:border-slate-700">
{history ? <>
{['from', 'through'].map (key => <label key={key}
className="flex flex-wrap items-center gap-1 text-sm">
{label (key)}<DateTimeField precision="second" value={draft.get (key) ?? ''}
aria-label={message (key).text} onChange={value => update (key, value ?? '')}/>
</label>)}
</> : <label className="flex flex-wrap items-center gap-1 text-sm">
{label ('referenceAt')}<HelpPopover label={message ('referenceAt')}>
{label ('referenceHelp')}</HelpPopover>
<DateTimeField precision="second" value={draft.get ('at') ?? ''}
aria-label={message ('referenceAt').text}
onChange={value => update ('at', value ?? '')}/>
</label>}
</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="button" size="sm" variant="outline" aria-expanded={open}
aria-controls={id} onClick={() => setOpen (!open)}>
{label ('conditions')}<span aria-hidden>{open ? '▴' : '▾'}</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>
<div id={id} hidden={!open} className="max-h-[35dvh] overflow-y-auto overscroll-contain
border-b py-2 dark:border-slate-700 md:max-h-[40dvh]">
<div className="grid gap-3 md:grid-cols-2">
<fieldset className="min-w-0" data-counts-output>
{mode === 'summary' ? <CountsAxes resource={resource} params={draft} update={update}/>
: <>
<legend className="mb-1 font-semibold">{label ('output')}</legend>
<label className="mb-1 flex items-center gap-1">
<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>
<CountsFieldTree resource={resource} params={draft} update={(key, value) => {
update (key, value)
if (key === 'order') reorder (value)
}}/>
</>}
</fieldset>
<fieldset className="min-w-0 space-y-2" data-counts-filters>
<legend className="font-semibold">{label ('filters')}</legend>
<div className="flex flex-wrap gap-2">
{['resource_id', 'user_id', 'limit'].map (key => <FormField key={key}
label={label (key === 'resource_id' ? 'columns.resourceId'
: (key === 'user_id' ? 'fields.actor' : 'fields.limit'))}>
{() => <input value={draft.get (key) ?? (key === 'limit' ? '20' : '')}
aria-label={message (key === 'resource_id' ? 'columns.resourceId'
: (key === 'user_id' ? 'fields.actor' : 'fields.limit')).text}
className={inputClass (false, 'w-28 py-1')}
onChange={event => update (key, event.target.value)}/>}
</FormField>)}
</div>
{resource === 'posts' && <FormField label={label ('columns.tags')}>
{() => <TagInput includeNico includeUnused value={draft.get ('tags') ?? ''}
setValue={value => update ('tags', value)}/>}
</FormField>}
<CountsAdditionalConditions resource={resource} params={draft} update={update}/>
</fieldset>
</div>
</div>
</form>
}