このコミットが含まれているのは:
@@ -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
|
||||
|
||||
新しいイシューから参照
ユーザーをブロックする