155 行
7.0 KiB
TypeScript
155 行
7.0 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 { CountsResource } from '@/lib/counts'
|
||
import type { CountsCandidate } from '@/lib/countsDefinitions'
|
||
|
||
|
||
type CandidateSource = {
|
||
resource: CountsResource; field: string; params?: URLSearchParams }
|
||
|
||
const CandidatePicker = (
|
||
{ source, choices = [], categories = [], initial, maximum, controls, onSelect }: {
|
||
source?: CandidateSource; choices?: CountsCandidate[]
|
||
categories?: { value: string; label: string }[]
|
||
initial: (string | number | boolean)[]; maximum: number
|
||
controls: DialogueFormControls; onSelect: (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 params = source?.params ?? new URLSearchParams ()
|
||
const point = useRef (params.get ('execution_at'))
|
||
const resource = source?.resource ?? 'posts'
|
||
const request = new URLSearchParams ({
|
||
field: source?.field ?? '', 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 = source != null
|
||
const { data, isFetching: fetching, isError: errorFlg } = useQuery ({
|
||
queryKey: countsKeys.candidates (resource, request.toString (), locale), enabled: dynamic,
|
||
queryFn: ({ signal }) => {
|
||
const pinned = new URLSearchParams (request)
|
||
if (point.current != null) pinned.set ('execution_at', point.current)
|
||
return fetchCountsCandidates (resource, pinned, locale, signal)
|
||
}, retry: false, staleTime: 0, gcTime: 0 })
|
||
useEffect (() => {
|
||
if (data != null) point.current = data.executionAt
|
||
}, [data])
|
||
useEffect (() => {
|
||
controls.setActions ([{ label: label ('counts:pattern.selection'),
|
||
onSelect: () => onSelect ([...selected]) }])
|
||
}, [controls, label, onSelect, selected])
|
||
const rows = dynamic ? data?.rows ?? [] : choices.filter (row =>
|
||
row.name?.includes (search))
|
||
const headings: Record<string, string> = {
|
||
id: 'columns.resourceId', name: 'columns.name', category: 'common:categoryLabel',
|
||
kind: 'columns.kind' }
|
||
const referenceFlg = ['tags', 'tags_json', 'tags.id', 'members'].includes (source?.field ?? '')
|
||
const available =
|
||
dynamic
|
||
? (referenceFlg ? ['id', 'name', 'category', 'kind'] : ['id', 'name']) : ['name']
|
||
const visible = available.filter (column => columns.includes (column))
|
||
const cell = (row: CountsCandidate, column: string) => {
|
||
if (column === 'category')
|
||
return row.category == null ? '' : message (`common:categories.${row.category}`).text
|
||
if (column === 'kind')
|
||
{
|
||
const key = row.kind === 'group' ? 'resources.tag_groups' : `kinds.${row.kind}`
|
||
return row.kind == null ? '' : message (key).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} maxLength={1000}
|
||
onChange={event => {
|
||
const value = event.target.value
|
||
if (new TextEncoder ().encode (value).length <= 1000) setDraft (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>
|
||
{categories.length > 0 && <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.kind ?? ''}:${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={maximum !== 1 && !selected.has (row.value) && selected.size >= maximum}
|
||
onChange={event => setSelected (previous => {
|
||
const next = new Set (maximum === 1 ? [] : 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 || page >= 100_000 || fetching}
|
||
onClick={() => setPage (previous => previous + 1)}>
|
||
{label ('common:pagination.next')}</Button>
|
||
</div>}
|
||
</div>
|
||
}
|
||
|
||
|
||
export default CandidatePicker
|
||
export type { CandidateSource }
|