このコミットが含まれているのは:
2026-10-28 00:28:29 +09:00
コミット d6cf992622
27個のファイルの変更、630行の追加、60行の削除
+139
ファイルの表示
@@ -0,0 +1,139 @@
import { useQuery } from '@tanstack/react-query'
import { useEffect, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { countsUseLatest } from '@/lib/counts'
import { fetchCountsCandidates } from '@/lib/countsDefinitions'
import { countsKeys } from '@/lib/queryKeys'
import { useLocale } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
import { inputClass } from '@/lib/utils'
import type { DialogueFormControls } from '@/lib/dialogues/useDialogue'
import type { CountsCandidate, CountsDefinition, CountsFieldMetadata }
from '@/lib/countsDefinitions'
const CountsCandidatePicker = (
{ field, definition, params, initial, maximum, controls, save }: {
field: CountsFieldMetadata; params: URLSearchParams
definition: CountsDefinition
initial: (string | number | boolean)[]; maximum: number
controls: DialogueFormControls; save: (values: string[]) => void },
) => {
const { label, message } = useUITranslation ('counts')
const locale = useLocale ()
const [selected, setSelected] = useState (() => new Set (initial.map (String)))
const [columns, setColumns] = useState (['id', 'name', 'category', 'kind'])
const [draft, setDraft] = useState ('')
const [search, setSearch] = useState ('')
const [category, setCategory] = useState ('')
const [page, setPage] = useState (1)
const point = useRef (params.get ('execution_at'))
const request = new URLSearchParams ({ field: field.key, query: search, page: String (page) })
for (const key of ['timezone', 'at', 'execution_at'])
if (params.has (key)) request.set (key, params.get (key)!)
if (countsUseLatest (params)) request.delete ('at')
if (category) request.set ('category', category)
const dynamic = field.candidateSource === 'tags'
const { data, isFetching: fetching, isError: errorFlg } = useQuery ({
queryKey: countsKeys.candidates (request.toString (), locale), enabled: dynamic,
queryFn: ({ signal }) => {
const pinned = new URLSearchParams (request)
if (point.current != null) pinned.set ('execution_at', point.current)
return fetchCountsCandidates (pinned, locale, signal)
}, retry: false })
useEffect (() => {
if (data != null) point.current = data.executionAt
}, [data])
useEffect (() => {
controls.setActions ([{ label: label ('pattern.selection'),
onSelect: () => save ([...selected]) }])
}, [controls, label, save, selected])
const fixed: CountsCandidate[] = (field.choices ?? []).map (choice => ({
value: choice.value, name: message (choice.label).text }))
const rows = dynamic ? data?.rows ?? [] : fixed.filter (row =>
row.name?.includes (search))
const headings: Record<string, string> = {
id: 'columns.resourceId', name: 'columns.name', category: 'common:categoryLabel',
kind: 'columns.kind' }
const available = dynamic ? ['id', 'name', 'category', 'kind'] : ['name']
const visible = available.filter (column => columns.includes (column))
const categories = definition.filterFields?.find (field =>
field.candidateSource === 'categories')?.choices ?? []
const cell = (row: CountsCandidate, column: string) => {
if (column === 'category') return row.category == null ? ''
: message (`common:categories.${row.category}`).text
if (column === 'kind') return row.kind == null ? '' : message (`kinds.${row.kind}`).text
return column === 'id' ? row.id : row.name
}
return <div className="min-w-0 space-y-2">
<div className="flex min-w-0 items-center gap-2">
<input className={inputClass (false, 'h-9 min-w-0 flex-1')}
aria-label={message ('common:search').text} value={draft}
onChange={event => setDraft (event.target.value)}
onKeyDown={event => {
if (event.key !== 'Enter') return
event.preventDefault ()
setSearch (draft)
setPage (1)
}}/>
<Button type="button" onClick={() => { setSearch (draft); setPage (1) }}>
{label ('common:search')}</Button>
</div>
{dynamic && <select className={inputClass (false, 'h-9 w-full')}
aria-label={message ('common:categoryLabel').text} value={category}
onChange={event => { setCategory (event.target.value); setPage (1) }}>
<option value=""/>
{categories.map (choice => <option key={choice.value} value={choice.value}>
{message (choice.label).text}</option>)}
</select>}
<fieldset className="flex flex-wrap items-center gap-2 text-sm">
<legend>{label ('pattern.columns')}</legend>
{available.map (column => <label key={column} className="inline-flex items-center gap-1">
<input type="checkbox" checked={columns.includes (column)} onChange={event =>
setColumns (previous => event.target.checked ? [...previous, column]
: previous.filter (value => value !== column))}/>{label (headings[column])}
</label>)}
</fieldset>
<p className="text-sm">{label ('pattern.value')}{':'}
{label (dynamic ? 'columns.resourceId' : 'common:categoryLabel')}
</p>
<Button type="button" variant="ghost" size="sm" onClick={() => setSelected (new Set ())}>
{label ('shared:actions.reset')}</Button>
{errorFlg && <p role="alert">{label ('common:fetchError')}</p>}
<div className="max-h-[40dvh] overflow-auto" aria-busy={fetching}>
<table className="w-full text-left text-sm">
<thead><tr><th className="p-2">{label ('pattern.selection')}</th>
{visible.map (column => <th key={column} className="p-2">
{label (headings[column])}</th>)}
</tr></thead>
<tbody>{rows.map (row => <tr key={row.value} className="border-t dark:border-slate-700">
<td className="p-2"><input type="checkbox" checked={selected.has (row.value)}
aria-label={row.name ?? String (row.id ?? '')}
disabled={!selected.has (row.value) && selected.size >= maximum}
onChange={event => setSelected (previous => {
const next = new Set (previous)
if (event.target.checked) next.add (row.value)
else next.delete (row.value)
return next
})}/></td>
{visible.map (column => <td key={column} className="break-words p-2">
{cell (row, column)}</td>)}
</tr>)}</tbody>
</table>
</div>
{dynamic && <div className="flex items-center justify-between gap-2">
<Button type="button" variant="outline" disabled={page === 1 || fetching}
onClick={() => setPage (previous => previous - 1)}>
{label ('common:pagination.previous')}</Button>
<span>{page}</span>
<Button type="button" variant="outline" disabled={!data?.more || fetching}
onClick={() => setPage (previous => previous + 1)}>
{label ('common:pagination.next')}</Button>
</div>}
</div>
}
export default CountsCandidatePicker