このコミットが含まれているのは:
@@ -1,5 +1,20 @@
|
||||
# frontend/AGENTS.md
|
||||
|
||||
## Mandatory security and URL contracts
|
||||
|
||||
- URL クエリへの JSON 格納を禁止する。Base64・16進数等による迂回も禁止。
|
||||
単純な配列は半角スペース区切り、構造化条件は番号付きの個別キーで表す。
|
||||
未指定と明示的な空選択を区別し、自由入力を区切り文字で分割しない。
|
||||
Frontend / Backend で同じ文法・上限・検証を使い、新しい URL 状態の追加前に確認する。
|
||||
- 一度除票されたリソースの除票以前(除票版を含む)の履歴は admin 以外へ公開しない。
|
||||
恢復しても解除しない。すべての後続除票を確定版番号と正しい複合キーで判定する。
|
||||
本文・属性・ID・版番号・関連参照・旧値を API / SSR / キャッシュ / 出力から漏らさない。
|
||||
サーバーが確定した current_user の role のみを認可に使用する。
|
||||
秘匿版も安全な全体件数には含めるが、公開明細・内訳・検索件数とは分離する。
|
||||
秘匿属性を条件・軸・ソート・差分件数から推測できる実装を禁止する。
|
||||
- 秘匿は React / CSS では実装しない。認可済み API のみを使う。
|
||||
認証・権限変更時は取得中リクエストと履歴キャッシュを破棄し、SSR の管理者データを共有しない。
|
||||
|
||||
## Scope
|
||||
|
||||
These rules apply to work under `frontend/`.
|
||||
|
||||
+14
-3
@@ -1,6 +1,6 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { AnimatePresence, LayoutGroup, MotionConfig, motion } from 'framer-motion'
|
||||
import { createElement, Suspense, lazy, useEffect,
|
||||
import { createElement, Suspense, lazy, useCallback, useEffect, useRef,
|
||||
useLayoutEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { createBrowserRouter,
|
||||
@@ -267,8 +267,19 @@ const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => {
|
||||
? resolve ({ location, params: route.params, locale, targetLocale })
|
||||
: switchLocalePath (location, targetLocale)
|
||||
}
|
||||
const [user, setUser] = useState<User | null> (
|
||||
const [user, setUserState] = useState<User | null> (
|
||||
() => client.getQueryData<User> (sessionKeys.current) ?? null)
|
||||
const authority = useRef (user)
|
||||
const setUser = useCallback<Dispatch<SetStateAction<User | null>>> (action => {
|
||||
const next = typeof action === 'function' ? action (authority.current) : action
|
||||
if (next?.id !== authority.current?.id || next?.role !== authority.current?.role)
|
||||
{
|
||||
client.cancelQueries ()
|
||||
client.removeQueries ({ predicate: query => query.queryKey[0] !== 'session' })
|
||||
}
|
||||
authority.current = next
|
||||
setUserState (next)
|
||||
}, [client])
|
||||
const [status, setStatus] = useState (200)
|
||||
const behaviourSettings = useClientBehaviourSettings ()
|
||||
const animationMode = behaviourSettings.animation ?? 'normal'
|
||||
@@ -317,7 +328,7 @@ const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => {
|
||||
}
|
||||
}) ()
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
}, [setUser])
|
||||
|
||||
useEffect (() => {
|
||||
if (!(user))
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { DndContext, DragOverlay, KeyboardSensor, MouseSensor, TouchSensor,
|
||||
closestCenter, useSensor, useSensors } from '@dnd-kit/core'
|
||||
import { SortableContext, arrayMove, horizontalListSortingStrategy,
|
||||
sortableKeyboardCoordinates } from '@dnd-kit/sortable'
|
||||
import { useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
import { useClientMotion } from '@/lib/useClientMotion'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
|
||||
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 motion = useClientMotion ()
|
||||
const sensors = useSensors (
|
||||
useSensor (MouseSensor, { activationConstraint: { distance: 4 } }),
|
||||
useSensor (TouchSensor, { activationConstraint: { delay: 350, tolerance: 8 } }),
|
||||
useSensor (KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }))
|
||||
const move = (current: string[], from: string, to: string) => {
|
||||
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)
|
||||
}
|
||||
const reset = () => {
|
||||
latest.current = null
|
||||
lastOver.current = null
|
||||
setPreview (null)
|
||||
setActive (null)
|
||||
}
|
||||
return <DndContext sensors={sensors} collisionDetection={closestCenter}
|
||||
accessibility={{ announcements: {
|
||||
onDragStart: event => name (String (event.active.id)).text,
|
||||
onDragOver: event => event.over == null ? undefined : name (String (event.over.id)).text,
|
||||
onDragMove: () => undefined, onDragEnd: () => undefined,
|
||||
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)
|
||||
}} 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))
|
||||
}
|
||||
reset ()
|
||||
}}>
|
||||
<SortableContext items={preview ?? ids} strategy={horizontalListSortingStrategy}>
|
||||
{children (preview ?? ids)}
|
||||
</SortableContext>
|
||||
{typeof document !== 'undefined' && createPortal (<DragOverlay
|
||||
dropAnimation={motion.reduced ? null : {
|
||||
duration: motion.duration ('drop'), easing: motion.easing }}>
|
||||
{active != null && <div lang={name (active).language}
|
||||
className="rounded border border-blue-500 bg-white p-2 text-slate-900 shadow-lg
|
||||
dark:border-blue-400 dark:bg-slate-800 dark:text-slate-100">{name (active).text}</div>}
|
||||
</DragOverlay>, document.body)}
|
||||
</DndContext>
|
||||
}
|
||||
|
||||
export default SortableColumns
|
||||
@@ -4,30 +4,28 @@ import { CSS } from '@dnd-kit/utilities'
|
||||
import { useClientMotion } from '@/lib/useClientMotion'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import type { CSSProperties, ReactNode } from 'react'
|
||||
|
||||
type Name = { text: string; language: string }
|
||||
|
||||
|
||||
const SortableItem = ({ id, name, fixed, children, moveGroup, handleId }: {
|
||||
const SortableItem = ({ id, name, fixed, children, moveGroup, handleId, renderShell }: {
|
||||
id: string; name: Name; fixed?: boolean; children: ReactNode
|
||||
moveGroup: (direction: number) => void; handleId: string },
|
||||
moveGroup: (direction: number) => void; handleId: string
|
||||
renderShell?: (shell: { ref: (node: HTMLElement | null) => void
|
||||
style: CSSProperties; handle: ReactNode; dragging: boolean }) => ReactNode },
|
||||
) => {
|
||||
const motion = useClientMotion ()
|
||||
const item = useSortable ({ id, disabled: fixed,
|
||||
transition: { duration: motion.duration ('reorder'), easing: motion.easing },
|
||||
animateLayoutChanges: options => !motion.reduced
|
||||
&& defaultAnimateLayoutChanges ({ ...options, wasDragging: true }) })
|
||||
return <div ref={item.setNodeRef}
|
||||
style={{ transform: CSS.Transform.toString (item.transform), transition: item.transition }}
|
||||
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.isDragging && 'opacity-30 ring-2 ring-blue-500 dark:ring-blue-400')}>
|
||||
<div className="flex min-w-0 items-start gap-1 px-1">
|
||||
{fixed ? <span className="w-6 shrink-0"/> : <button type="button"
|
||||
const handle = fixed ? <span className="w-6 shrink-0"/> : <button type="button"
|
||||
id={handleId} ref={item.setActivatorNodeRef} {...item.attributes} {...item.listeners}
|
||||
aria-label={name.text} lang={name.language}
|
||||
aria-keyshortcuts="Space ArrowUp ArrowDown Enter Escape Alt+ArrowLeft Alt+ArrowRight"
|
||||
aria-keyshortcuts={renderShell == null
|
||||
? 'Space ArrowUp ArrowDown Enter Escape Alt+ArrowLeft Alt+ArrowRight'
|
||||
: 'Space ArrowLeft ArrowRight Enter Escape'}
|
||||
className="mt-1 w-6 shrink-0 cursor-grab touch-none rounded
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500
|
||||
active:cursor-grabbing dark:focus-visible:ring-blue-400"
|
||||
@@ -41,7 +39,16 @@ const SortableItem = ({ id, name, fixed, children, moveGroup, handleId }: {
|
||||
moveGroup (event.key === 'ArrowLeft' ? -1 : 1)
|
||||
}
|
||||
else item.listeners?.onKeyDown?.(event)
|
||||
}}><span aria-hidden>⠿</span></button>}
|
||||
}}><span aria-hidden>⠿</span></button>
|
||||
const style = { transform: CSS.Transform.toString (item.transform), transition: item.transition }
|
||||
if (renderShell != null)
|
||||
return renderShell ({ ref: item.setNodeRef, style, handle, dragging: item.isDragging })
|
||||
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.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}
|
||||
<div className="min-w-0 flex-1 py-1">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,12 +3,14 @@ import { useEffect, useState } from 'react'
|
||||
import DateTimeField from '@/components/common/DateTimeField'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { countsEventTypes, countsFieldLabel, countsFilterFields,
|
||||
readCountsJSON, useCountsDefinition }
|
||||
useCountsDefinition }
|
||||
from '@/lib/countsDefinitions'
|
||||
import useDialogue from '@/lib/dialogues/useDialogue'
|
||||
import { inputClass } from '@/lib/utils'
|
||||
import { useUITranslation } from '@/lib/useUITranslation'
|
||||
|
||||
import { readCountState, encodeCountState } from '@/lib/queryState'
|
||||
|
||||
import type { DialogueFormControls } from '@/lib/dialogues/useDialogue'
|
||||
import type { CountsCondition, CountsDefinition } from '@/lib/countsDefinitions'
|
||||
|
||||
@@ -65,8 +67,8 @@ const CountsAdditionalConditions = ({ params, update }: {
|
||||
const { label, message } = useUITranslation ('counts')
|
||||
const definition = useCountsDefinition ()
|
||||
const dialogue = useDialogue ()
|
||||
const conditions = readCountsJSON<CountsCondition[]> (params, 'conditions', [])
|
||||
const write = (next: CountsCondition[]) => update ('conditions', JSON.stringify (next))
|
||||
const conditions = readCountState<CountsCondition[]> (params, 'conditions', [])
|
||||
const write = (next: CountsCondition[]) => update ('conditions', encodeCountState ('conditions', next))
|
||||
const edit = (index?: number) => {
|
||||
const initial = index == null ? undefined : conditions[index]
|
||||
const save = (condition: CountsCondition) => {
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import LocalisedText from '@/components/LocalisedText'
|
||||
import SortableList from '@/components/common/SortableList'
|
||||
import { countsAxisFields, countsAxisLabel, countsFilterFields,
|
||||
readCountsJSON, useCountsDefinition }
|
||||
useCountsDefinition }
|
||||
from '@/lib/countsDefinitions'
|
||||
import { useClientMotion } from '@/lib/useClientMotion'
|
||||
import { cn, inputClass } from '@/lib/utils'
|
||||
import { useLocale } from '@/lib/useLocale'
|
||||
import { useUITranslation } from '@/lib/useUITranslation'
|
||||
|
||||
import { readCountState, encodeCountState } from '@/lib/queryState'
|
||||
|
||||
import type { CountsAxis } from '@/lib/countsDefinitions'
|
||||
|
||||
|
||||
@@ -19,8 +21,8 @@ const CountsAxes = ({ params, update }: {
|
||||
const locale = useLocale ()
|
||||
const motion = useClientMotion ()
|
||||
const available = countsAxisFields (definition, params.get ('definition') === 'history')
|
||||
const rows = readCountsJSON<CountsAxis[]> (params, 'rows', [])
|
||||
const cols = readCountsJSON<CountsAxis[]> (params, 'cols', [])
|
||||
const rows = readCountState<CountsAxis[]> (params, 'rows', [])
|
||||
const cols = readCountState<CountsAxis[]> (params, 'cols', [])
|
||||
const axes = [...rows, ...cols]
|
||||
const groups = {
|
||||
available: available.map (field => field.key).filter (id => !axes.some (
|
||||
@@ -33,7 +35,7 @@ const CountsAxes = ({ params, update }: {
|
||||
}
|
||||
const commit = (next: Record<string, string[]>) => {
|
||||
for (const group of ['rows', 'cols'] as const)
|
||||
update (group, JSON.stringify (next[group].map (field => axes.find (
|
||||
update (group, encodeCountState (group, next[group].map (field => axes.find (
|
||||
axis => axis.field === field) ?? { field,
|
||||
...(countsFilterFields (definition)[field] === 'datetime' ? { grain: 'month' } : { }) })))
|
||||
}
|
||||
@@ -73,7 +75,7 @@ const CountsAxes = ({ params, update }: {
|
||||
{countsFilterFields (definition)[id] === 'datetime' && <select
|
||||
className={inputClass (false, 'w-auto py-0')}
|
||||
value={axis?.grain ?? 'month'} aria-label={message ('grain').text}
|
||||
onChange={event => update (group, JSON.stringify (
|
||||
onChange={event => update (group, encodeCountState (group,
|
||||
(group === 'rows' ? rows : cols).map (item => item.field === id
|
||||
? { ...item, grain: event.target.value } : item)))}>
|
||||
{definition.grains.map (grain => <option key={grain} value={grain}
|
||||
|
||||
@@ -12,11 +12,13 @@ import { Button } from '@/components/ui/button'
|
||||
import { countsUseLatest, fieldsForCounts, normaliseCountsParams, readCountSort,
|
||||
selectedCountChildren, selectedCountColumns } from '@/lib/counts'
|
||||
import { countsNestedIsScalar } from '@/lib/countsDisplay'
|
||||
import { readCountsJSON, useCountsDefinition } from '@/lib/countsDefinitions'
|
||||
import { useCountsDefinition } from '@/lib/countsDefinitions'
|
||||
import { useClientMotion } from '@/lib/useClientMotion'
|
||||
import { inputClass } from '@/lib/utils'
|
||||
import { useUITranslation } from '@/lib/useUITranslation'
|
||||
|
||||
import { readCountState, setCountState } from '@/lib/queryState'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import type { CountsResource } from '@/lib/counts'
|
||||
@@ -59,7 +61,7 @@ const CountsControls = (
|
||||
const next = new URLSearchParams (previous)
|
||||
const columns = selectedCountColumns (resource, previous, definition)
|
||||
.filter (column => column !== 'thumbnail')
|
||||
next.set ('columns', (thumbnail ? ['thumbnail', ...columns] : columns).join (','))
|
||||
next.set ('columns', (thumbnail ? ['thumbnail', ...columns] : columns).join (' '))
|
||||
return next
|
||||
})
|
||||
}, [thumbnail, resource, definition])
|
||||
@@ -79,16 +81,16 @@ const CountsControls = (
|
||||
const update = (key: string, value: string) => setDraft (previous => {
|
||||
const next = new URLSearchParams (previous)
|
||||
if (['rows', 'cols'].includes (key) && next.get ('sort') !== '') next.delete ('sort')
|
||||
next.set (key, value)
|
||||
setCountState (next, key, value)
|
||||
if (key !== 'mode') return next
|
||||
const resolved = normaliseCountsParams (resource, next, definition)
|
||||
const axes = [...readCountsJSON<unknown[]> (resolved, 'rows', []),
|
||||
...readCountsJSON<unknown[]> (resolved, 'cols', [])]
|
||||
const axes = [...readCountState<unknown[]> (resolved, 'rows', []),
|
||||
...readCountState<unknown[]> (resolved, 'cols', [])]
|
||||
const allowed = value === 'summary'
|
||||
? ['count', ...axes.map ((_, index) => `a${index}`)]
|
||||
: fieldsForCounts (resource, definition).map (field => field.key)
|
||||
if (readCountSort (resolved.get ('sort')).some (sort => !allowed.includes (sort.key)
|
||||
&& !(value === 'summary' && /^measure_[0-9a-f]+$/.test (sort.key))))
|
||||
&& !(value === 'summary' && /^m[0-9]{1,2}$/.test (sort.key))))
|
||||
next.delete ('sort')
|
||||
return normaliseCountsParams (resource, next, definition)
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useId, useState } from 'react'
|
||||
|
||||
import AnimatedCollapse from '@/components/common/AnimatedCollapse'
|
||||
import SortableList from '@/components/common/SortableList'
|
||||
import { countChildPaths, fieldsForCounts, orderedCountFields,
|
||||
import { fieldsForCounts, orderedCountFields,
|
||||
selectedCountChildren, selectedCountColumns, selectedCountFields } from '@/lib/counts'
|
||||
import { useCountsDefinition } from '@/lib/countsDefinitions'
|
||||
import { useClientMotion } from '@/lib/useClientMotion'
|
||||
@@ -26,7 +26,8 @@ const FieldBranch = ({ field, path, enabled, selected, params, select, update }:
|
||||
return <>
|
||||
<div className="flex items-center gap-1">
|
||||
<label className="inline-flex items-center gap-1">
|
||||
<input type="checkbox" checked={checked} disabled={!enabled}
|
||||
<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>
|
||||
@@ -43,7 +44,7 @@ const FieldBranch = ({ field, path, enabled, selected, params, select, update }:
|
||||
{field.children != null && <AnimatedCollapse open={open} id={id}>
|
||||
<div className="ml-2 border-l pl-1 dark:border-slate-700">
|
||||
<FieldGroup fields={field.children} path={path} params={params}
|
||||
enabled={enabled && checked} selected={selected} select={select} update={update}/>
|
||||
enabled={enabled} selected={selected} select={select} update={update}/>
|
||||
</div>
|
||||
</AnimatedCollapse>}
|
||||
</>
|
||||
@@ -65,9 +66,9 @@ const FieldGroup = ({ fields, path, params, enabled, selected, select, update }:
|
||||
renderGroup={(_, content) => content}
|
||||
onChange={groups => {
|
||||
const current = fields.map (field => `${prefix}${field.key}`)
|
||||
const other = (params.get ('order') ?? '').split (',')
|
||||
const other = (params.get ('order') ?? '').split (' ')
|
||||
.filter (key => key && !current.includes (key))
|
||||
update ('order', [...other, ...groups.items.map (key => `${prefix}${key}`)].join (','))
|
||||
update ('order', [...other, ...groups.items.map (key => `${prefix}${key}`)].join (' '))
|
||||
}} renderItem={key => {
|
||||
const field = fields.find (field => field.key === key)!
|
||||
return <FieldBranch field={field} path={`${prefix}${key}`} enabled={enabled}
|
||||
@@ -101,13 +102,28 @@ const CountsFieldTree = (
|
||||
if (checked) next.add (root.key)
|
||||
else next.delete (root.key)
|
||||
update (selectionKey, fields.filter (field => next.has (field.key))
|
||||
.map (field => field.key).join (','))
|
||||
.map (field => field.key).join (' '))
|
||||
return
|
||||
}
|
||||
if (checked) selected.add (path)
|
||||
if (checked)
|
||||
{
|
||||
const parts = path.split ('.')
|
||||
for (let index = 1; index < parts.length; ++index)
|
||||
selected.add (parts.slice (0, index + 1).join ('.'))
|
||||
}
|
||||
else selected.delete (path)
|
||||
update ('children', countChildPaths (fields).filter (
|
||||
key => key.includes ('.') && selected.has (key)).join (','))
|
||||
const nextParams = new URLSearchParams (params)
|
||||
nextParams.set ('children', [...selected].filter (key => key.includes ('.')).join (' '))
|
||||
const children = selectedCountChildren (nextParams)
|
||||
update ('children', [...children].join (' '))
|
||||
const nextRoots = new Set (roots)
|
||||
const ancestor = fields.find (field => path.startsWith (`${rootPath (field.key)}.`))
|
||||
if (checked && ancestor != null) nextRoots.add (ancestor.key)
|
||||
for (const field of fields)
|
||||
if (field.children != null && !field.children.some (child =>
|
||||
children.has (`${rootPath (field.key)}.${child.key}`))) nextRoots.delete (field.key)
|
||||
update (selectionKey, fields.filter (field => nextRoots.has (field.key))
|
||||
.map (field => field.key).join (' '))
|
||||
}
|
||||
const visibleFields = parent === ''
|
||||
? fields.filter (field => field.key !== 'thumbnail') : fields
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import SortableColumns from '@/components/common/SortableColumns'
|
||||
import LocalisedText from '@/components/LocalisedText'
|
||||
import PrefetchLink from '@/components/PrefetchLink'
|
||||
import CountsRowNumber from '@/components/counts/CountsRowNumber'
|
||||
@@ -33,7 +34,6 @@ const CountsResults = (
|
||||
const dialogue = useDialogue ()
|
||||
const columns = selectedCountColumns (resource, params, definition)
|
||||
const rowNumbers = params.get ('row_numbers') === '1'
|
||||
const tableColumns = rowNumbers ? ['row_number', ...columns] : columns
|
||||
const fields = fieldsForCounts (resource, definition)
|
||||
const sorts = readCountSort (params.get ('sort') ?? 'created_at:desc')
|
||||
const sort = (key: string, additive: boolean) =>
|
||||
@@ -104,7 +104,15 @@ const CountsResults = (
|
||||
default: return value
|
||||
}
|
||||
}
|
||||
return (
|
||||
return <SortableColumns ids={columns} fixed={['thumbnail']}
|
||||
name={key => message (fields.find (field => field.key === key)!.label)}
|
||||
onChange={next => {
|
||||
const all = fields.map (field => field.key)
|
||||
const other = (params.get ('order') ?? '').split (' ').filter (key => !all.includes (key))
|
||||
update ('order', [...next, ...all.filter (key => !next.includes (key)), ...other].join (' '))
|
||||
}}>{columns => {
|
||||
const tableColumns = rowNumbers ? ['row_number', ...columns] : columns
|
||||
return (
|
||||
<div className="min-h-0 flex-1 overflow-auto" data-counts-results aria-busy={pending}>
|
||||
{tableColumns.length === 0 ? <p>{label ('nested.noColumns')}</p> : (
|
||||
<CountsSelectableTable family={resource}
|
||||
@@ -112,7 +120,7 @@ const CountsResults = (
|
||||
copyTable={() => countsDetailClipboardTable (data.versions ?? [],
|
||||
columns.map (column => fields.find (field => field.key === column)!),
|
||||
params, locale, rowNumbers ? (data.page - 1) * data.limit + 1 : null)}
|
||||
identity={`${resource}:${countsRequestParams (params)}:${tableColumns.join (',')}:${
|
||||
identity={`${resource}:${countsRequestParams (params)}:${tableColumns.join (' ')}:${
|
||||
(data.versions ?? []).map (row => row.id)}`}
|
||||
className="text-left text-sm">
|
||||
<thead className="sticky top-0 z-20 bg-background text-foreground
|
||||
@@ -126,6 +134,7 @@ const CountsResults = (
|
||||
</th>
|
||||
: <CountsSortHeader key={column} field={column}
|
||||
label={message (fields.find (field => field.key === column)!.label)}
|
||||
dragId={column}
|
||||
sorts={sorts} change={sort} structured={fields.find (
|
||||
field => field.key === column)?.children != null}/>)}
|
||||
</tr></thead>
|
||||
@@ -148,6 +157,7 @@ const CountsResults = (
|
||||
{(data.versions?.length ?? 0) === 0
|
||||
&& <p className="p-2">{label ('empty')}</p>}
|
||||
</div>)
|
||||
}}</SortableColumns>
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -16,8 +16,29 @@ type Selection = { identity: string; anchor: Point; end: Point }
|
||||
const interactive = 'a,button,input,select,textarea,summary,[contenteditable],[data-counts-resize]'
|
||||
|
||||
|
||||
const cellGrid = (table: HTMLTableElement): HTMLTableCellElement[][] => {
|
||||
const grid: HTMLTableCellElement[][] = []
|
||||
for (const [row, tr] of Array.from (table.tBodies[0]?.rows ?? []).entries ())
|
||||
{
|
||||
grid[row] ??= []
|
||||
let column = 0
|
||||
for (const cell of Array.from (tr.cells))
|
||||
{
|
||||
while (grid[row][column] != null) ++column
|
||||
for (let y = 0; y < cell.rowSpan; ++y)
|
||||
{
|
||||
grid[row + y] ??= []
|
||||
for (let x = 0; x < cell.colSpan; ++x) grid[row + y][column + x] = cell
|
||||
}
|
||||
column += cell.colSpan
|
||||
}
|
||||
}
|
||||
return grid
|
||||
}
|
||||
|
||||
|
||||
const cellAt = (table: HTMLTableElement, point: Point) =>
|
||||
table.tBodies[0]?.rows[point.row]?.cells[point.column]
|
||||
cellGrid (table)[point.row]?.[point.column]
|
||||
|
||||
|
||||
const CountsSelectableTable = (
|
||||
@@ -66,31 +87,48 @@ const CountsSelectableTable = (
|
||||
}
|
||||
}, [])
|
||||
useLayoutEffect (() => {
|
||||
const rows = root.current?.tBodies[0]?.rows
|
||||
if (rows == null) return
|
||||
for (const [row, tr] of Array.from (rows).entries ())
|
||||
if (root.current == null) return
|
||||
const grid = cellGrid (root.current)
|
||||
const assigned = new Set<HTMLTableCellElement> ()
|
||||
for (const [row, cells] of grid.entries ())
|
||||
{
|
||||
for (const [column, cell] of Array.from (tr.cells).entries ())
|
||||
for (const [column, cell] of cells.entries ())
|
||||
{
|
||||
const selected = bounds != null && row >= bounds.top && row <= bounds.bottom
|
||||
&& column >= bounds.left && column <= bounds.right
|
||||
cell.id = `${id}-${row}-${column}`
|
||||
cell.setAttribute ('role', 'gridcell')
|
||||
cell.setAttribute ('aria-selected', String (selected))
|
||||
if (selected) cell.dataset.countsSelected = ''
|
||||
else delete cell.dataset.countsSelected
|
||||
if (assigned.has (cell)) continue
|
||||
assigned.add (cell)
|
||||
const selected = bounds != null && row <= bounds.bottom
|
||||
&& row + cell.rowSpan - 1 >= bounds.top && column <= bounds.right
|
||||
&& column + cell.colSpan - 1 >= bounds.left
|
||||
cell.id = `${id}-${row}-${column}`
|
||||
if (cell.getAttribute ('role') !== 'rowheader') cell.setAttribute ('role', 'gridcell')
|
||||
cell.setAttribute ('aria-selected', String (selected))
|
||||
if (selected) cell.dataset.countsSelected = ''
|
||||
else delete cell.dataset.countsSelected
|
||||
}
|
||||
}
|
||||
if (bounds != null)
|
||||
root.current.setAttribute ('aria-activedescendant', grid[bounds.bottom]?.[bounds.right]?.id ?? '')
|
||||
else root.current.removeAttribute ('aria-activedescendant')
|
||||
}, [bounds, children, id])
|
||||
const pointFor = (element: Element | null): Point | null => {
|
||||
const pointFor = (element: Element | null, y?: number): Point | null => {
|
||||
const table = root.current
|
||||
if (table == null || element == null || !table.contains (element)) return null
|
||||
let cell = element.closest ('td')
|
||||
while (cell != null && cell.closest ('table') !== table)
|
||||
cell = cell.closest ('table')?.parentElement?.closest ('td') ?? null
|
||||
if (cell == null || cell.parentElement?.parentElement !== table.tBodies[0]) return null
|
||||
return { row: (cell.parentElement as HTMLTableRowElement).sectionRowIndex,
|
||||
column: (cell as HTMLTableCellElement).cellIndex }
|
||||
const grid = cellGrid (table)
|
||||
const hitRow = y == null ? -1 : Array.from (table.tBodies[0].rows).findIndex (row => {
|
||||
const rect = row.getBoundingClientRect ()
|
||||
return y >= rect.top && y < rect.bottom
|
||||
})
|
||||
for (const [row, cells] of grid.entries ())
|
||||
{
|
||||
if (hitRow >= 0 && row !== hitRow) continue
|
||||
const column = cells.indexOf (cell as HTMLTableCellElement)
|
||||
if (column >= 0) return { row, column }
|
||||
}
|
||||
return null
|
||||
}
|
||||
const closeMenu = useCallback (() => {
|
||||
setMenu (null)
|
||||
@@ -181,8 +219,7 @@ const CountsSelectableTable = (
|
||||
<table ref={root} role="grid" tabIndex={0} aria-multiselectable="true"
|
||||
aria-label={name?.text ?? message ('selection.table').text}
|
||||
aria-describedby={`${id}-hint`}
|
||||
aria-activedescendant={selection == null ? undefined
|
||||
: `${id}-${selection.end.row}-${selection.end.column}`}
|
||||
|
||||
style={{ tableLayout: 'fixed', width: 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',
|
||||
@@ -213,7 +250,7 @@ const CountsSelectableTable = (
|
||||
if (event.clientX >= rect.left + element.clientLeft + element.clientWidth
|
||||
|| event.clientY >= rect.top + element.clientTop + element.clientHeight) return
|
||||
}
|
||||
const point = pointFor (element)
|
||||
const point = pointFor (element, event.clientY)
|
||||
if (point == null) return
|
||||
event.stopPropagation ()
|
||||
setMenu (null)
|
||||
@@ -246,7 +283,7 @@ const CountsSelectableTable = (
|
||||
event.clientX - touchPress.current.x, event.clientY - touchPress.current.y) > 8)
|
||||
finish ()
|
||||
if (!dragging.current || event.buttons === 0) return
|
||||
const point = pointFor (document.elementFromPoint (event.clientX, event.clientY))
|
||||
const point = pointFor (document.elementFromPoint (event.clientX, event.clientY), event.clientY)
|
||||
if (point == null) return
|
||||
if (touchArmed.current && touchPress.current != null
|
||||
&& Math.hypot (event.clientX - touchPress.current.x,
|
||||
@@ -263,7 +300,7 @@ const CountsSelectableTable = (
|
||||
onContextMenu={event => {
|
||||
const element = event.target as Element
|
||||
if (element.closest ('table') !== event.currentTarget) return
|
||||
const point = pointFor (element)
|
||||
const point = pointFor (element, event.clientY)
|
||||
if (point == null || element.closest ('[data-counts-resize]') != null) return
|
||||
event.preventDefault ()
|
||||
event.stopPropagation ()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import SortableItem from '@/components/common/SortableItem'
|
||||
import CountsResizeHandle from '@/components/counts/CountsResizeHandle'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useUITranslation } from '@/lib/useUITranslation'
|
||||
@@ -8,13 +9,14 @@ import type { CountSort } from '@/lib/counts'
|
||||
|
||||
|
||||
const CountsSortHeader = (
|
||||
{ field, label, sorts, change, structured = false, rowSpan, colSpan, resizable = true }: {
|
||||
{ field, label, sorts, change, structured = false, rowSpan, colSpan, resizable = true, dragId, totalKind }: {
|
||||
field: string
|
||||
label: { text: string; language: string }
|
||||
sorts: CountSort[]
|
||||
change: (key: string, additive: boolean) => void
|
||||
structured?: boolean
|
||||
rowSpan?: number; colSpan?: number; resizable?: boolean },
|
||||
rowSpan?: number; colSpan?: number; resizable?: boolean
|
||||
dragId?: string; totalKind?: string },
|
||||
) => {
|
||||
const { message } = useUITranslation ('counts')
|
||||
const press = useRef<{ x: number; y: number
|
||||
@@ -36,10 +38,14 @@ const CountsSortHeader = (
|
||||
column: label.text, priority: priority + 1,
|
||||
direction: message (`sort.${state}`).text })
|
||||
|
||||
return (
|
||||
<th data-counts-heading={label.text} rowSpan={rowSpan} colSpan={colSpan}
|
||||
const render = (shell?: Parameters<NonNullable<
|
||||
Parameters<typeof SortableItem>[0]['renderShell']>>[0]) => (
|
||||
<th ref={shell?.ref} style={shell?.style} data-counts-total={totalKind}
|
||||
data-counts-heading={label.text} rowSpan={rowSpan} colSpan={colSpan}
|
||||
className="relative overflow-hidden whitespace-nowrap px-1 py-1"
|
||||
aria-sort={(sorts.length > 1 && priority >= 0) ? 'other' : state}>
|
||||
<div className="flex min-w-0 items-center">
|
||||
{shell?.handle}
|
||||
<Button type="button" variant="ghost" size="sm"
|
||||
className="h-auto w-full max-w-full justify-start select-none px-1 py-0 pr-3"
|
||||
lang={aria.language} aria-label={aria.text} title={title.text}
|
||||
@@ -90,8 +96,11 @@ const CountsSortHeader = (
|
||||
{priority >= 0 && <span aria-hidden>
|
||||
{direction === 'asc' ? '▲' : '▼'}{sorts.length > 1 && priority + 1}</span>}
|
||||
</Button>
|
||||
</div>
|
||||
{resizable && <CountsResizeHandle field={field} label={label}/>}
|
||||
</th>)
|
||||
return dragId == null ? render () : <SortableItem id={dragId} name={message ('reorder', { column: label.text })}
|
||||
handleId={`column-${dragId}`} moveGroup={() => { }} renderShell={render}>{null}</SortableItem>
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,22 +1,27 @@
|
||||
import SortableColumns from '@/components/common/SortableColumns'
|
||||
import { countsSummaryValue } from '@/lib/countsDisplay'
|
||||
import PrefetchLink from '@/components/PrefetchLink'
|
||||
import CountsRowNumber from '@/components/counts/CountsRowNumber'
|
||||
import CountsSelectableTable from '@/components/counts/CountsSelectableTable'
|
||||
import CountsSortHeader from '@/components/counts/CountsSortHeader'
|
||||
import { changeCountSort, countsRequestParams, readCountSort, writeCountSort } from '@/lib/counts'
|
||||
import { countsAxisLabel, countsFieldLabel, readCountsJSON, useCountsDefinition }
|
||||
import { countsAxisLabel, useCountsDefinition }
|
||||
from '@/lib/countsDefinitions'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useLocale } from '@/lib/useLocale'
|
||||
import { useUITranslation } from '@/lib/useUITranslation'
|
||||
|
||||
import { queryRecordParams, setQueryRecords, readCountState, encodeCountState, setCountState } from '@/lib/queryState'
|
||||
|
||||
import type { CountsPivotHeader, CountsResource, CountsResult } from '@/lib/counts'
|
||||
import type { CountsAxis } from '@/lib/countsDefinitions'
|
||||
|
||||
|
||||
const CountsSummaryResults = (
|
||||
{ resource, data, params, pending, update }: {
|
||||
const CountsSummaryTable = (
|
||||
{ resource, data, params, pending, update, dragIds = {} }: {
|
||||
dragIds?: Record<string, string>
|
||||
resource: CountsResource; data: CountsResult; params: URLSearchParams
|
||||
pending: boolean; update: (key: string, value: string) => void },
|
||||
pending: boolean; update: (key: string, value: string, extra?: Record<string, string>) => void },
|
||||
) => {
|
||||
const definition = useCountsDefinition ()
|
||||
const locale = useLocale ()
|
||||
@@ -33,30 +38,38 @@ const CountsSummaryResults = (
|
||||
const sorts = readCountSort (params.get ('sort'))
|
||||
const change = (key: string, additive: boolean) => update (
|
||||
'sort', writeCountSort (changeCountSort (sorts, key, additive)))
|
||||
const display = (value: string | number | null, axis: CountsAxis, index: number): string => {
|
||||
if (value == null) return axis.field === 'user' ? message ('bot').text : ''
|
||||
const logical = data.axisLabels?.[`a${index}`]?.[String (value)]
|
||||
if (axis.field === 'user') return message ('actorWithId', {
|
||||
name: logical || message ('notRegistered').text, id: value }).text
|
||||
if (logical != null) return logical
|
||||
if (['tags', 'language_code', 'source_language_code'].includes (axis.field))
|
||||
return message ('notRegistered').text
|
||||
if (axis.field === 'changed_field') return message (countsFieldLabel (String (value))).text
|
||||
if (axis.field === 'event_type') return message (`events.${value}`).text
|
||||
if (axis.field === 'category' || axis.field === 'tag_category')
|
||||
return message (`common:categories.${value}`).text
|
||||
return String (value)
|
||||
}
|
||||
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' ? 'grandTotal' : 'subtotal').text
|
||||
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 names = (header: CountsPivotHeader, axes: CountsAxis[], offset: number) =>
|
||||
header.values.map ((value, index) => display (value, axes[index], offset + index))
|
||||
const columnKey = (header: CountsPivotHeader) => `${header.kind}:${JSON.stringify (header.values)}`
|
||||
const measureKey = (header: CountsPivotHeader) => header.values.length === 0 ? 'count'
|
||||
: `measure_${[...new TextEncoder ().encode (JSON.stringify (header.values))]
|
||||
.map (byte => byte.toString (16).padStart (2, '0')).join ('')}`
|
||||
const measures = readCountState<Record<string, string | number | null>[]> (
|
||||
params, 'measures', [])
|
||||
const measureKey = (header: CountsPivotHeader) => {
|
||||
if (header.values.length === 0) return 'count'
|
||||
const ids = [...new Set (measures.map (entry => String (entry.key)))]
|
||||
const existing = ids.find (id => JSON.stringify (measures.filter (entry => entry.key === id)
|
||||
.sort ((a, b) => Number (a.axis) - Number (b.axis)).map (entry => entry.value))
|
||||
=== JSON.stringify (header.values))
|
||||
return existing ?? `m${Array.from ({ length: 100 }, (_, index) => index)
|
||||
.find (index => !ids.includes (`m${index}`)) ?? 99}`
|
||||
}
|
||||
const changeMeasure = (header: CountsPivotHeader, additive: boolean) => {
|
||||
const key = measureKey (header)
|
||||
const next = new URLSearchParams (params)
|
||||
const nextSort = changeCountSort (sorts, key, additive)
|
||||
const active = new Set (nextSort.map (sort => sort.key))
|
||||
const records = measures.filter (record => record.key !== key && active.has (String (record.key)))
|
||||
setQueryRecords (next, 'measures', [...records, ...(active.has (key)
|
||||
? header.values.map ((value, axis) => ({ key, axis, value })) : [])])
|
||||
update ('sort', writeCountSort (nextSort),
|
||||
queryRecordParams (next, 'measures'))
|
||||
}
|
||||
const keys = [...(numbered ? ['row_number'] : []),
|
||||
...Array.from ({ length: leading }, (_, index) => `a${index}`),
|
||||
...columnHeaders.map (columnKey)]
|
||||
@@ -69,8 +82,8 @@ const CountsSummaryResults = (
|
||||
: totals.get (JSON.stringify ([row.values, column.values])) ?? 0
|
||||
const drill = (row: CountsPivotHeader, column: CountsPivotHeader) => {
|
||||
const next = new URLSearchParams (params)
|
||||
const existing = readCountsJSON<unknown[]> (params, 'drill', [])
|
||||
next.set ('drill', JSON.stringify ([...existing,
|
||||
const existing = readCountState<Record<string, string | number | null>[]> (params, 'drill', [])
|
||||
setCountState (next, 'drill', encodeCountState ('drill', [...existing,
|
||||
...row.values.map ((value, index) => ({ ...rows[index], value })),
|
||||
...column.values.map ((value, index) => ({ ...cols[index], value }))]))
|
||||
next.set ('mode', 'details')
|
||||
@@ -86,6 +99,25 @@ const CountsSummaryResults = (
|
||||
return [...(numbered ? [number] : []),
|
||||
...Array.from ({ length: leading }, (_, index) => labels[index] ?? '')]
|
||||
})
|
||||
const spans = rowHeaders.map ((row, index) => rows.map ((_, axis) => {
|
||||
if (row.kind !== 'data') return 1
|
||||
const prefix = JSON.stringify (row.values.slice (0, axis + 1))
|
||||
const previous = rowHeaders[index - 1]
|
||||
if (previous?.kind === 'data'
|
||||
&& 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'
|
||||
|| JSON.stringify (next.values.slice (0, axis + 1)) !== prefix) break
|
||||
++span
|
||||
}
|
||||
return span
|
||||
}))
|
||||
rowLabels.forEach ((labels, row) => spans[row].forEach ((span, column) => {
|
||||
if (span === 0) labels[column + (numbered ? 1 : 0)] = ''
|
||||
}))
|
||||
// This unmerged matrix is also the single source for TSV and HTML clipboard headings.
|
||||
const headingRows = Array.from ({ length: levels }, (_, level) => [
|
||||
...(numbered ? [level === 0 ? message ('rowNumberHeading').text : ''] : []),
|
||||
@@ -134,9 +166,13 @@ const CountsSummaryResults = (
|
||||
const field = leaf ? columnKey (header) : sortKey
|
||||
output.push (<CountsSortHeader key={`${level}:${field}:${index}`} field={field}
|
||||
colSpan={span} rowSpan={aggregate ? levels - level : 1} resizable={leaf}
|
||||
totalKind={header.kind !== 'data' ? header.kind : undefined}
|
||||
dragId={level === 0 && header.values.length > 0
|
||||
? dragIds[JSON.stringify (header.values.slice (0, 1))] : undefined}
|
||||
label={{ text, language: message ('count').language }}
|
||||
sorts={sorts.map (sort => sort.key === sortKey ? { ...sort, key: field } : sort)}
|
||||
change={(_, additive) => change (sortKey, additive)}/>)
|
||||
change={(_, additive) => leaf ? changeMeasure (header, additive)
|
||||
: change (sortKey, additive)}/>)
|
||||
index += span - 1
|
||||
}
|
||||
return output
|
||||
@@ -161,14 +197,21 @@ const CountsSummaryResults = (
|
||||
</tr>)}
|
||||
</thead>
|
||||
<tbody>{rowHeaders.map ((row, index) => <tr key={columnKey (row)}>
|
||||
{rowLabels[index].map ((value, offset) => <td key={offset} data-counts-copy={value}
|
||||
data-counts-aggregate={row.kind !== 'data' ? '' : undefined}
|
||||
className={cn ('overflow-hidden break-words border px-2 py-1 dark:border-slate-700',
|
||||
row.kind !== 'data' && 'bg-slate-200 text-slate-950 font-semibold',
|
||||
row.kind !== 'data' && 'dark:bg-slate-700 dark:text-slate-50',
|
||||
numbered && offset === 0 && 'text-right tabular-nums')}>{value}</td>)}
|
||||
{rowLabels[index].map ((value, offset) => {
|
||||
const axis = offset - (numbered ? 1 : 0)
|
||||
const span = axis < 0 ? 1 : spans[index][axis] ?? 1
|
||||
return span === 0 ? null : <td key={offset} rowSpan={span}
|
||||
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}
|
||||
className={cn ('overflow-hidden break-words border px-2 py-1 dark:border-slate-700',
|
||||
numbered && offset === 0 && 'text-right tabular-nums')}>{value}</td>
|
||||
})}
|
||||
{columnHeaders.map (column => <td key={columnKey (column)}
|
||||
data-counts-copy={String (count (row, column))}
|
||||
data-counts-total={(row.kind === 'grand_total' || column.kind === 'grand_total')
|
||||
? 'grand_total' : ((row.kind === 'subtotal' || column.kind === 'subtotal')
|
||||
? 'subtotal' : undefined)}
|
||||
data-counts-aggregate={(row.kind !== 'data' || column.kind !== 'data')
|
||||
? '' : undefined}
|
||||
className={cn ('border px-2 py-1 dark:border-slate-700',
|
||||
@@ -183,4 +226,30 @@ const CountsSummaryResults = (
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
const CountsSummaryResults = (props: Parameters<typeof CountsSummaryTable>[0]) => {
|
||||
const locale = useLocale ()
|
||||
const { message } = useUITranslation ('counts')
|
||||
const headers = props.data.columnHeaders ?? []
|
||||
const columns = props.data.cols ?? []
|
||||
if (columns.length === 0) return <CountsSummaryTable {...props}/>
|
||||
const groups = [...new Set (headers.filter (header => header.values.length > 0)
|
||||
.map (header => JSON.stringify (header.values.slice (0, 1))))]
|
||||
const ids = groups.map ((_, index) => `group${index}`)
|
||||
const dragIds = Object.fromEntries (groups.map ((group, index) => [group, ids[index]]))
|
||||
return <SortableColumns ids={ids} name={id => ({
|
||||
text: countsSummaryValue (JSON.parse (groups[ids.indexOf (id)])[0], columns[0],
|
||||
props.data.rows?.length ?? 0, props.data, locale), language: message ('count').language })}
|
||||
onChange={order => props.update ('column_order', encodeCountState ('column_order',
|
||||
order.map (id => ({ value: JSON.parse (groups[ids.indexOf (id)])[0] }))))}>
|
||||
{order => {
|
||||
const position = (header: CountsPivotHeader) => header.values.length === 0 ? order.length
|
||||
: order.indexOf (dragIds[JSON.stringify (header.values.slice (0, 1))])
|
||||
const sorted = [...headers].sort ((a, b) => position (a) - position (b))
|
||||
return <CountsSummaryTable {...props} data={{ ...props.data, columnHeaders: sorted }}
|
||||
dragIds={dragIds}/>
|
||||
}}
|
||||
</SortableColumns>
|
||||
}
|
||||
|
||||
export default CountsSummaryResults
|
||||
|
||||
@@ -54,3 +54,26 @@
|
||||
.counts-nested {
|
||||
@apply border-l-2 border-slate-300 dark:border-slate-600;
|
||||
}
|
||||
|
||||
.counts-grid > tbody > tr > td[data-counts-total='subtotal']:not([data-counts-selected]),
|
||||
.counts-grid > thead > tr > th[data-counts-total='subtotal'] {
|
||||
background-color: #e2e8f0;
|
||||
color: #0f172a;
|
||||
font-weight: 600;
|
||||
}
|
||||
.counts-grid > tbody > tr > td[data-counts-total='grand_total']:not([data-counts-selected]),
|
||||
.counts-grid > thead > tr > th[data-counts-total='grand_total'] {
|
||||
background-color: #cbd5e1;
|
||||
color: #0f172a;
|
||||
font-weight: 700;
|
||||
}
|
||||
.dark .counts-grid > tbody > tr > td[data-counts-total='subtotal']:not([data-counts-selected]),
|
||||
.dark .counts-grid > thead > tr > th[data-counts-total='subtotal'] {
|
||||
background-color: #334155;
|
||||
color: #f1f5f9;
|
||||
}
|
||||
.dark .counts-grid > tbody > tr > td[data-counts-total='grand_total']:not([data-counts-selected]),
|
||||
.dark .counts-grid > thead > tr > th[data-counts-total='grand_total'] {
|
||||
background-color: #475569;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
+54
-24
@@ -1,6 +1,8 @@
|
||||
import { countsFieldLabel, readCountsJSON } from '@/lib/countsDefinitions'
|
||||
import { countsFieldLabel } from '@/lib/countsDefinitions'
|
||||
import { apiDownload, apiGet } from '@/lib/api'
|
||||
|
||||
import { readCountState, encodeCountState } from '@/lib/queryState'
|
||||
|
||||
import type { CountsAxis, CountsDefinition } from '@/lib/countsDefinitions'
|
||||
import type { LocaleCode } from '@/lib/locales'
|
||||
import type { TagNameSnapshot } from '@/types'
|
||||
@@ -58,6 +60,8 @@ export type CountsStateTotals = {
|
||||
export type CountsPivotHeader = { values: (string | number | null)[]
|
||||
kind: 'data' | 'subtotal' | 'grand_total' }
|
||||
export type CountsResult = {
|
||||
populationCount?: number
|
||||
populationCountScope?: string
|
||||
rowHeaders?: CountsPivotHeader[]
|
||||
columnHeaders?: CountsPivotHeader[]
|
||||
pivotTotals?: { row: (string | number | null)[]; column: (string | number | null)[]
|
||||
@@ -95,7 +99,7 @@ export const countColumns: Record<string, string> = {
|
||||
parents: 'shared:labels.parentPosts', video_ms: 'shared:labels.duration',
|
||||
original_created_from: 'counts:columns.originalFrom',
|
||||
original_created_before: 'counts:columns.originalBefore',
|
||||
name: 'history:tagName', names: 'counts:columns.names',
|
||||
name: 'counts:columns.name', names: 'counts:columns.names',
|
||||
category: 'common:categoryLabel', aliases: 'tags:aliases',
|
||||
deprecated: 'counts:columns.deprecated', user: 'history:actor',
|
||||
created_at: 'counts:columns.changedAt' }
|
||||
@@ -165,7 +169,9 @@ export const fieldsForCounts = (
|
||||
key,
|
||||
label: (key === 'parents' && resource === 'tags') ? 'tags:parents'
|
||||
: (countColumns[key] ?? countsFieldLabel (key)),
|
||||
children: nested[key] }))
|
||||
children: key === 'parents' && resource === 'tags'
|
||||
? [...(nested.parents ?? []), { key: 'name', label: 'counts:columns.name' }]
|
||||
: nested[key] }))
|
||||
|
||||
|
||||
export const orderedCountFields = (
|
||||
@@ -174,7 +180,7 @@ export const orderedCountFields = (
|
||||
parent = '',
|
||||
): CountField[] => {
|
||||
const prefix = parent ? `${parent}.` : ''
|
||||
const saved = (params.get ('order') ?? '').split (',')
|
||||
const saved = (params.get ('order') ?? '').split (' ')
|
||||
const keys = [...new Set ([...saved, ...fields.map (field => `${prefix}${field.key}`)])]
|
||||
const ordered = keys.flatMap (key => {
|
||||
const field = fields.find (field => `${prefix}${field.key}` === key)
|
||||
@@ -201,8 +207,8 @@ export const reorderCountFields = (
|
||||
const current = ordered.map (field => `${prefix}${field.key}`)
|
||||
const next = [...current]
|
||||
next.splice (last, 0, ...next.splice (first, 1))
|
||||
return [...(params.get ('order') ?? '').split (',').filter (
|
||||
key => key && !current.includes (key)), ...next].join (',')
|
||||
return [...(params.get ('order') ?? '').split (' ').filter (
|
||||
key => key && !current.includes (key)), ...next].join (' ')
|
||||
}
|
||||
|
||||
|
||||
@@ -220,7 +226,7 @@ export const selectedCountColumns = (
|
||||
tag_descriptions: ['language_code', 'body', 'source_language_code'],
|
||||
tag_groups: ['name', 'description', 'members'],
|
||||
materials: ['url', 'source_kind', 'tag_id'] }
|
||||
const selected = resolved.has ('columns') ? resolved.get ('columns')!.split (',')
|
||||
const selected = resolved.has ('columns') ? resolved.get ('columns')!.split (' ')
|
||||
: [...common, ...defaults[resource]]
|
||||
return orderedCountFields (fieldsForCounts (resource, definition), resolved)
|
||||
.map (field => field.key as CountColumn).filter (key => selected.includes (key))
|
||||
@@ -234,13 +240,26 @@ export const countChildPaths = (fields: CountField[], prefix = ''): string[] =>
|
||||
})
|
||||
|
||||
|
||||
export const selectedCountChildren = (params: URLSearchParams): Set<string> =>
|
||||
new Set (params.has ('children') ? params.get ('children')!.split (',')
|
||||
: ['tags.name', 'parents.id', 'aliases.name', 'members.tagId'])
|
||||
export const selectedCountChildren = (params: URLSearchParams): Set<string> => {
|
||||
const selected = new Set (params.has ('children') ? params.get ('children')!.split (' ')
|
||||
: ['tags.name', 'parents.id', 'aliases.name', 'members.tagId'])
|
||||
const clean = (fields: CountField[], prefix: string) => {
|
||||
for (const field of fields)
|
||||
{
|
||||
const path = `${prefix}.${field.key}`
|
||||
if (field.children == null) continue
|
||||
clean (field.children, path)
|
||||
if (!field.children.some (child => selected.has (`${path}.${child.key}`)))
|
||||
selected.delete (path)
|
||||
}
|
||||
}
|
||||
for (const [path, fields] of Object.entries (nested)) clean (fields ?? [], path)
|
||||
return selected
|
||||
}
|
||||
|
||||
|
||||
export const readCountSort = (value: string | null): CountSort[] =>
|
||||
(value ?? '').split (',').flatMap (entry => {
|
||||
(value ?? '').split (' ').flatMap (entry => {
|
||||
const [key, direction] = entry.split (':')
|
||||
return (key && (direction === 'asc' || direction === 'desc'))
|
||||
? [{ key, direction }] : []
|
||||
@@ -248,7 +267,7 @@ export const readCountSort = (value: string | null): CountSort[] =>
|
||||
|
||||
|
||||
export const writeCountSort = (sort: CountSort[]): string =>
|
||||
sort.map (item => `${item.key}:${item.direction}`).join (',')
|
||||
sort.map (item => `${item.key}:${item.direction}`).join (' ')
|
||||
|
||||
|
||||
export const changeCountSort = (
|
||||
@@ -274,7 +293,7 @@ export const selectedCountFields = (
|
||||
parent = '',
|
||||
): CountField[] => {
|
||||
const selected = params.has (selectionKey)
|
||||
? new Set (params.get (selectionKey)!.split (',')) : null
|
||||
? new Set (params.get (selectionKey)!.split (' ')) : null
|
||||
return orderedCountFields (fields, params, parent)
|
||||
.filter (field => selected == null || selected.has (field.key))
|
||||
}
|
||||
@@ -300,7 +319,7 @@ export const normaliseCountsParams = (
|
||||
if (!params.has ('rows') && params.has ('axis'))
|
||||
{
|
||||
const field = params.get ('axis') === 'month' ? 'created_at' : params.get ('axis')!
|
||||
params.set ('rows', JSON.stringify ([{
|
||||
params.set ('rows', encodeCountState ('rows', [{
|
||||
field, ...(field === 'created_at' ? { grain: 'month' } : { }) }]))
|
||||
}
|
||||
if (params.has ('before') && !params.has ('through'))
|
||||
@@ -309,16 +328,16 @@ export const normaliseCountsParams = (
|
||||
params.delete (key)
|
||||
if (params.has ('columns') && ['posts', 'tags'].includes (resource))
|
||||
{
|
||||
const selected = new Set (params.get ('columns')!.split (','))
|
||||
const selected = new Set (params.get ('columns')!.split (' '))
|
||||
params.set ('columns', columnsForCounts (resource).filter (key => selected.has (key))
|
||||
.join (','))
|
||||
.join (' '))
|
||||
}
|
||||
if (params.has ('sort'))
|
||||
params.set ('sort', params.get ('sort')!.split (',')
|
||||
.filter (entry => entry.split (':')[0] !== 'original_created_at').join (','))
|
||||
params.set ('sort', params.get ('sort')!.split (' ')
|
||||
.filter (entry => entry.split (':')[0] !== 'original_created_at').join (' '))
|
||||
if (params.has ('order'))
|
||||
params.set ('order', params.get ('order')!.split (',')
|
||||
.filter (key => key !== 'original_created_at').join (','))
|
||||
params.set ('order', params.get ('order')!.split (' ')
|
||||
.filter (key => key !== 'original_created_at').join (' '))
|
||||
const defaults = definition?.defaults?.find (entry =>
|
||||
entry.definition === params.get ('definition') && entry.mode === params.get ('mode'))
|
||||
for (const { key, value } of defaults?.params ?? [])
|
||||
@@ -327,13 +346,24 @@ export const normaliseCountsParams = (
|
||||
if (key === 'sort')
|
||||
{
|
||||
const expected = defaults?.params.find (entry => entry.key === 'rows')?.value
|
||||
const axes = readCountsJSON<CountsAxis[]> (params, 'rows', [])
|
||||
const first = expected == null ? null : (JSON.parse (expected) as CountsAxis[])[0]
|
||||
const axes = readCountState<CountsAxis[]> (params, 'rows', [])
|
||||
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
|
||||
}
|
||||
params.set (key, value)
|
||||
}
|
||||
const children = selectedCountChildren (params)
|
||||
if (params.has ('children')) params.set ('children', [...children].filter (Boolean).join (' '))
|
||||
if (params.has ('columns'))
|
||||
{
|
||||
const fields = fieldsForCounts (resource, definition)
|
||||
params.set ('columns', params.get ('columns')!.split (' ').filter (key => {
|
||||
const field = fields.find (field => field.key === key)
|
||||
return field != null && (field.children == null
|
||||
|| field.children.some (child => children.has (`${key}.${child.key}`)))
|
||||
}).join (' '))
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
@@ -393,13 +423,13 @@ export const countsExportPayload = (
|
||||
{
|
||||
const selected = selectedCountChildren (params)
|
||||
payload.set ('children', countChildPaths (fieldsForCounts (resource, definition))
|
||||
.filter (path => selected.has (path)).join (','))
|
||||
.filter (path => selected.has (path)).join (' '))
|
||||
}
|
||||
payload.delete ('current')
|
||||
payload.delete ('row_numbers')
|
||||
if (isCountsState (params) && countsUseLatest (params)) payload.delete ('at')
|
||||
return { ...Object.fromEntries (payload), resource, format,
|
||||
columns: columns.join (','), headers: JSON.stringify (headers),
|
||||
columns: columns.join (' '), headers: JSON.stringify (headers),
|
||||
labels: JSON.stringify (labels) }
|
||||
}
|
||||
|
||||
|
||||
@@ -75,15 +75,3 @@ export const countsAxisFields = (
|
||||
history: boolean,
|
||||
): CountField[] => definition.axes[history ? 'history' : 'latest']
|
||||
.map (key => ({ key, label: countsFieldLabel (key) }))
|
||||
|
||||
|
||||
export const readCountsJSON = <T,> (params: URLSearchParams, key: string, fallback: T): T => {
|
||||
try
|
||||
{
|
||||
return JSON.parse (params.get (key) ?? JSON.stringify (fallback)) as T
|
||||
}
|
||||
catch
|
||||
{
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { fieldsForCounts, orderedCountFields, selectedCountChildren,
|
||||
sortedCountChildren } from '@/lib/counts'
|
||||
import { countsEventTypes } from '@/lib/countsDefinitions'
|
||||
import { countsEventTypes, countsFieldLabel } from '@/lib/countsDefinitions'
|
||||
import { translateUIMessage } from '@/lib/i18n'
|
||||
import { localisedDateString, msToTime } from '@/lib/utils'
|
||||
|
||||
import type { CountColumn, CountField, CountRecord,
|
||||
import type { CountColumn, CountField, CountRecord, CountsResult,
|
||||
CountsResource, CountsVersion } from '@/lib/counts'
|
||||
import type { CountsClipboardCell, CountsClipboardColumn, CountsClipboardTable }
|
||||
from '@/lib/countsClipboard'
|
||||
import type { CountsDefinition } from '@/lib/countsDefinitions'
|
||||
import type { CountsAxis, CountsDefinition } from '@/lib/countsDefinitions'
|
||||
import type { LocaleCode } from '@/lib/locales'
|
||||
|
||||
|
||||
@@ -308,6 +308,7 @@ export const countsExportLabels = (
|
||||
for (const event of countsEventTypes)
|
||||
labels[`event_type:${event}`] = translateUIMessage (`events.${event}`, 'counts', locale).text
|
||||
labels.subtotal = translateUIMessage ('subtotal', 'counts', locale).text
|
||||
labels.singleTotal = translateUIMessage ('singleTotal', 'counts', locale).text
|
||||
labels.grandTotal = translateUIMessage ('grandTotal', 'counts', locale).text
|
||||
labels.bot = translateUIMessage ('bot', 'counts', locale).text
|
||||
labels.actorFormat = translateUIMessage ('actorWithId', 'counts', locale, {
|
||||
@@ -384,3 +385,27 @@ export const countsDetailClipboardTable = (
|
||||
return rowStart == null ? cells : [String (rowStart + index), ...cells]
|
||||
}) }
|
||||
}
|
||||
|
||||
|
||||
export const countsSummaryValue = (
|
||||
value: string | number | null,
|
||||
axis: CountsAxis,
|
||||
index: number,
|
||||
data: CountsResult,
|
||||
locale: LocaleCode,
|
||||
): string => {
|
||||
const message = (key: string, values?: Record<string, string | number>) =>
|
||||
translateUIMessage (key, 'counts', locale, values).text
|
||||
if (value == null) return axis.field === 'user' ? message ('bot') : ''
|
||||
const logical = data.axisLabels?.[`a${index}`]?.[String (value)]
|
||||
if (axis.field === 'user') return message ('actorWithId', {
|
||||
name: logical || message ('notRegistered'), id: value })
|
||||
if (logical != null) return logical
|
||||
if (['tags', 'language_code', 'source_language_code'].includes (axis.field))
|
||||
return message ('notRegistered')
|
||||
if (axis.field === 'changed_field') return message (countsFieldLabel (String (value)))
|
||||
if (axis.field === 'event_type') return message (`events.${value}`)
|
||||
if (axis.field === 'category' || axis.field === 'tag_category')
|
||||
return message (`common:categories.${value}`)
|
||||
return String (value)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { apiDelete, apiGet, apiPost, apiPut } from '@/lib/api'
|
||||
import { queryRecordParams, setQueryRecords } from '@/lib/queryState'
|
||||
import { switchLocalePath } from '@/lib/locales'
|
||||
|
||||
import type { LocaleCode } from '@/lib/locales'
|
||||
@@ -17,7 +18,7 @@ export const fetchPosts = async (
|
||||
...(url && { url }),
|
||||
...(title && { title }),
|
||||
...(tags && { tags }),
|
||||
...(tagReferences && { tag_refs: tagReferences }),
|
||||
...(tagReferences && queryRecordParams (new URLSearchParams (tagReferences), 'tag_refs')),
|
||||
...(match && { match }),
|
||||
...(createdFrom && { created_from: createdFrom }),
|
||||
...(createdTo && { created_to: createdTo }),
|
||||
@@ -45,14 +46,14 @@ export const switchPostFilterLocale = async (
|
||||
negative?: boolean
|
||||
missing?: boolean }[] }> ('/posts/tag_filters', {
|
||||
locale, params: { tags, target_locale: targetLocale,
|
||||
tag_refs: query.get ('tag_refs') ?? undefined } })
|
||||
...queryRecordParams (query, 'tag_refs') } })
|
||||
query.set ('tags', references.map (reference => reference.name).join (' '))
|
||||
query.set ('tag_refs', JSON.stringify (references.map (reference => ({
|
||||
setQueryRecords (query, 'tag_refs', references.map (reference => ({
|
||||
name: reference.name,
|
||||
negative: reference.negative,
|
||||
...(reference.tagId != null && { tag_id: reference.tagId }),
|
||||
...(reference.externalTagId != null && { external_tag_id: reference.externalTagId }),
|
||||
...(reference.missing && { missing: true }) }))))
|
||||
...(reference.missing && { missing: true }) })))
|
||||
return switchLocalePath ({ ...location, search: `?${query}` }, targetLocale)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { queryRecordParams } from '@/lib/queryState'
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { match } from 'path-to-regexp'
|
||||
|
||||
@@ -175,8 +176,8 @@ const prefetchPostsIndex: Prefetcher = async (qc, url, locale) => {
|
||||
|
||||
const keys = {
|
||||
tags, match: m, page, limit, url: qURL, title,
|
||||
...(url.searchParams.get ('tag_refs') && {
|
||||
tagReferences: url.searchParams.get ('tag_refs')! }),
|
||||
...(url.searchParams.has ('tag_refs') && {
|
||||
tagReferences: new URLSearchParams (queryRecordParams (url.searchParams, 'tag_refs')).toString () }),
|
||||
originalCreatedFrom, originalCreatedTo, createdFrom, createdTo,
|
||||
updatedFrom, updatedTo, order }
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// Public URL grammar: space-delimited tokens and indexed scalar fields, never JSON.
|
||||
export type QueryRecord = Record<string, string | number | boolean | null | undefined>
|
||||
const schemas: Record<string, string[]> = {
|
||||
conditions: ['field', 'operator', 'value', 'end', 'blank'],
|
||||
drill: ['field', 'grain', 'value', 'value_type'],
|
||||
tag_refs: ['name', 'tag_id', 'external_tag_id', 'negative', 'missing'],
|
||||
column_order: ['value', 'value_type'],
|
||||
measures: ['key', 'axis', 'value', 'value_type'] }
|
||||
|
||||
|
||||
export const queryList = (value: string | null): string[] =>
|
||||
value == null || value === '' ? [] : value.split (' ')
|
||||
|
||||
|
||||
export const queryRecords = (params: URLSearchParams, key: string): QueryRecord[] => {
|
||||
const indexed = new Map<number, QueryRecord> ()
|
||||
for (const [name, value] of params)
|
||||
{
|
||||
if (!name.startsWith (`${key}.`)) continue
|
||||
const parts = name.slice (key.length + 1).split ('.')
|
||||
if (parts.length !== 2 || !/^\d{1,2}$/.test (parts[0])
|
||||
|| !schemas[key]?.includes (parts[1])) continue
|
||||
const index = Number (parts[0])
|
||||
const record = indexed.get (index) ?? { }
|
||||
const field = parts[1]
|
||||
record[field] = ['blank', 'negative', 'missing'].includes (field)
|
||||
? value === '1' : (['tag_id', 'external_tag_id', 'axis'].includes (field)
|
||||
? Number (value) : value)
|
||||
indexed.set (index, record)
|
||||
}
|
||||
return [...indexed].sort ((a, b) => a[0] - b[0]).map (([, record]) => {
|
||||
if (record.value_type === 'null') record.value = null
|
||||
if (record.value_type === 'number') record.value = Number (record.value)
|
||||
if (record.value_type === 'boolean') record.value = record.value === 'true'
|
||||
delete record.value_type
|
||||
return record
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
export const setQueryRecords = (
|
||||
params: URLSearchParams,
|
||||
key: string,
|
||||
records: QueryRecord[],
|
||||
): void => {
|
||||
for (const name of [...params.keys ()])
|
||||
if (name === key || name.startsWith (`${key}.`)) params.delete (name)
|
||||
params.set (key, '')
|
||||
records.forEach ((record, index) => {
|
||||
for (const [field, value] of Object.entries (record))
|
||||
{
|
||||
if (value == null && field !== 'value') continue
|
||||
if (!schemas[key]?.includes (field)) continue
|
||||
const name = `${key}.${index}.${field}`
|
||||
if (field === 'value' && ['drill', 'measures', 'column_order'].includes (key))
|
||||
params.set (`${key}.${index}.value_type`, value == null ? 'null' : typeof value)
|
||||
if (value == null) continue
|
||||
params.set (name, typeof value === 'boolean'
|
||||
? (['blank', 'negative', 'missing'].includes (field) ? (value ? '1' : '0') : String (value))
|
||||
: String (value))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
export const readCountState = <T,> (
|
||||
params: URLSearchParams,
|
||||
key: string,
|
||||
fallback: T,
|
||||
): T => {
|
||||
if (!params.has (key)) return fallback
|
||||
if (key === 'rows' || key === 'cols')
|
||||
return queryList (params.get (key)).map (token => {
|
||||
const [field, grain] = token.split (':')
|
||||
return grain == null ? { field } : { field, grain }
|
||||
}) as T
|
||||
return queryRecords (params, key) as T
|
||||
}
|
||||
|
||||
|
||||
export const encodeCountState = (key: string, records: QueryRecord[]): string => {
|
||||
if (key === 'rows' || key === 'cols')
|
||||
return records.map (record => record.grain == null
|
||||
? String (record.field) : `${record.field}:${record.grain}`).join (' ')
|
||||
const params = new URLSearchParams ()
|
||||
setQueryRecords (params, key, records)
|
||||
return params.toString ()
|
||||
}
|
||||
|
||||
|
||||
export const setCountState = (params: URLSearchParams, key: string, value: string): void => {
|
||||
if (schemas[key] != null)
|
||||
{
|
||||
const fields = new URLSearchParams (value)
|
||||
setQueryRecords (params, key, queryRecords (fields, key))
|
||||
}
|
||||
else params.set (key, value)
|
||||
}
|
||||
|
||||
|
||||
export const queryRecordParams = (
|
||||
params: URLSearchParams,
|
||||
key: string,
|
||||
): Record<string, string> => Object.fromEntries ([...params].filter (([name]) =>
|
||||
name === key || name.startsWith (`${key}.`)))
|
||||
@@ -43,6 +43,7 @@
|
||||
"columns": {
|
||||
"changedAt": "操作日時",
|
||||
"version": "版",
|
||||
"name": "名称",
|
||||
"resourceId": "Id.",
|
||||
"postTitle": "タイトル",
|
||||
"postURL": "URL",
|
||||
@@ -167,7 +168,7 @@
|
||||
"changedField": "変更項目",
|
||||
"eventType": "異動区分",
|
||||
"count": "件数",
|
||||
"recordTotal": "総件数:{{count}}",
|
||||
"recordTotal": "閲覧可能件数:{{count}}",
|
||||
"grain": "粒度",
|
||||
"grains": {
|
||||
"year": "年",
|
||||
@@ -235,6 +236,7 @@
|
||||
},
|
||||
"rowNumberHeading": "No.",
|
||||
"subtotal": "小計",
|
||||
"singleTotal": "合計",
|
||||
"grandTotal": "綜合計",
|
||||
"rowNumbers": "行番号",
|
||||
"periodSeparator": "〜",
|
||||
|
||||
@@ -20,7 +20,7 @@ import { countsRequestParams, defaultCountsMode, countsExportPayload, fieldsForC
|
||||
import { countsExportLabels } from '@/lib/countsDisplay'
|
||||
import { CountsDefinitionContext, countsAxisFields, countsAxisLabel,
|
||||
countsResources, countsFieldLabel,
|
||||
fetchCountsDefinition, readCountsJSON, useCountsDefinition }
|
||||
fetchCountsDefinition, useCountsDefinition }
|
||||
from '@/lib/countsDefinitions'
|
||||
import { countsKeys } from '@/lib/queryKeys'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -29,6 +29,8 @@ import useDialogue from '@/lib/dialogues/useDialogue'
|
||||
import { useLocale } from '@/lib/useLocale'
|
||||
import { useUITranslation } from '@/lib/useUITranslation'
|
||||
|
||||
import { setCountState, readCountState } from '@/lib/queryState'
|
||||
|
||||
import type { CountsAxis } from '@/lib/countsDefinitions'
|
||||
|
||||
import type { CountsResource } from '@/lib/counts'
|
||||
@@ -44,7 +46,7 @@ const CountsResourceContent = ({ resource }: { resource: CountsResource }) => {
|
||||
controlParams.delete ('order')
|
||||
controlParams.delete ('row_numbers')
|
||||
controlParams.set ('columns', selectedCountColumns (resource, params, definition)
|
||||
.filter (column => column !== 'thumbnail').sort ().join (','))
|
||||
.filter (column => column !== 'thumbnail').sort ().join (' '))
|
||||
const request = countsRequestParams (params)
|
||||
const [open, setOpen] = useState (false)
|
||||
const [sidebarOpen, setSidebarOpen] = useState (false)
|
||||
@@ -99,10 +101,17 @@ const CountsResourceContent = ({ resource }: { resource: CountsResource }) => {
|
||||
}, [canonical, data, isPlaceholderData, params, setParams])
|
||||
|
||||
|
||||
const update = (key: string, value: string) => {
|
||||
const update = (key: string, value: string, extra?: Record<string, string>) => {
|
||||
const next = new URLSearchParams (params)
|
||||
if (value || key === 'sort' || key === 'columns') next.set (key, value)
|
||||
if (value || ['sort', 'columns', 'children', 'conditions', 'drill'].includes (key))
|
||||
setCountState (next, key, value)
|
||||
else next.delete (key)
|
||||
if (extra != null)
|
||||
{
|
||||
for (const key of [...next.keys ()])
|
||||
if (key === 'measures' || key.startsWith ('measures.')) next.delete (key)
|
||||
for (const [key, value] of Object.entries (extra)) next.set (key, value)
|
||||
}
|
||||
if (key === 'sort') next.delete ('page')
|
||||
setParams (next)
|
||||
}
|
||||
@@ -113,8 +122,8 @@ const CountsResourceContent = ({ resource }: { resource: CountsResource }) => {
|
||||
setExportError (false)
|
||||
try
|
||||
{
|
||||
const axes = [...readCountsJSON<CountsAxis[]> (params, 'rows', []),
|
||||
...readCountsJSON<CountsAxis[]> (params, 'cols', [])]
|
||||
const axes = [...readCountState<CountsAxis[]> (params, 'rows', []),
|
||||
...readCountState<CountsAxis[]> (params, 'cols', [])]
|
||||
const fields = mode === 'summary' ? [
|
||||
...axes.map (axis => ({
|
||||
key: axis.field, label: countsFieldLabel (axis.field) })),
|
||||
@@ -212,7 +221,7 @@ const CountsResourceContent = ({ resource }: { resource: CountsResource }) => {
|
||||
displayThumbnail={enabled => {
|
||||
const columns = selectedCountColumns (resource, params, definition)
|
||||
.filter (column => column !== 'thumbnail')
|
||||
update ('columns', (enabled ? ['thumbnail', ...columns] : columns).join (','))
|
||||
update ('columns', (enabled ? ['thumbnail', ...columns] : columns).join (' '))
|
||||
}}
|
||||
execute={next => {
|
||||
if (next.toString () === canonical) void refetch ()
|
||||
@@ -231,7 +240,11 @@ const CountsResourceContent = ({ resource }: { resource: CountsResource }) => {
|
||||
{isPending && <p>{label ('shared:messages.loadingEllipsis')}</p>}
|
||||
{isError && <p role="alert">{label ('common:fetchError')}</p>}
|
||||
{data != null && <>
|
||||
<p className="my-1 text-xs">{label ('recordTotal', { count: data.count })}</p>
|
||||
<p className="my-1 flex flex-wrap gap-x-3 text-xs">
|
||||
<span>{label ('recordTotal', { count: data.count })}</span>
|
||||
{data.populationCount != null && <span>{label ('total', {
|
||||
count: data.populationCount })}</span>}
|
||||
</p>
|
||||
{mode === 'summary' ? <CountsSummaryResults resource={resource}
|
||||
data={data} params={params}
|
||||
pending={isPlaceholderData} update={update}/> : (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { queryRecordParams } from '@/lib/queryState'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
@@ -61,7 +62,7 @@ const PostListPage: FC = () => {
|
||||
|
||||
const keys = {
|
||||
tags: tagsKey, match, page, limit,
|
||||
...(query.get ('tag_refs') && { tagReferences: query.get ('tag_refs')! }),
|
||||
...(query.has ('tag_refs') && { tagReferences: new URLSearchParams (queryRecordParams (query, 'tag_refs')).toString () }),
|
||||
url: '', title: '', originalCreatedFrom: '', originalCreatedTo: '',
|
||||
createdFrom: '', createdTo: '', updatedFrom: '', updatedTo: '',
|
||||
order } as const
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { queryRecordParams } from '@/lib/queryState'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { motion } from 'framer-motion'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
@@ -126,7 +127,7 @@ const PostSearchPage: FC = () => {
|
||||
|
||||
const keys: FetchPostsParams = {
|
||||
tags: qTags, match: qMatch, page, limit,
|
||||
...(query.get ('tag_refs') && { tagReferences: query.get ('tag_refs')! }),
|
||||
...(query.has ('tag_refs') && { tagReferences: new URLSearchParams (queryRecordParams (query, 'tag_refs')).toString () }),
|
||||
url: qURL,
|
||||
title: qTitle,
|
||||
originalCreatedFrom: qOriginalCreatedFrom,
|
||||
|
||||
新しいイシューから参照
ユーザーをブロックする