140 行
6.5 KiB
TypeScript
140 行
6.5 KiB
TypeScript
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 ('counts: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 ('counts: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 ('counts: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 ('counts: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
|