このコミットが含まれているのは:
2026-10-28 00:28:29 +09:00
コミット d6cf992622
27個のファイルの変更、630行の追加、60行の削除
+8
ファイルの表示
@@ -7,6 +7,7 @@ import { localiseTo } from '@/lib/locales'
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
import { useOverlayStore } from '@/components/RouteBlockerOverlay'
import { prefetchForURL } from '@/lib/prefetchers'
import { isCountsPath } from '@/lib/routeLocales'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { cn } from '@/lib/utils'
import { currentRenderContext } from '@/lib/renderContext'
@@ -53,6 +54,7 @@ export default forwardRef<HTMLAnchorElement, Props> (({
const setOverlay = useOverlayStore (s => s.setActive)
const doPrefetch = async () => {
if (isCountsPath (new URL (url).pathname)) return true
try
{
await prefetchForURL (qc, url)
@@ -94,6 +96,12 @@ export default forwardRef<HTMLAnchorElement, Props> (({
ev.preventDefault ()
if (isCountsPath (new URL (url).pathname))
{
navigate (path, { replace, ...(state && { state }) })
return
}
flushSync (() => {
setOverlay (true)
})
+43 -10
ファイルの表示
@@ -1,9 +1,10 @@
import { useEffect, useState } from 'react'
import CountsConditionValues from '@/components/counts/CountsConditionValues'
import DateTimeField from '@/components/common/DateTimeField'
import { Button } from '@/components/ui/button'
import { countsEventTypes, countsFieldLabel, countsFilterFields,
useCountsDefinition }
import { countsConditionInputs, countsEventTypes, countsFieldLabel, countsFilterFields,
countsSetOperators, useCountsDefinition }
from '@/lib/countsDefinitions'
import useDialogue from '@/lib/dialogues/useDialogue'
import { inputClass } from '@/lib/utils'
@@ -33,12 +34,14 @@ const ConditionEditor = (
})
const { field, operator } = condition
const operators = fields.find (item => item.key === field)?.operators ?? []
const inputs = countsConditionInputs (fields.find (item => item.key === field), operator)
useEffect (() => {
controls.setActions ([{ label: label (initial == null ? 'add' : 'shared:actions.edit'),
onSelect: () => save (condition) }])
}, [controls, condition, initial, label, save])
return <div className="space-y-2">
<select value={field} aria-label={message ('field').text} className={inputClass ()}
<select value={field} aria-label={message ('field').text}
className={inputClass (false, 'h-9 w-full')}
onChange={event => {
const field = event.target.value
const operator = fields.find (item => item.key === field)?.operators[0]
@@ -47,17 +50,39 @@ const ConditionEditor = (
{fields.filter (field => field.operators.length > 0).map (field => (
<option key={field.key} value={field.key}>{fieldName (field.key)}</option>))}
</select>
<select value={operator} aria-label={message ('operator').text} className={inputClass ()}
<select value={operator} aria-label={message ('operator').text}
className={inputClass (false, 'h-9 w-full')}
onChange={event => setCondition (previous => {
const operator = event.target.value
const next = { ...previous, operator }
if (operator !== 'range') delete next.end
if ((operator === 'like') !== (previous.operator === 'like')) delete next.value
const choices = countsConditionInputs (fields.find (item => item.key === field), operator)
if (countsSetOperators.includes (operator))
{
delete next.value
delete next.end
next.input = choices.includes (previous.input ?? 'single') ? previous.input : choices[0]
}
else
{
delete next.values
delete next.input
}
return next
})}>
{operators.map (key => <option key={key} value={key}>
{message (`operators.${key}`).text}</option>)}
</select>
<label className="block text-sm">{label ('pattern.input')}
<select value={condition.input ?? inputs[0]}
className={inputClass (false, 'h-9 w-full')}
onChange={event => setCondition (previous => ({ ...previous,
input: event.target.value as CountsCondition['input'] }))}>
{inputs.map (input => <option key={input} value={input}>
{message (`pattern.${input}`).text}</option>)}
</select>
</label>
</div>
}
@@ -99,7 +124,7 @@ const CountsAdditionalConditions = ({ params, update }: {
const options: string[] = type === 'boolean' ? ['true', 'false'] : [...countsEventTypes]
const namespace = type === 'boolean' ? 'truthValues' : 'events'
return <select value={condition[key] ?? ''} disabled={condition.blank}
className={inputClass (false, 'h-7 w-36 min-w-0 max-w-full py-1')}
className={inputClass (false, 'h-9 w-36 min-w-0 max-w-full py-1')}
aria-label={aria.text}
lang={aria.language}
onChange={event => patch (index, { [key]: event.target.value })}>
@@ -110,13 +135,13 @@ const CountsAdditionalConditions = ({ params, update }: {
</select>
}
if (type === 'datetime')
return <DateTimeField precision="second" className="h-7 w-48 min-w-0 max-w-full py-1"
return <DateTimeField precision="second" className="h-9 w-48 min-w-0 max-w-full py-1"
value={condition[key] ?? ''} disabled={condition.blank}
aria-label={aria.text} lang={aria.language}
onChange={value => patch (index, { [key]: value ?? '' })}/>
return <input value={condition[key] ?? ''} disabled={condition.blank}
type={(type === 'integer' || type === 'interval') ? 'number' : 'text'}
className={inputClass (false, 'h-7 w-36 min-w-0 max-w-full py-1')}
className={inputClass (false, 'h-9 w-36 min-w-0 max-w-full py-1')}
aria-label={aria.text} lang={aria.language}
onChange={event => patch (index, { [key]: event.target.value })}/>
}
@@ -130,24 +155,32 @@ const CountsAdditionalConditions = ({ params, update }: {
<Button type="button" variant="ghost" size="sm" onClick={() => edit (index)}
aria-label={`${fieldName (condition.field)} ${message (
`operators.${condition.operator}`).text}`}
className="h-7 min-w-0 max-w-full justify-start gap-2 whitespace-normal px-1 text-left">
className="min-w-0 max-w-full justify-start gap-2 whitespace-normal px-1 text-left">
<span className="min-w-0 break-words">{fieldName (condition.field)}</span>
{condition.operator !== 'range' && <span aria-hidden="true" className="shrink-0">
{label (`operatorSymbols.${condition.operator}`)}</span>}
</Button>
{countsSetOperators.includes (condition.operator) ? (
<CountsConditionValues key={`${condition.field}:${condition.operator}`}
condition={condition}
field={metadata.find (field => field.key === condition.field)!}
definition={definition} params={params}
maximum={Math.max (0, 100 - conditions.reduce ((sum, item, position) =>
sum + (position === index ? 0 : item.values?.length ?? 0), 0))}
change={values => patch (index, { values })}/>) : (
<div className="inline-flex min-w-0 max-w-full items-center gap-1">
{valueInput (condition, index, 'value')}
{condition.operator === 'range' && <>
<span aria-hidden="true" className="shrink-0">{label ('operatorSymbols.range')}</span>
{valueInput (condition, index, 'end')}
</>}
</div>
</div>)}
<div className="inline-flex shrink-0 items-center gap-1">
<label className="inline-flex items-center gap-1 text-sm">
<input type="checkbox" checked={condition.blank ?? false}
onChange={event => patch (index, { blank: event.target.checked })}/>
{label ('blank')}</label>
<Button type="button" variant="ghost" size="sm" className="h-7 px-1"
<Button type="button" variant="ghost" size="sm" className="px-1"
aria-label={message ('remove').text}
onClick={() => write (conditions.filter ((_, position) => index !== position))}>
×</Button>
+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
+84
ファイルの表示
@@ -0,0 +1,84 @@
import { useState } from 'react'
import CountsCandidatePicker from '@/components/counts/CountsCandidatePicker'
import DateTimeField from '@/components/common/DateTimeField'
import { Button } from '@/components/ui/button'
import useDialogue from '@/lib/dialogues/useDialogue'
import { inputClass } from '@/lib/utils'
import { useUITranslation } from '@/lib/useUITranslation'
import type { CountsCondition, CountsDefinition, CountsFieldMetadata }
from '@/lib/countsDefinitions'
const CountsConditionValues = (
{ condition, field, definition, params, maximum, change }: {
condition: CountsCondition; field: CountsFieldMetadata; definition: CountsDefinition
params: URLSearchParams; maximum: number
change: (values: (string | number | boolean)[]) => void },
) => {
const { label, message } = useUITranslation ('counts')
const dialogue = useDialogue ()
const [draft, setDraft] = useState ('')
const values = condition.values ?? []
const pick = () => {
void dialogue.form ({ title: label (field.candidateSource === 'tags'
? 'pattern.link' : 'pattern.pattern'), size: 'large', footerLayout: 'inline',
body: controls => <CountsCandidatePicker field={field} definition={definition}
params={params} initial={values} maximum={maximum} controls={controls} save={change}/> })
}
if (condition.input === 'list' || field.candidateSource === 'tags')
return <Button type="button" variant="outline" disabled={condition.blank} onClick={pick}>
{label ('pattern.list')}{' ('}{values.length}{')'}</Button>
const append = () => {
if (draft === '' || values.length >= maximum
|| values.some (value => String (value) === draft))
return
change ([...values, draft])
setDraft ('')
}
const input = () => {
if (field.type === 'datetime')
return <DateTimeField precision="second" value={draft} disabled={condition.blank}
className="h-9 w-48 min-w-0 max-w-full" aria-label={message ('value').text}
onChange={value => setDraft (value ?? '')}/>
if (field.type === 'boolean' || field.choices != null)
{
const choices = field.choices ?? ['true', 'false'].map (value => ({
value, label: `counts:truthValues.${value}` }))
return <select value={draft} disabled={condition.blank}
className={inputClass (false, 'h-9 w-36 min-w-0 max-w-full')}
aria-label={message ('value').text} onChange={event => setDraft (event.target.value)}>
<option value=""/>
{choices.map (choice => <option key={choice.value} value={choice.value}>
{message (choice.label).text}</option>)}
</select>
}
return <input value={draft} disabled={condition.blank}
type={field.type === 'integer' ? 'number' : 'text'} aria-label={message ('value').text}
className={inputClass (false, 'h-9 w-36 min-w-0 max-w-full')}
onChange={event => setDraft (event.target.value)}/>
}
const display = (value: string | number | boolean) => {
const choice = field.choices?.find (choice => choice.value === String (value))
if (choice != null) return message (choice.label).text
if (field.type === 'boolean') return message (`truthValues.${value}`).text
return String (value)
}
return <div className="flex min-w-0 max-w-full flex-wrap items-center gap-1">
{values.map ((value, index) => <span key={`${index}:${value}`}
className="inline-flex items-center gap-1">
{display (value)}<Button type="button" size="sm" variant="ghost"
aria-label={message ('remove').text} disabled={condition.blank}
onClick={() => change (values.filter ((_, position) => position !== index))}>×</Button>
</span>)}
{input ()}
<Button type="button" variant="outline" disabled={condition.blank || draft === ''
|| values.length >= maximum} onClick={append}>{label ('add')}</Button>
<Button type="button" variant="ghost" disabled={condition.blank}
onClick={() => change ([])}>{label ('shared:actions.reset')}</Button>
</div>
}
export default CountsConditionValues
+15 -6
ファイルの表示
@@ -41,8 +41,8 @@ const CountsSummaryTable = (
const display = (value: string | number | null, axis: CountsAxis, index: number) =>
countsSummaryValue (value, axis, index, data, locale)
const aggregateLabel = (header: CountsPivotHeader) => message (
header.kind === 'grand_total' ? (rows.length + cols.length === 1
? 'singleTotal' : 'grandTotal') : 'subtotal').text
header.labelKey ?? (header.kind === 'grand_total' ? (rows.length + cols.length === 1
? 'singleTotal' : 'grandTotal') : 'subtotal')).text
const leading = Math.max (rows.length, cols.length > 0 ? 1 : 0)
const levels = Math.max (1, cols.length)
const headingLevels = cols.length === 0 ? 1 : levels * 2
@@ -77,6 +77,8 @@ const CountsSummaryTable = (
const full = new Map (cells.map (cell => [JSON.stringify (cell.values), cell.count]))
const totals = new Map ((data.pivotTotals ?? []).map (cell => [
JSON.stringify ([cell.row, cell.column]), cell.count]))
const totalLabels = new Map ((data.pivotTotals ?? []).map (cell => [
JSON.stringify ([cell.row, cell.column]), cell.labelKey]))
const count = (row: CountsPivotHeader, column: CountsPivotHeader) =>
(row.values.length === rows.length && column.values.length === cols.length)
? full.get (JSON.stringify ([...row.values, ...column.values])) ?? 0
@@ -101,16 +103,16 @@ const CountsSummaryTable = (
...Array.from ({ length: leading }, (_, index) => labels[index] ?? '')]
})
const spans = rowHeaders.map ((row, index) => rows.map ((_, axis) => {
if (row.kind !== 'data') return 1
if (row.values.length <= axis) return 1
const prefix = JSON.stringify (row.values.slice (0, axis + 1))
const previous = rowHeaders[index - 1]
if (previous?.kind === 'data'
if (previous != null && previous.values.length > axis
&& JSON.stringify (previous.values.slice (0, axis + 1)) === prefix) return 0
let span = 1
while (index + span < rowHeaders.length)
{
const next = rowHeaders[index + span]
if (next.kind !== 'data'
if (next.values.length <= axis
|| JSON.stringify (next.values.slice (0, axis + 1)) !== prefix) break
++span
}
@@ -246,8 +248,11 @@ const CountsSummaryTable = (
<tbody>{rowHeaders.map ((row, index) => <tr key={columnKey (row)}>
{rowLabels[index].map ((value, offset) => {
const axis = offset - (numbered ? 1 : 0)
if (axis >= 0 && row.kind !== 'data' && axis > row.values.length) return null
const aggregate = axis >= 0 && row.kind !== 'data' && axis === row.values.length
const span = axis < 0 ? 1 : spans[index][axis] ?? 1
return span === 0 ? null : <td key={offset} rowSpan={span}
colSpan={aggregate ? leading - axis : 1}
role={axis >= 0 ? 'rowheader' : undefined} data-counts-copy={value}
data-counts-aggregate={row.kind !== 'data' ? '' : undefined}
data-counts-total={row.kind !== 'data' ? row.kind : undefined}
@@ -264,7 +269,11 @@ const CountsSummaryTable = (
data-counts-aggregate={(row.kind !== 'data' || column.kind !== 'data')
? '' : undefined}
className="border px-2 py-1 dark:border-slate-700">
<PrefetchLink to={drill (row, column)}>{count (row, column)}</PrefetchLink>
<PrefetchLink to={drill (row, column)} aria-label={
totalLabels.get (JSON.stringify ([row.values, column.values])) == null ? undefined
: `${message (totalLabels.get (
JSON.stringify ([row.values, column.values]))!).text}: ${count (row, column)}`}>
{count (row, column)}</PrefetchLink>
</td>)}
</tr>)}</tbody>
</CountsSelectableTable>