このコミットが含まれているのは:
@@ -18,6 +18,13 @@
|
||||
- 秘匿は React / CSS では実装しない。認可済み API のみを使う。
|
||||
認証・権限変更時は取得中リクエストと履歴キャッシュを破棄し、SSR の管理者データを共有しない。
|
||||
|
||||
## Mandatory Counts data contract
|
||||
|
||||
- Counts のロカール非依存・内表行集合保持はルート AGENTS.md の必須契約に従う。
|
||||
内表行の表示フィルターを作らない。言語名列はサーバーの Locale 定義を使い、
|
||||
未登録列に別言語名を代入しない。列を隠しても取得済みの全言語データは保持する。
|
||||
ソート・コピー・JSON の投影には安定したキーと値を使い、UI 訳語を識別に使わない。
|
||||
|
||||
## Mandatory naming contract
|
||||
|
||||
- ルート `AGENTS.md` の必須命名規約を適用する。非 callable の自前識別子に
|
||||
|
||||
@@ -14,10 +14,8 @@ const SortableColumns = ({ ids, fixed = [], name, onChange, children }: {
|
||||
ids: string[]; fixed?: string[]; name: (id: string) => { text: string; language: string }
|
||||
onChange: (ids: string[]) => void; children: (ids: string[]) => ReactNode },
|
||||
) => {
|
||||
const [preview, setPreview] = useState<string[] | null> (null)
|
||||
const [active, setActive] = useState<string | null> (null)
|
||||
const latest = useRef<string[] | null> (null)
|
||||
const lastOver = useRef<string | null> (null)
|
||||
const drag = useRef<{ id: string; ids: string[] } | null> (null)
|
||||
const motion = useClientMotion ()
|
||||
const sensors = useSensors (
|
||||
useSensor (MouseSensor, { activationConstraint: { distance: 4 } }),
|
||||
@@ -27,12 +25,11 @@ const SortableColumns = ({ ids, fixed = [], name, onChange, children }: {
|
||||
if (fixed.includes (from) || fixed.includes (to)) return current
|
||||
const source = current.indexOf (from)
|
||||
const target = current.indexOf (to)
|
||||
return source < 0 || target < 0 ? current : arrayMove (current, source, target)
|
||||
return source < 0 || target < 0 || source === target
|
||||
? current : arrayMove (current, source, target)
|
||||
}
|
||||
const reset = () => {
|
||||
latest.current = null
|
||||
lastOver.current = null
|
||||
setPreview (null)
|
||||
drag.current = null
|
||||
setActive (null)
|
||||
}
|
||||
return <DndContext sensors={sensors} collisionDetection={closestCenter}
|
||||
@@ -43,28 +40,20 @@ const SortableColumns = ({ ids, fixed = [], name, onChange, children }: {
|
||||
onDragCancel: () => undefined },
|
||||
screenReaderInstructions: { draggable: active == null ? '' : name (active).text } }}
|
||||
onDragStart={event => {
|
||||
latest.current = ids
|
||||
setPreview (ids)
|
||||
setActive (String (event.active.id))
|
||||
}} onDragOver={event => {
|
||||
if (event.over == null || event.active.id === event.over.id) return
|
||||
const target = String (event.over.id)
|
||||
if (target === lastOver.current) return
|
||||
latest.current = move (latest.current ?? ids, String (event.active.id), target)
|
||||
lastOver.current = target
|
||||
setPreview (latest.current)
|
||||
const id = String (event.active.id)
|
||||
drag.current = { id, ids }
|
||||
setActive (id)
|
||||
}} onDragCancel={reset} onDragEnd={event => {
|
||||
if (event.over != null)
|
||||
{
|
||||
const target = String (event.over.id)
|
||||
const current = latest.current ?? ids
|
||||
onChange (target === lastOver.current || target === String (event.active.id)
|
||||
? current : move (current, String (event.active.id), target))
|
||||
}
|
||||
const session = drag.current
|
||||
reset ()
|
||||
if (session == null || event.over == null || session.id !== String (event.active.id)
|
||||
|| session.ids.length !== ids.length
|
||||
|| session.ids.some ((id, index) => id !== ids[index])) return
|
||||
const next = move (ids, session.id, String (event.over.id))
|
||||
if (next.some ((id, index) => id !== ids[index])) onChange (next)
|
||||
}}>
|
||||
<SortableContext items={preview ?? ids} strategy={horizontalListSortingStrategy}>
|
||||
{children (preview ?? ids)}
|
||||
<SortableContext items={ids} strategy={horizontalListSortingStrategy}>
|
||||
{children (ids)}
|
||||
</SortableContext>
|
||||
{typeof document !== 'undefined' && createPortal (<DragOverlay
|
||||
dropAnimation={motion.reduced ? null : {
|
||||
|
||||
@@ -46,6 +46,8 @@ const SortableItem = ({ id, name, fixed, children, moveGroup, handleId, renderSh
|
||||
return <div ref={item.setNodeRef} style={style}
|
||||
className={cn ('relative max-w-full rounded border border-slate-300 bg-white',
|
||||
'text-slate-900 dark:border-slate-600 dark:bg-slate-900 dark:text-slate-100',
|
||||
item.isOver && !item.isDragging
|
||||
&& 'ring-2 ring-blue-500 dark:ring-blue-400',
|
||||
item.isDragging && 'opacity-30 ring-2 ring-blue-500 dark:ring-blue-400')}>
|
||||
<div className="flex min-w-0 items-start gap-1 px-1">
|
||||
{handle}
|
||||
|
||||
@@ -15,6 +15,12 @@ type Groups = Record<string, string[]>
|
||||
type Name = { text: string; language: string }
|
||||
|
||||
|
||||
const sameGroups = (left: Groups, right: Groups): boolean =>
|
||||
Object.keys (left).length === Object.keys (right).length
|
||||
&& Object.keys (left).every (key => left[key].length === right[key]?.length
|
||||
&& left[key].every ((id, index) => id === right[key][index]))
|
||||
|
||||
|
||||
const Group = ({ id, children }: { id: string; children: ReactNode }) => {
|
||||
const drop = useDroppable ({ id: `group:${id}` })
|
||||
return <div ref={drop.setNodeRef} className={cn (
|
||||
@@ -29,13 +35,10 @@ const SortableList = ({ groups, name, renderItem, renderGroup, onChange, fixed =
|
||||
renderGroup: (id: string, content: ReactNode) => ReactNode
|
||||
onChange: (groups: Groups) => void; fixed?: string[] },
|
||||
) => {
|
||||
const [preview, setPreview] = useState<Groups | null> (null)
|
||||
const previewRef = useRef<Groups | null> (null)
|
||||
const lastOver = useRef<string | null> (null)
|
||||
const drag = useRef<{ id: string; groups: Groups } | null> (null)
|
||||
const scope = useId ()
|
||||
const [active, setActive] = useState<string | null> (null)
|
||||
const motion = useClientMotion ()
|
||||
const current = preview ?? groups
|
||||
const sensors = useSensors (
|
||||
useSensor (MouseSensor, { activationConstraint: { distance: 4 } }),
|
||||
useSensor (TouchSensor, { activationConstraint: { delay: 350, tolerance: 8 } }),
|
||||
@@ -45,7 +48,8 @@ const SortableList = ({ groups, name, renderItem, renderGroup, onChange, fixed =
|
||||
const relocate = (value: Groups, id: string, over: string): Groups => {
|
||||
const source = owner (id, value)
|
||||
const target = over.startsWith ('group:') ? over.slice (6) : owner (over, value)
|
||||
if (source == null || target == null || fixed.includes (id) || fixed.includes (over))
|
||||
if (source == null || target == null || value[target] == null
|
||||
|| fixed.includes (id) || fixed.includes (over))
|
||||
return value
|
||||
if (source === target)
|
||||
{
|
||||
@@ -62,19 +66,20 @@ const SortableList = ({ groups, name, renderItem, renderGroup, onChange, fixed =
|
||||
[target]: destination }
|
||||
}
|
||||
const moveGroup = (id: string, direction: number) => {
|
||||
if (drag.current != null) return
|
||||
const keys = Object.keys (groups)
|
||||
const group = owner (id, groups)
|
||||
const target = keys[keys.indexOf (group ?? '') + direction]
|
||||
if (target != null)
|
||||
{
|
||||
onChange (relocate (groups, id, `group:${target}`))
|
||||
const next = relocate (groups, id, `group:${target}`)
|
||||
if (sameGroups (groups, next)) return
|
||||
onChange (next)
|
||||
requestAnimationFrame (() => document.getElementById (`${scope}-${id}`)?.focus ())
|
||||
}
|
||||
}
|
||||
const reset = () => {
|
||||
previewRef.current = null
|
||||
lastOver.current = null
|
||||
setPreview (null)
|
||||
drag.current = null
|
||||
setActive (null)
|
||||
}
|
||||
return <DndContext sensors={sensors} collisionDetection={closestCenter}
|
||||
@@ -87,32 +92,20 @@ const SortableList = ({ groups, name, renderItem, renderGroup, onChange, fixed =
|
||||
onDragCancel: () => undefined },
|
||||
screenReaderInstructions: { draggable: active == null ? '' : name (active).text } }}
|
||||
onDragStart={event => {
|
||||
previewRef.current = groups
|
||||
lastOver.current = null
|
||||
setPreview (groups)
|
||||
setActive (String (event.active.id))
|
||||
}} onDragOver={event => {
|
||||
if (event.over == null || event.active.id === event.over.id) return
|
||||
const target = String (event.over.id)
|
||||
if (lastOver.current === target) return
|
||||
const next = relocate (previewRef.current ?? groups, String (event.active.id), target)
|
||||
previewRef.current = next
|
||||
lastOver.current = target
|
||||
setPreview (next)
|
||||
const id = String (event.active.id)
|
||||
drag.current = { id, groups }
|
||||
setActive (id)
|
||||
}} onDragCancel={reset} onDragEnd={event => {
|
||||
if (event.over != null)
|
||||
{
|
||||
const id = String (event.active.id)
|
||||
const target = String (event.over.id)
|
||||
const latest = previewRef.current ?? groups
|
||||
const next = (target === id || target === lastOver.current)
|
||||
? latest : relocate (latest, id, target)
|
||||
onChange (next)
|
||||
}
|
||||
const session = drag.current
|
||||
reset ()
|
||||
if (session == null || event.over == null || session.id !== String (event.active.id)
|
||||
|| !sameGroups (session.groups, groups)) return
|
||||
const next = relocate (groups, session.id, String (event.over.id))
|
||||
if (!sameGroups (groups, next)) onChange (next)
|
||||
}}>
|
||||
|
||||
{Object.entries (current).map (([group, ids]) => <div key={group}>
|
||||
{/* Keep indexes and droppable geometry stable until the drop is committed. */}
|
||||
{Object.entries (groups).map (([group, ids]) => <div key={group}>
|
||||
{renderGroup (group, <SortableContext items={ids} strategy={rectSortingStrategy}>
|
||||
<Group id={group}>{ids.map (id => <SortableItem key={id} id={id}
|
||||
name={name (id)} fixed={fixed.includes (id)} handleId={`${scope}-${id}`}
|
||||
|
||||
@@ -25,7 +25,8 @@ const ConditionEditor = (
|
||||
const { label, message } = useUITranslation ('counts')
|
||||
const fields = definition.filterFields ?? definition.fields
|
||||
const fieldName = (key: string) => (fields.find (item => item.key === key)?.labels
|
||||
?? [countsFieldLabel (key)]).map (key => message (key).text).join (' › ')
|
||||
?? [countsFieldLabel (key)]).map (label => message (
|
||||
label, fields.find (item => item.key === key)?.labelValues).text).join (' › ')
|
||||
const [condition, setCondition] = useState<CountsCondition> (() => {
|
||||
const next = { ...initial, field: initial?.field ?? 'resource_id',
|
||||
operator: initial?.operator ?? 'eq' }
|
||||
@@ -39,9 +40,9 @@ const ConditionEditor = (
|
||||
controls.setActions ([{ label: label (initial == null ? 'add' : 'shared:actions.edit'),
|
||||
onSelect: () => save (condition) }])
|
||||
}, [controls, condition, initial, label, save])
|
||||
return <div className="space-y-2">
|
||||
return <div className="space-y-5 px-1 py-3 md:px-3">
|
||||
<select value={field} aria-label={message ('field').text}
|
||||
className={inputClass (false, 'h-9 w-full')}
|
||||
className={inputClass (false, 'h-11 w-full')}
|
||||
onChange={event => {
|
||||
const field = event.target.value
|
||||
const operator = fields.find (item => item.key === field)?.operators[0]
|
||||
@@ -51,7 +52,7 @@ const ConditionEditor = (
|
||||
<option key={field.key} value={field.key}>{fieldName (field.key)}</option>))}
|
||||
</select>
|
||||
<select value={operator} aria-label={message ('operator').text}
|
||||
className={inputClass (false, 'h-9 w-full')}
|
||||
className={inputClass (false, 'h-11 w-full')}
|
||||
onChange={event => setCondition (previous => {
|
||||
const operator = event.target.value
|
||||
const next = { ...previous, operator }
|
||||
@@ -74,15 +75,16 @@ const ConditionEditor = (
|
||||
{operators.map (key => <option key={key} value={key}>
|
||||
{message (`operators.${key}`).text}</option>)}
|
||||
</select>
|
||||
<label className="block text-sm">{label ('pattern.input')}
|
||||
{inputs.length > 1 && <label className="block space-y-2 text-sm">
|
||||
<span>{label ('counts:pattern.input')}</span>
|
||||
<select value={condition.input ?? inputs[0]}
|
||||
className={inputClass (false, 'h-9 w-full')}
|
||||
className={inputClass (false, 'h-11 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>)}
|
||||
{message (`counts:pattern.${input}`).text}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</label>}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -98,7 +100,8 @@ const CountsAdditionalConditions = ({ params, update }: {
|
||||
const metadata = definition.filterFields ?? definition.fields
|
||||
const fields = metadata.filter (field => field.operators.length > 0)
|
||||
const fieldName = (key: string) => (metadata.find (item => item.key === key)?.labels
|
||||
?? [countsFieldLabel (key)]).map (key => message (key).text).join (' › ')
|
||||
?? [countsFieldLabel (key)]).map (label => message (
|
||||
label, metadata.find (item => item.key === key)?.labelValues).text).join (' › ')
|
||||
const write = (next: CountsCondition[]) => update (
|
||||
'conditions', encodeCountState ('conditions', next))
|
||||
const replace = (index: number, condition: CountsCondition) => write (
|
||||
@@ -112,6 +115,7 @@ const CountsAdditionalConditions = ({ params, update }: {
|
||||
else replace (index, condition)
|
||||
}
|
||||
void dialogue.form ({ title: label ('additionalConditions'), footerLayout: 'inline',
|
||||
size: 'large',
|
||||
body: controls => <ConditionEditor definition={definition}
|
||||
initial={initial} controls={controls} save={save}/> })
|
||||
}
|
||||
@@ -124,7 +128,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-9 w-36 min-w-0 max-w-full py-1')}
|
||||
className={inputClass (false, 'h-10 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 })}>
|
||||
@@ -135,13 +139,13 @@ const CountsAdditionalConditions = ({ params, update }: {
|
||||
</select>
|
||||
}
|
||||
if (type === 'datetime')
|
||||
return <DateTimeField precision="second" className="h-9 w-48 min-w-0 max-w-full py-1"
|
||||
return <DateTimeField precision="second" className="h-10 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-9 w-36 min-w-0 max-w-full py-1')}
|
||||
className={inputClass (false, 'h-10 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 })}/>
|
||||
}
|
||||
@@ -150,8 +154,8 @@ const CountsAdditionalConditions = ({ params, update }: {
|
||||
<Button type="button" variant="outline" size="sm" disabled={fields.length === 0}
|
||||
onClick={() => edit ()}>{label ('add')}</Button></legend>
|
||||
{conditions.map ((condition, index) => (
|
||||
<div key={index} className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1
|
||||
border-b border-slate-200 py-1 dark:border-slate-700">
|
||||
<div key={index} className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-2
|
||||
border-b border-slate-200 py-2 dark:border-slate-700">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => edit (index)}
|
||||
aria-label={`${fieldName (condition.field)} ${message (
|
||||
`operators.${condition.operator}`).text}`}
|
||||
|
||||
@@ -35,9 +35,13 @@ const CountsAxes = ({ params, update }: {
|
||||
}
|
||||
const commit = (next: Record<string, string[]>) => {
|
||||
for (const group of ['rows', 'cols'] as const)
|
||||
update (group, encodeCountState (group, next[group].map (field => axes.find (
|
||||
axis => axis.field === field) ?? { field,
|
||||
...(countsFilterFields (definition)[field] === 'datetime' ? { grain: 'month' } : { }) })))
|
||||
{
|
||||
if (next[group].join (' ') === groups[group].join (' ')) continue
|
||||
const selected = next[group].map (field => axes.find (axis => axis.field === field)
|
||||
?? { field, ...(countsFilterFields (definition)[field] === 'datetime'
|
||||
? { grain: 'month' } : { }) })
|
||||
update (group, encodeCountState (group, selected))
|
||||
}
|
||||
}
|
||||
const place = (id: string, target?: 'rows' | 'cols') => {
|
||||
const next = Object.fromEntries (Object.entries (groups).map (([key, ids]) => [
|
||||
|
||||
@@ -47,7 +47,7 @@ const CountsCandidatePicker = (
|
||||
if (data != null) point.current = data.executionAt
|
||||
}, [data])
|
||||
useEffect (() => {
|
||||
controls.setActions ([{ label: label ('pattern.selection'),
|
||||
controls.setActions ([{ label: label ('counts:pattern.selection'),
|
||||
onSelect: () => save ([...selected]) }])
|
||||
}, [controls, label, save, selected])
|
||||
const fixed: CountsCandidate[] = (field.choices ?? []).map (choice => ({
|
||||
@@ -89,14 +89,14 @@ const CountsCandidatePicker = (
|
||||
{message (choice.label).text}</option>)}
|
||||
</select>}
|
||||
<fieldset className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<legend>{label ('pattern.columns')}</legend>
|
||||
<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 ('pattern.value')}{':'}
|
||||
<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 ())}>
|
||||
@@ -104,7 +104,7 @@ const CountsCandidatePicker = (
|
||||
{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>
|
||||
<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>
|
||||
|
||||
@@ -23,13 +23,13 @@ const CountsConditionValues = (
|
||||
const values = condition.values ?? []
|
||||
const pick = () => {
|
||||
void dialogue.form ({ title: label (field.candidateSource === 'tags'
|
||||
? 'pattern.link' : 'pattern.pattern'), size: 'large', footerLayout: 'inline',
|
||||
? 'counts:pattern.link' : 'counts: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>
|
||||
{label ('counts:pattern.list')}{' ('}{values.length}{')'}</Button>
|
||||
const append = () => {
|
||||
if (draft === '' || values.length >= maximum
|
||||
|| values.some (value => String (value) === draft))
|
||||
@@ -40,14 +40,14 @@ const CountsConditionValues = (
|
||||
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}
|
||||
className="h-10 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')}
|
||||
className={inputClass (false, 'h-10 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}>
|
||||
@@ -56,7 +56,7 @@ const CountsConditionValues = (
|
||||
}
|
||||
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')}
|
||||
className={inputClass (false, 'h-10 w-36 min-w-0 max-w-full')}
|
||||
onChange={event => setDraft (event.target.value)}/>
|
||||
}
|
||||
const display = (value: string | number | boolean) => {
|
||||
@@ -65,7 +65,7 @@ const CountsConditionValues = (
|
||||
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">
|
||||
return <div className="flex min-w-0 max-w-full flex-wrap items-center gap-x-2 gap-y-2 py-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"
|
||||
|
||||
@@ -91,8 +91,9 @@ const CountsControls = (
|
||||
const history = draft.get ('definition') === 'history'
|
||||
const update = (key: string, value: string) => setDraft (previous => {
|
||||
const next = new URLSearchParams (previous)
|
||||
if (['rows', 'cols'].includes (key) && next.get ('sort') !== '') next.delete ('sort')
|
||||
setCountState (next, key, value)
|
||||
if (next.toString () === previous.toString ()) return previous
|
||||
if (['rows', 'cols'].includes (key) && next.get ('sort') !== '') next.delete ('sort')
|
||||
if (key !== 'mode') return next
|
||||
const resolved = normaliseCountsParams (resource, next, definition)
|
||||
const axes = [...readCountState<unknown[]> (resolved, 'rows', []),
|
||||
|
||||
@@ -2,7 +2,9 @@ import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import LocalisedText from '@/components/LocalisedText'
|
||||
import { apiDelete, apiGet, apiPost, isApiError, startAPIDownload } from '@/lib/api'
|
||||
import { useClientMotion } from '@/lib/useClientMotion'
|
||||
import { useUITranslation } from '@/lib/useUITranslation'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import type { DialogueFormControls } from '@/lib/dialogues/useDialogue'
|
||||
import type { LocaleCode } from '@/lib/locales'
|
||||
@@ -10,7 +12,8 @@ import type { LocaleCode } from '@/lib/locales'
|
||||
export type CountsExportHandle = { cancel: () => Promise<boolean> }
|
||||
type ExportStatus = {
|
||||
status: 'starting' | 'queued' | 'running' | 'cancelling' | 'cancelled' | 'ready' | 'failed'
|
||||
processed: number; total: number | null; token?: string }
|
||||
processed: number; total: number | null; token?: string
|
||||
phase?: 'queued' | 'aggregating' | 'writing'; heartbeatAt?: number; updatedAt?: number }
|
||||
|
||||
|
||||
const CountsExportProgress = (
|
||||
@@ -19,6 +22,7 @@ const CountsExportProgress = (
|
||||
handle: CountsExportHandle; controls: DialogueFormControls },
|
||||
) => {
|
||||
const { label, message } = useUITranslation ('counts')
|
||||
const motion = useClientMotion ()
|
||||
const [state, setState] = useState<ExportStatus> ({
|
||||
status: 'starting', processed: 0, total: null })
|
||||
const [cancelError, setCancelError] = useState (false)
|
||||
@@ -27,40 +31,55 @@ const CountsExportProgress = (
|
||||
const job = useRef<string | null> (null)
|
||||
const creation = useRef<Promise<{ job: string }> | null> (null)
|
||||
const downloaded = useRef (false)
|
||||
const pollRequest = useRef<AbortController | null> (null)
|
||||
useEffect (() => {
|
||||
handle.cancel = async () => {
|
||||
if (terminal.current) return true
|
||||
cancelled.current = true
|
||||
if (job.current != null)
|
||||
{
|
||||
const controller = new AbortController ()
|
||||
const deadline = setTimeout (() => controller.abort (), 15_000)
|
||||
try
|
||||
{
|
||||
setCancelError (false)
|
||||
const result = await apiDelete<ExportStatus> (
|
||||
`/counts/export/jobs/${encodeURIComponent (job.current)}`, { locale })
|
||||
`/counts/export/jobs/${encodeURIComponent (job.current)}`, {
|
||||
locale, signal: controller.signal })
|
||||
setState (result)
|
||||
}
|
||||
catch
|
||||
{
|
||||
setCancelError (true)
|
||||
}
|
||||
finally
|
||||
{
|
||||
clearTimeout (deadline)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}, [handle, locale])
|
||||
useEffect (() => {
|
||||
let active = true
|
||||
let lastResponse = Date.now ()
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const poll = async (token: string) => {
|
||||
const controller = new AbortController ()
|
||||
pollRequest.current = controller
|
||||
const deadline = setTimeout (() => controller.abort (), 15_000)
|
||||
try
|
||||
{
|
||||
const response = await apiGet<ExportStatus> (
|
||||
`/counts/export/jobs/${encodeURIComponent (token)}`, { locale })
|
||||
`/counts/export/jobs/${encodeURIComponent (token)}`, {
|
||||
locale, signal: controller.signal })
|
||||
if (!active) return
|
||||
const result = (cancelled.current && response.status === 'ready')
|
||||
? await apiDelete<ExportStatus> (
|
||||
`/counts/export/jobs/${encodeURIComponent (token)}`, { locale }) : response
|
||||
`/counts/export/jobs/${encodeURIComponent (token)}`, {
|
||||
locale, signal: controller.signal }) : response
|
||||
if (!active) return
|
||||
lastResponse = Date.now ()
|
||||
setState (result)
|
||||
terminal.current = ['ready', 'failed', 'cancelled'].includes (result.status)
|
||||
if (result.status === 'ready' && result.token != null && !downloaded.current)
|
||||
@@ -76,7 +95,8 @@ const CountsExportProgress = (
|
||||
{
|
||||
if (active)
|
||||
{
|
||||
if (isApiError (error) && error.response?.status === 404)
|
||||
if ((isApiError (error) && error.response?.status === 404)
|
||||
|| Date.now () - lastResponse > 30_000)
|
||||
{
|
||||
terminal.current = true
|
||||
setState (previous => ({ ...previous, status: 'failed' }))
|
||||
@@ -86,8 +106,19 @@ const CountsExportProgress = (
|
||||
timer = setTimeout (() => poll (token), 2000)
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
clearTimeout (deadline)
|
||||
if (pollRequest.current === controller) pollRequest.current = null
|
||||
}
|
||||
}
|
||||
if (creation.current == null)
|
||||
{
|
||||
const controller = new AbortController ()
|
||||
const deadline = setTimeout (() => controller.abort (), 15_000)
|
||||
creation.current = apiPost<{ job: string }> ('/counts/export/jobs', payload, {
|
||||
locale, signal: controller.signal }).finally (() => clearTimeout (deadline))
|
||||
}
|
||||
creation.current ??= apiPost<{ job: string }> ('/counts/export/jobs', payload, { locale })
|
||||
creation.current.then (async result => {
|
||||
if (!active) return
|
||||
job.current = result.job
|
||||
@@ -103,6 +134,7 @@ const CountsExportProgress = (
|
||||
return () => {
|
||||
active = false
|
||||
if (timer != null) clearTimeout (timer)
|
||||
pollRequest.current?.abort ()
|
||||
}
|
||||
}, [handle, locale, payload])
|
||||
useEffect (() => {
|
||||
@@ -110,11 +142,17 @@ const CountsExportProgress = (
|
||||
}, [controls])
|
||||
const percent = state.total == null ? null : (state.total === 0 ? 100
|
||||
: Math.min (100, Math.floor (state.processed / state.total * 100)))
|
||||
const phase = message (`exportProgress.${state.status}`)
|
||||
return <div className="space-y-3 py-2">
|
||||
const busy = !['ready', 'failed', 'cancelled'].includes (state.status)
|
||||
const phase = message (`exportProgress.${state.status === 'running'
|
||||
? state.phase ?? 'running' : state.status}`)
|
||||
return <div className="space-y-3 py-2" aria-busy={busy}>
|
||||
<p>{label ('exportProgress.format', {
|
||||
format: message (`exportFormats.${payload.format}`).text })}</p>
|
||||
<p role="status" aria-live="polite">
|
||||
<p role="status" aria-live="polite" className="flex items-center gap-2">
|
||||
{busy && <span aria-hidden="true" className={cn (
|
||||
'size-4 shrink-0 rounded-full border-2 border-current border-r-transparent',
|
||||
!motion.reduced && 'animate-spin')}
|
||||
style={{ animationDuration: `${motion.duration ('collapse') * 4}ms` }}/>}
|
||||
<LocalisedText language={phase.language}>{phase.text}</LocalisedText>
|
||||
</p>
|
||||
<progress className="h-3 w-full" max={100} value={percent ?? undefined}
|
||||
|
||||
@@ -29,11 +29,11 @@ const FieldBranch = ({ field, path, enabled, selected, params, select, update }:
|
||||
<input type="checkbox" checked={checked} disabled={!enabled || (field.children != null
|
||||
&& !field.children.some (child => selected.has (`${path}.${child.key}`)))}
|
||||
onChange={event => select (path, event.target.checked)}/>
|
||||
{label (field.label)}
|
||||
{label (field.label, field.labelValues)}
|
||||
</label>
|
||||
{field.children != null && <button type="button" aria-expanded={open}
|
||||
aria-controls={id} aria-label={message (field.label).text}
|
||||
lang={message (field.label).language} onClick={() => setOpen (!open)}
|
||||
aria-controls={id} aria-label={message (field.label, field.labelValues).text}
|
||||
lang={message (field.label, field.labelValues).language} onClick={() => setOpen (!open)}
|
||||
className="rounded px-1 focus-visible:outline-none focus-visible:ring-2
|
||||
focus-visible:ring-blue-500 dark:focus-visible:ring-blue-400">
|
||||
<span aria-hidden className="inline-block" style={{
|
||||
@@ -62,7 +62,8 @@ const FieldGroup = ({ fields, path, params, enabled, selected, select, update }:
|
||||
return <SortableList groups={{ items: ordered.map (field => field.key) }}
|
||||
fixed={path === '' ? ['thumbnail'] : []}
|
||||
name={key => message ('reorder', {
|
||||
column: message (fields.find (field => field.key === key)!.label).text })}
|
||||
column: message (fields.find (field => field.key === key)!.label,
|
||||
fields.find (field => field.key === key)!.labelValues).text })}
|
||||
renderGroup={(_, content) => content}
|
||||
onChange={groups => {
|
||||
const current = fields.map (field => `${prefix}${field.key}`)
|
||||
|
||||
@@ -4,7 +4,7 @@ import CountsSortHeader from '@/components/counts/CountsSortHeader'
|
||||
import CountsSelectableTable from '@/components/counts/CountsSelectableTable'
|
||||
import { changeCountSort, readCountSort, selectedCountChildren,
|
||||
writeCountSort, orderedCountFields } from '@/lib/counts'
|
||||
import { countsLogicalName, countsNestedClipboardTable, countsNestedExport,
|
||||
import { countsFieldValue, countsLogicalName, countsNestedClipboardTable, countsNestedExport,
|
||||
countsNestedOrder, countsNestedText, countsNestedIsScalar, countsNestedScalarText }
|
||||
from '@/lib/countsDisplay'
|
||||
import { useLocale } from '@/lib/useLocale'
|
||||
@@ -33,7 +33,7 @@ const CountsNestedTable = (
|
||||
.map (sort => ({ ...sort, key: sort.key.slice (path.length + 1) }))
|
||||
const ordered = countsNestedOrder (rows, path, params, locale)
|
||||
const scalar = (row: CountRecord, field: string) => {
|
||||
const value = row[field]
|
||||
const value = countsFieldValue (row, field)
|
||||
if (value == null) return null
|
||||
if (field === 'tagId' || field === 'tagGroupId')
|
||||
return <PrefetchLink to={field === 'tagId'
|
||||
@@ -47,6 +47,9 @@ const CountsNestedTable = (
|
||||
{String (value)}</PrefetchLink>
|
||||
}
|
||||
const display = countsLogicalName (row, field, locale)
|
||||
const nameLocale = fields.find (item => item.key === field)?.nameLocale
|
||||
if (nameLocale != null)
|
||||
return <LocalisedText language={nameLocale.languageCode}>{display.text}</LocalisedText>
|
||||
if (typeof value === 'boolean')
|
||||
return <span aria-label={display.text} lang={display.language}>{value ? '✓' : '✗'}</span>
|
||||
if (display.language != null)
|
||||
@@ -75,7 +78,7 @@ const CountsNestedTable = (
|
||||
dark:bg-background dark:text-foreground"><tr>
|
||||
{columns.map (field => (
|
||||
<CountsSortHeader key={field.key} field={field.key}
|
||||
label={message (field.label)}
|
||||
label={message (field.label, field.labelValues)}
|
||||
sorts={sorts} structured={field.children != null}
|
||||
change={(key, additive) => {
|
||||
const next = changeCountSort (sorts, key, additive)
|
||||
@@ -101,7 +104,8 @@ const CountsNestedTable = (
|
||||
? <CountsNestedTable rows={row[field.key] as CountRecord[] ?? []}
|
||||
fields={field.children} path={`${path}.${field.key}`}
|
||||
resource={resource}
|
||||
title={message (field.label)} params={params} update={update}/>
|
||||
title={message (field.label, field.labelValues)}
|
||||
params={params} update={update}/>
|
||||
: scalar (row, field.key)}
|
||||
</td>))}
|
||||
</tr>))}</tbody>
|
||||
|
||||
@@ -45,8 +45,10 @@ const CountsResults = (
|
||||
{
|
||||
return <CountsNestedTable rows={countsNestedRows (row, column)}
|
||||
fields={field.children} path={column} resource={resource}
|
||||
title={message (field.label)} params={params} update={update}/>
|
||||
title={message (field.label, field.labelValues)} params={params} update={update}/>
|
||||
}
|
||||
if (field.nameLocale != null)
|
||||
return <LocalisedText language={field.nameLocale.languageCode}>{value}</LocalisedText>
|
||||
switch (column)
|
||||
{
|
||||
case 'resource_id':
|
||||
@@ -105,7 +107,8 @@ const CountsResults = (
|
||||
}
|
||||
}
|
||||
return <SortableColumns ids={columns} fixed={['thumbnail']}
|
||||
name={key => message (fields.find (field => field.key === key)!.label)}
|
||||
name={key => message (fields.find (field => field.key === key)!.label,
|
||||
fields.find (field => field.key === key)!.labelValues)}
|
||||
onChange={next => {
|
||||
const all = fields.map (field => field.key)
|
||||
const other = (params.get ('order') ?? '').split (' ').filter (key => !all.includes (key))
|
||||
@@ -133,7 +136,8 @@ const CountsResults = (
|
||||
<CountsResizeHandle field={column} label={message (countColumns[column])}/>
|
||||
</th>
|
||||
: <CountsSortHeader key={column} field={column}
|
||||
label={message (fields.find (field => field.key === column)!.label)}
|
||||
label={message (fields.find (field => field.key === column)!.label,
|
||||
fields.find (field => field.key === column)!.labelValues)}
|
||||
dragId={column}
|
||||
sorts={sorts} change={sort} structured={fields.find (
|
||||
field => field.key === column)?.children != null}/>)}
|
||||
|
||||
@@ -45,13 +45,14 @@ const cellAt = (table: HTMLTableElement, point: Point) =>
|
||||
|
||||
|
||||
const CountsSelectableTable = (
|
||||
{ family, columns, identity, children, className, name, copyTable }: {
|
||||
{ family, columns, identity, children, className, name, copyTable, stretched = false }: {
|
||||
family: string
|
||||
columns: string[]
|
||||
identity: string
|
||||
children: ReactNode
|
||||
className?: string
|
||||
name?: { text: string; language: string }
|
||||
stretched?: boolean
|
||||
copyTable?: () => CountsClipboardTable },
|
||||
) => {
|
||||
const id = useId ()
|
||||
@@ -343,13 +344,15 @@ const CountsSelectableTable = (
|
||||
}
|
||||
}
|
||||
const totalWidth = columns.reduce ((sum, key) => sum + layout.width (family, key), 0)
|
||||
const flexible = stretched ? columns.filter (key => !layout.custom (family, key)).length : 0
|
||||
return (
|
||||
<CountsTableScope.Provider value={family}>
|
||||
<table ref={root} role="grid" tabIndex={0} aria-multiselectable="true"
|
||||
aria-label={name?.text ?? message ('selection.table').text}
|
||||
aria-describedby={`${id}-hint`}
|
||||
|
||||
style={{ tableLayout: 'fixed', width: totalWidth }}
|
||||
style={{ tableLayout: 'fixed', minWidth: totalWidth,
|
||||
width: flexible > 0 ? '100%' : totalWidth }}
|
||||
className={cn ('counts-grid select-none border-separate border-spacing-0 outline-none',
|
||||
'focus-visible:outline focus-visible:outline-2 focus-visible:outline-blue-500',
|
||||
'dark:focus-visible:outline-blue-400',
|
||||
@@ -501,18 +504,35 @@ const CountsSelectableTable = (
|
||||
}
|
||||
if (textCell != null)
|
||||
{
|
||||
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes (event.key)) return
|
||||
const boundaryFlg = event.key === 'Home' || event.key === 'End'
|
||||
if (!boundaryFlg
|
||||
&& !['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes (event.key)) return
|
||||
const text = window.getSelection ()
|
||||
if (text == null || typeof text.modify !== 'function') return
|
||||
if (text == null) return
|
||||
if (boundaryFlg && (event.ctrlKey || event.metaKey))
|
||||
{
|
||||
const nodes = textMode?.nodes.filter (node => textCell.contains (node)) ?? []
|
||||
const edge = event.key === 'Home' ? nodes[0] : nodes[nodes.length - 1]
|
||||
if (edge == null) return
|
||||
event.preventDefault ()
|
||||
event.stopPropagation ()
|
||||
const end = event.key === 'Home' ? 0 : edge.textContent?.length ?? 0
|
||||
if (event.shiftKey && text.anchorNode != null && textCell.contains (text.anchorNode))
|
||||
text.setBaseAndExtent (text.anchorNode, text.anchorOffset, edge, end)
|
||||
else text.setPosition (edge, end)
|
||||
return
|
||||
}
|
||||
if (typeof text.modify !== 'function') return
|
||||
event.preventDefault ()
|
||||
event.stopPropagation ()
|
||||
const anchor = text.anchorNode
|
||||
const offset = text.anchorOffset
|
||||
const backward = event.key === 'ArrowLeft' || event.key === 'ArrowUp'
|
||||
const backward = ['ArrowLeft', 'ArrowUp', 'Home'].includes (event.key)
|
||||
const vertical = event.key === 'ArrowUp' || event.key === 'ArrowDown'
|
||||
const direction = vertical ? (backward ? 'backward' : 'forward')
|
||||
const direction = (vertical || boundaryFlg) ? (backward ? 'backward' : 'forward')
|
||||
: (backward ? 'left' : 'right')
|
||||
const grain = vertical ? 'line' : ((event.ctrlKey || event.metaKey) ? 'word' : 'character')
|
||||
const grain = boundaryFlg ? 'lineboundary' : (vertical ? 'line'
|
||||
: ((event.ctrlKey || event.metaKey) ? 'word' : 'character'))
|
||||
text.modify (event.shiftKey ? 'extend' : 'move', direction, grain)
|
||||
if (!textCell.contains (text.focusNode))
|
||||
{
|
||||
@@ -528,16 +548,22 @@ const CountsSelectableTable = (
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes (event.key)) return
|
||||
const boundaryFlg = event.key === 'Home' || event.key === 'End'
|
||||
if (!boundaryFlg
|
||||
&& !['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes (event.key)) return
|
||||
const rows = event.currentTarget.tBodies[0]?.rows
|
||||
if (rows == null || rows.length === 0) return
|
||||
event.preventDefault ()
|
||||
const previous = selection?.end ?? { row: 0, column: 0 }
|
||||
const key = selection == null ? '' : event.key
|
||||
const key = (selection == null && !boundaryFlg) ? '' : event.key
|
||||
const first = event.key === 'Home'
|
||||
const vertical = boundaryFlg && (event.ctrlKey || event.metaKey)
|
||||
const point = {
|
||||
row: Math.max (0, Math.min (rows.length - 1, previous.row
|
||||
row: vertical ? (first ? 0 : rows.length - 1)
|
||||
: Math.max (0, Math.min (rows.length - 1, previous.row
|
||||
+ (key === 'ArrowDown' ? 1 : (key === 'ArrowUp' ? -1 : 0)))),
|
||||
column: Math.max (0, Math.min (columns.length - 1, previous.column
|
||||
column: (boundaryFlg && !vertical) ? (first ? 0 : columns.length - 1)
|
||||
: Math.max (0, Math.min (columns.length - 1, previous.column
|
||||
+ (key === 'ArrowRight' ? 1 : (key === 'ArrowLeft' ? -1 : 0)))) }
|
||||
setStored ({ identity, anchor: event.shiftKey && selection != null
|
||||
? selection.anchor : point, end: point })
|
||||
@@ -549,7 +575,9 @@ const CountsSelectableTable = (
|
||||
<span role="status">{textCell != null && label ('selection.textMode')}</span>
|
||||
</caption>
|
||||
<colgroup>{columns.map (key => (
|
||||
<col key={key} style={{ width: layout.width (family, key) }}/>))}</colgroup>
|
||||
<col key={key} style={{ width: flexible > 0 && !layout.custom (family, key)
|
||||
? `calc(${layout.width (family, key)}px + (100% - ${totalWidth}px) / ${flexible})`
|
||||
: layout.width (family, key) }}/>))}</colgroup>
|
||||
{children}
|
||||
</table>
|
||||
{textCell != null && caret != null && caret.cell === textCell && createPortal (
|
||||
|
||||
@@ -97,7 +97,8 @@ const CountsSummaryTable = (
|
||||
let ordinal = (data.page - 1) * data.limit
|
||||
const rowLabels = rowHeaders.map (header => {
|
||||
const labels = names (header, rows, 0)
|
||||
const number = header.kind === 'data' ? String (++ordinal) : ''
|
||||
// With no row axes the sole result row is not an additional rollup row.
|
||||
const number = rows.length === 0 ? '1' : (header.kind === 'data' ? String (++ordinal) : '')
|
||||
if (header.kind !== 'data') labels.push (aggregateLabel (header))
|
||||
return [...(numbered ? [number] : []),
|
||||
...Array.from ({ length: leading }, (_, index) => labels[index] ?? '')]
|
||||
@@ -225,11 +226,11 @@ const CountsSummaryTable = (
|
||||
return output
|
||||
}
|
||||
return <div className="min-h-0 flex-1 overflow-auto" data-counts-results aria-busy={pending}>
|
||||
<CountsSelectableTable family={`pivot_${resource}`} columns={keys}
|
||||
<CountsSelectableTable family={`pivot_${resource}`} columns={keys} stretched
|
||||
copyTable={() => ({ columns: keys.map ((_, index) => ({ label: headingRows[0][index] })),
|
||||
headerRows: headingRows, rows: matrix })}
|
||||
identity={`${countsRequestParams (params)}:${JSON.stringify (keys)}`}
|
||||
className="text-left text-sm">
|
||||
className="counts-summary text-left text-sm">
|
||||
<thead className="sticky top-0 z-20">
|
||||
{headingRows.map ((_, tier) => <tr key={tier}>
|
||||
{tier === 0 && <>
|
||||
|
||||
@@ -10,12 +10,14 @@ const CountsTableLayout = ({ children }: { children: ReactNode }) => {
|
||||
const [widths, setWidths] = useState<Record<string, number>> ({ })
|
||||
const width = (family: string, key: string) =>
|
||||
widths[`${family}/${key}`] ?? defaultCountWidths[key] ?? 160
|
||||
const custom = (family: string, key: string) => widths[`${family}/${key}`] != null
|
||||
const resize = (family: string, key: string, value: number) => {
|
||||
const next = Math.round (Math.max (MIN_COUNT_WIDTH, Math.min (MAX_COUNT_WIDTH, value)))
|
||||
setWidths (previous => ({ ...previous, [`${family}/${key}`]: next }))
|
||||
setWidths (previous => previous[`${family}/${key}`] === next ? previous
|
||||
: { ...previous, [`${family}/${key}`]: next })
|
||||
}
|
||||
return (
|
||||
<CountsLayoutContext.Provider value={{ width, resize }}>
|
||||
<CountsLayoutContext.Provider value={{ width, custom, resize }}>
|
||||
{children}
|
||||
</CountsLayoutContext.Provider>)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
@apply h-6;
|
||||
}
|
||||
|
||||
.counts-summary > tbody > tr > td,
|
||||
.counts-summary > thead > tr > th {
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
.counts-grid > tbody > tr > td[data-counts-text] {
|
||||
position: relative;
|
||||
outline: 1px solid #64748b;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { countsFieldLabel } from '@/lib/countsDefinitions'
|
||||
import { apiDownload, apiGet } from '@/lib/api'
|
||||
import { currentViewingLocale } from '@/lib/locales'
|
||||
|
||||
import { readCountState, encodeCountState } from '@/lib/queryState'
|
||||
|
||||
@@ -61,7 +62,8 @@ export type CountsPivotHeader = { values: (string | number | null)[]
|
||||
kind: 'data' | 'subtotal' | 'grand_total'
|
||||
depth?: number; axisCount?: number; labelKey?: string | null }
|
||||
export type CountsResult = {
|
||||
populationCount?: number
|
||||
populationCount?: number | null
|
||||
populationCountComputed?: boolean
|
||||
excludedCount?: number | null
|
||||
excludedCountScope?: string
|
||||
outputCount?: number | null
|
||||
@@ -76,6 +78,7 @@ export type CountsResult = {
|
||||
columnValues?: (string | number | null)[][]
|
||||
cells?: { values: (string | number | null)[]; count: number }[]
|
||||
axisLabels?: Record<string, Record<string, string | null>>
|
||||
axisNames?: Record<string, Record<string, Record<string, unknown>>>
|
||||
rows?: { field: string; grain?: string }[]
|
||||
cols?: { field: string; grain?: string }[]
|
||||
count: number
|
||||
@@ -93,7 +96,9 @@ export type CountsResult = {
|
||||
coverage?: { basis: string; exact: boolean
|
||||
postHistoryFrom: string | null; tagHistoryFrom: string | null } }
|
||||
export type CountSort = { key: string; direction: 'asc' | 'desc' }
|
||||
export type CountField = { key: string; label: string; children?: CountField[] }
|
||||
export type CountField = { key: string; label: string; children?: CountField[]
|
||||
labelValues?: Record<string, string>
|
||||
nameLocale?: { code: string; languageCode: string; scriptCode: string } }
|
||||
export type CountRecord = Record<string, unknown>
|
||||
|
||||
export const countColumns: Record<string, string> = {
|
||||
@@ -177,7 +182,8 @@ export const fieldsForCounts = (
|
||||
label: (key === 'parents' && resource === 'tags') ? 'tags:parents'
|
||||
: (countColumns[key] ?? countsFieldLabel (key)),
|
||||
children: key === 'parents' && resource === 'tags'
|
||||
? [...(nested.parents ?? []), { key: 'name', label: 'counts:columns.name' }]
|
||||
? [...(nested.parents ?? []), { key: 'name', label: 'counts:columns.name' },
|
||||
{ key: 'names', label: 'counts:columns.names', children: nameFields }]
|
||||
: nested[key] }))
|
||||
|
||||
|
||||
@@ -327,6 +333,29 @@ export const normaliseCountsParams = (
|
||||
definition?: CountsDefinition,
|
||||
): URLSearchParams => {
|
||||
const params = new URLSearchParams (source)
|
||||
if (definition?.nameColumns != null)
|
||||
{
|
||||
const names = definition.nameColumns.map (field => field.key)
|
||||
const current = definition.nameColumns.find (field =>
|
||||
field.nameLocale?.code === currentViewingLocale ())?.key
|
||||
if (resource === 'tags' && params.has ('columns'))
|
||||
params.set ('columns', params.get ('columns')!.split (' ').flatMap (key =>
|
||||
key === 'names' ? names : [key === 'name' && current != null ? current : key])
|
||||
.join (' '))
|
||||
if (params.has ('children'))
|
||||
{
|
||||
const selected = params.get ('children')!.split (' ')
|
||||
params.set ('children', selected.flatMap (key => {
|
||||
if (['tags.names', 'parents.names'].includes (key))
|
||||
return names.map (name => `${key.slice (0, -6)}.${name}`)
|
||||
return key.startsWith ('tags.names.') || key.startsWith ('parents.names.') ? [] : [key]
|
||||
}).join (' '))
|
||||
}
|
||||
if (resource === 'tags' && params.has ('order'))
|
||||
params.set ('order', params.get ('order')!.split (' ').flatMap (key =>
|
||||
key === 'names' ? names : [key === 'name' && current != null ? current : key])
|
||||
.join (' '))
|
||||
}
|
||||
for (const [key, value] of params)
|
||||
{
|
||||
if (['columns', 'order', 'sort', 'rows', 'cols', 'axis'].includes (key))
|
||||
@@ -353,11 +382,11 @@ export const normaliseCountsParams = (
|
||||
params.set ('through', params.get ('before')!)
|
||||
for (const key of ['basis', 'axis', 'latest', 'grain', 'before', 'summary_columns'])
|
||||
params.delete (key)
|
||||
if (params.has ('columns') && ['posts', 'tags'].includes (resource))
|
||||
if (params.has ('columns') && definition != null && ['posts', 'tags'].includes (resource))
|
||||
{
|
||||
const selected = new Set (params.get ('columns')!.split (' '))
|
||||
params.set ('columns', columnsForCounts (resource).filter (key => selected.has (key))
|
||||
.join (' '))
|
||||
params.set ('columns', columnsForCounts (resource, definition)
|
||||
.filter (key => selected.has (key)).join (' '))
|
||||
}
|
||||
if (params.has ('sort'))
|
||||
params.set ('sort', params.get ('sort')!.split (' ')
|
||||
@@ -374,7 +403,8 @@ export const normaliseCountsParams = (
|
||||
{
|
||||
const expected = defaults?.params.find (entry => entry.key === 'rows')?.value
|
||||
const axes = readCountState<CountsAxis[]> (params, 'rows', [])
|
||||
const first = expected == null ? null : readCountState<CountsAxis[]> (new URLSearchParams ({ rows: expected }), 'rows', [])[0]
|
||||
const first = expected == null ? null : readCountState<CountsAxis[]> (
|
||||
new URLSearchParams ({ rows: expected }), 'rows', [])[0]
|
||||
if (!Array.isArray (axes) || axes[0]?.field !== first?.field
|
||||
|| (axes[0]?.grain ?? 'month') !== (first?.grain ?? 'month')) continue
|
||||
}
|
||||
|
||||
@@ -25,10 +25,12 @@ export type CountsFieldMetadata = {
|
||||
key: string; type: CountsFieldType; nullable: boolean; operators: string[]
|
||||
labels?: string[]; multiple?: boolean; candidateSource?: 'categories' | 'tags'
|
||||
setType?: 'tag_reference'; inputTypes?: string[]
|
||||
labelValues?: Record<string, string>
|
||||
choices?: { value: string; label: string }[] }
|
||||
export type CountsDefinition = {
|
||||
fields: CountsFieldMetadata[]
|
||||
outputFields?: CountField[]
|
||||
nameColumns?: CountField[]
|
||||
filterFields?: CountsFieldMetadata[]
|
||||
defaults?: { definition: string; mode: string; params: { key: string; value: string }[] }[]
|
||||
axes: { latest: string[]; history: string[] }
|
||||
@@ -44,11 +46,14 @@ export const useCountsDefinition = (): CountsDefinition => {
|
||||
}
|
||||
|
||||
|
||||
export const fetchCountsDefinition = (resource: CountsResource): Promise<CountsDefinition> =>
|
||||
apiGet (`/counts/definitions/${encodeURIComponent (resource)}`)
|
||||
export const fetchCountsDefinition = (
|
||||
resource: CountsResource, locale: LocaleCode = 'ja',
|
||||
): Promise<CountsDefinition> => apiGet (
|
||||
`/counts/definitions/${encodeURIComponent (resource)}`, { locale })
|
||||
|
||||
export type CountsCandidate = {
|
||||
value: string; id?: number; name: string | null; category?: string | null
|
||||
names?: Record<string, unknown>
|
||||
kind?: 'internal' | 'external' }
|
||||
export type CountsCandidatesResult = {
|
||||
rows: CountsCandidate[]; page: number; more: boolean; executionAt: string }
|
||||
|
||||
@@ -12,6 +12,10 @@ import type { CountsAxis, CountsDefinition } from '@/lib/countsDefinitions'
|
||||
import type { LocaleCode } from '@/lib/locales'
|
||||
|
||||
|
||||
export const countsFieldValue = (row: CountRecord, field: string): unknown =>
|
||||
row[field] ?? row[field.replace (/_([a-z])/g, (_, letter: string) => letter.toUpperCase ())]
|
||||
|
||||
|
||||
export const countsCellText = (
|
||||
row: CountsVersion,
|
||||
column: CountColumn,
|
||||
@@ -85,8 +89,8 @@ export const countsCSVCell = (
|
||||
params: URLSearchParams,
|
||||
locale: LocaleCode,
|
||||
): string => {
|
||||
const field = fieldsForCounts (resource).find (field => field.key === column)!
|
||||
if (field.children != null)
|
||||
const field = fieldsForCounts (resource).find (field => field.key === column)
|
||||
if (field?.children != null)
|
||||
{
|
||||
const rows = countsNestedRows (row, column)
|
||||
return JSON.stringify (countsNestedExport (rows, field.children, column, params, locale))
|
||||
@@ -117,7 +121,7 @@ export const countsLogicalName = (
|
||||
field: string,
|
||||
locale: LocaleCode,
|
||||
): { text: string; language?: string } => {
|
||||
const value = row[field]
|
||||
const value = countsFieldValue (row, field)
|
||||
if (value == null) return { text: '' }
|
||||
if (field === 'sections')
|
||||
return { text: (countsSectionRanges (value, locale) ?? []).join (
|
||||
@@ -158,10 +162,8 @@ export const countsNestedOrder = (
|
||||
rows: CountRecord[],
|
||||
path: string,
|
||||
params: URLSearchParams,
|
||||
locale: LocaleCode,
|
||||
): CountRecord[] => sortedCountChildren (rows, path, params, (row, key) =>
|
||||
['languageCode', 'scriptCode'].includes (key)
|
||||
? countsLogicalName (row, key, locale).text : row[key])
|
||||
_locale: LocaleCode,
|
||||
): CountRecord[] => sortedCountChildren (rows, path, params, countsFieldValue)
|
||||
|
||||
|
||||
export const countsNestedExport = (
|
||||
@@ -177,13 +179,11 @@ export const countsNestedExport = (
|
||||
if (columns.length === 0) return rows.map (() => ({ }))
|
||||
return countsNestedOrder (rows, path, params, locale).map (row =>
|
||||
Object.fromEntries (columns.map (field => [
|
||||
translateUIMessage (field.label, 'counts', locale).text,
|
||||
field.key,
|
||||
field.children
|
||||
? countsNestedExport (row[field.key] as CountRecord[] ?? [], field.children,
|
||||
`${path}.${field.key}`, params, locale)
|
||||
: (field.key === 'sections'
|
||||
? countsSectionRanges (row[field.key], locale)
|
||||
: countsLogicalName (row, field.key, locale).text)])))
|
||||
: countsFieldValue (row, field.key)])))
|
||||
}
|
||||
|
||||
|
||||
@@ -238,7 +238,8 @@ export const countsNestedText = (
|
||||
? countsNestedText (row[field.key] as CountRecord[] ?? [], field.children,
|
||||
`${path}.${field.key}`, params, locale)
|
||||
: countsLogicalName (row, field.key, locale).text
|
||||
return `${translateUIMessage (field.label, 'counts', locale).text}: ${value}`
|
||||
const label = translateUIMessage (field.label, 'counts', locale, field.labelValues).text
|
||||
return `${label}: ${value}`
|
||||
}).join (' | ')).join ('\n')
|
||||
}
|
||||
|
||||
@@ -288,7 +289,7 @@ export const countsExportLabels = (
|
||||
for (const field of fields)
|
||||
{
|
||||
labels[aliases[field.key] ?? field.key] = translateUIMessage (
|
||||
field.label, 'counts', locale).text
|
||||
field.label, 'counts', locale, field.labelValues).text
|
||||
if (field.children) visit (field.children)
|
||||
}
|
||||
}
|
||||
@@ -328,7 +329,8 @@ const clipboardColumns = (
|
||||
const selected = selectedCountChildren (params)
|
||||
return orderedCountFields (fields, params, path)
|
||||
.filter (field => selected.has (`${path}.${field.key}`))
|
||||
.map (field => ({ label: translateUIMessage (field.label, 'counts', locale).text,
|
||||
.map (field => ({ label: translateUIMessage (
|
||||
field.label, 'counts', locale, field.labelValues).text,
|
||||
children: (field.children == null
|
||||
|| countsNestedIsScalar (field.children, `${path}.${field.key}`, params))
|
||||
? undefined : clipboardColumns (
|
||||
@@ -366,7 +368,7 @@ export const countsDetailClipboardTable = (
|
||||
rowStart: number | null = null,
|
||||
): CountsClipboardTable => {
|
||||
const columns = fields.map (field => ({
|
||||
label: translateUIMessage (field.label, 'counts', locale).text,
|
||||
label: translateUIMessage (field.label, 'counts', locale, field.labelValues).text,
|
||||
children: (field.children == null
|
||||
|| countsNestedIsScalar (field.children, field.key, params))
|
||||
? undefined : clipboardColumns (
|
||||
|
||||
@@ -14,6 +14,7 @@ export const defaultCountWidths: Record<string, number> = {
|
||||
|
||||
type Layout = {
|
||||
width: (family: string, key: string) => number
|
||||
custom: (family: string, key: string) => boolean
|
||||
resize: (family: string, key: string, width: number) => void }
|
||||
export const CountsLayoutContext = createContext<Layout | null> (null)
|
||||
export const CountsTableScope = createContext ('')
|
||||
|
||||
@@ -172,7 +172,8 @@ export const theatresKeys = {
|
||||
['theatres', locale, id, 'programmes'] as const }
|
||||
|
||||
export const countsKeys = {
|
||||
definition: (resource: string) => ['counts', 'definition', resource] as const,
|
||||
definition: (resource: string, locale: LocaleCode = 'ja') =>
|
||||
['counts', 'definition', resource, locale] as const,
|
||||
candidates: (params: string, locale: LocaleCode) => ['counts', 'candidates', locale, params] as const,
|
||||
index: (resource: string, params: string, locale: LocaleCode) =>
|
||||
['counts', resource, locale, params] as const }
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"materials": "素材の集計・明細"
|
||||
},
|
||||
"target": "集計定義",
|
||||
"localeName": "{{locale}}名",
|
||||
"run": "集計実行",
|
||||
"conditions": "集計条件",
|
||||
"output": "出力項目",
|
||||
@@ -19,6 +20,8 @@
|
||||
"starting": "開始中",
|
||||
"queued": "待機中",
|
||||
"running": "生成中",
|
||||
"aggregating": "集計中",
|
||||
"writing": "出力中",
|
||||
"cancelling": "取消処理中",
|
||||
"cancelled": "取消済み",
|
||||
"ready": "ファイル生成完了・ダウンロード開始(保存完了は未確認)",
|
||||
|
||||
@@ -246,7 +246,7 @@ const CountsResourceContent = (
|
||||
const headers = mode === 'summary'
|
||||
? [...axes.map (axis => countsAxisLabel (axis, definition, locale).text),
|
||||
message ('count').text]
|
||||
: fields.map (field => message (field.label).text)
|
||||
: fields.map (field => message (field.label, field.labelValues).text)
|
||||
const payload = countsExportPayload (resource, params, format,
|
||||
fields.map (field => field.key), headers,
|
||||
{ ...countsExportLabels (resource, locale, definition),
|
||||
@@ -383,9 +383,10 @@ const CountsResourceContent = (
|
||||
|
||||
|
||||
const CountsResourcePage = ({ resource }: { resource: CountsResource }) => {
|
||||
const locale = useLocale ()
|
||||
const { data, isError: errorFlg } = useQuery ({
|
||||
queryKey: countsKeys.definition (resource),
|
||||
queryFn: () => fetchCountsDefinition (resource), staleTime: Infinity })
|
||||
queryKey: countsKeys.definition (resource, locale),
|
||||
queryFn: () => fetchCountsDefinition (resource, locale), staleTime: Infinity })
|
||||
return <CountsDefinitionContext.Provider value={data ?? null}>
|
||||
<CountsResourceContent resource={resource} definition={data} definitionError={errorFlg}/>
|
||||
</CountsDefinitionContext.Provider>
|
||||
|
||||
新しいイシューから参照
ユーザーをブロックする