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, 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 CountsSummaryTable = ( { resource, data, params, pending, update, dragIds = {} }: { dragIds?: Record resource: CountsResource; data: CountsResult; params: URLSearchParams pending: boolean; update: (key: string, value: string, extra?: Record) => void }, ) => { const definition = useCountsDefinition () const locale = useLocale () const { message } = useUITranslation ('counts') const numbered = params.get ('row_numbers') === '1' const rows = data.rows ?? [] const cols = data.cols ?? [] const cells = data.cells ?? [] const rowHeaders: CountsPivotHeader[] = data.rowHeaders ?? [...new Set ( cells.map (cell => JSON.stringify (cell.values.slice (0, rows.length))))] .map (key => ({ values: JSON.parse (key), kind: 'data' })) const columnHeaders: CountsPivotHeader[] = data.columnHeaders ?? (data.columnValues ?? [[]]).map (values => ({ values, kind: 'data' })) 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) => countsSummaryValue (value, axis, index, data, locale) const aggregateLabel = (header: CountsPivotHeader) => message ( header.labelKey ?? (header.kind === 'grand_total' ? (rows.length + cols.length === 1 ? 'singleTotal' : 'grandTotal') : 'subtotal')).text const leading = Math.max (rows.length, cols.length > 0 ? 1 : 0) const levels = Math.max (1, cols.length) const headingLevels = cols.length === 0 ? 1 : levels * 2 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 measures = readCountState[]> ( 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)] const full = new Map (cells.map (cell => [JSON.stringify (cell.values), cell.count])) const totals = new Map ((data.pivotTotals ?? []).map (cell => [ JSON.stringify ([cell.row, cell.column]), cell.count])) const totalLabels = new Map ((data.pivotTotals ?? []).map (cell => [ JSON.stringify ([cell.row, cell.column]), cell.labelKey])) const count = (row: CountsPivotHeader, column: CountsPivotHeader) => (row.values.length === rows.length && column.values.length === cols.length) ? full.get (JSON.stringify ([...row.values, ...column.values])) ?? 0 : totals.get (JSON.stringify ([row.values, column.values])) ?? 0 const drill = (row: CountsPivotHeader, column: CountsPivotHeader) => { const next = new URLSearchParams (params) const existing = readCountState[]> (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') next.delete ('sort') next.delete ('page') return `/counts/${resource}?${next}` } let ordinal = (data.page - 1) * data.limit const rowLabels = rowHeaders.map (header => { const labels = names (header, rows, 0) // 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] ?? '')] }) const spans = rowHeaders.map ((row, index) => rows.map ((_, axis) => { if (row.values.length <= axis) return 1 const prefix = JSON.stringify (row.values.slice (0, axis + 1)) const previous = rowHeaders[index - 1] if (previous != null && previous.values.length > axis && JSON.stringify (previous.values.slice (0, axis + 1)) === prefix) return 0 let span = 1 while (index + span < rowHeaders.length) { const next = rowHeaders[index + span] if (next.values.length <= axis || 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 valueHeadingRows = Array.from ({ length: levels }, (_, level) => [ ...(numbered ? [level === 0 ? message ('rowNumberHeading').text : ''] : []), ...Array.from ({ length: leading }, (_, index) => level === 0 && rows[index] != null ? countsAxisLabel (rows[index], definition, locale).text : ''), ...columnHeaders.map ((header, index) => { if (cols.length === 0) return message ('count').text if (level > header.values.length) return '' if (level === header.values.length) return header.kind === 'data' ? '' : aggregateLabel (header) const prefix = header.values.slice (0, level + 1) const previous = columnHeaders[index - 1] if (level < levels - 1 && previous != null && JSON.stringify (previous.values.slice (0, level + 1)) === JSON.stringify (prefix)) return '' return display (header.values[level], cols[level], rows.length + level) })]) const axisHeadingRows = cols.map ((axis, level) => [ ...(numbered ? [''] : []), ...Array.from ({ length: leading }, () => ''), ...columnHeaders.map ((header, index) => { if (header.values.length < level) return '' const previous = columnHeaders[index - 1] const prefix = JSON.stringify (header.values.slice (0, level)) return (previous != null && previous.values.length >= level && JSON.stringify (previous.values.slice (0, level)) === prefix) ? '' : countsAxisLabel (axis, definition, locale).text })]) const headingRows = cols.length === 0 ? valueHeadingRows : axisHeadingRows.flatMap ((headings, level) => [headings, valueHeadingRows[level]]) if (cols.length > 0) { for (let index = 0; index < leading + (numbered ? 1 : 0); ++index) { headingRows[0][index] = valueHeadingRows[0][index] headingRows[1][index] = '' } } const axisHeaderCells = (level: number) => { const output = [] for (let index = 0; index < columnHeaders.length; ++index) { const header = columnHeaders[index] if (header.values.length < level) continue const prefix = JSON.stringify (header.values.slice (0, level)) let span = 1 while (index + span < columnHeaders.length) { const sibling = columnHeaders[index + span] if (sibling.values.length < level || JSON.stringify (sibling.values.slice (0, level)) !== prefix) break ++span } const name = countsAxisLabel (cols[level], definition, locale) output.push ({name.text}) index += span - 1 } return output } const matrix = rowHeaders.map ((header, index) => [...rowLabels[index], ...columnHeaders.map (column => String (count (header, column)))]) const headerCells = (level: number) => { const output = [] for (let index = 0; index < columnHeaders.length; ++index) { const header = columnHeaders[index] if (cols.length > 0 && level > header.values.length) continue const aggregate = header.kind !== 'data' && level === header.values.length const leaf = level === levels - 1 || aggregate const text = cols.length === 0 ? message ('count').text : (aggregate ? aggregateLabel (header) : display (header.values[level], cols[level], rows.length + level)) let span = 1 if (!leaf) { const prefix = JSON.stringify (header.values.slice (0, level + 1)) while (index + span < columnHeaders.length) { const sibling = columnHeaders[index + span] if (sibling.values.length <= level || JSON.stringify (sibling.values.slice (0, level + 1)) !== prefix) break ++span } } const sortKey = leaf ? measureKey (header) : `a${rows.length + level}` const field = leaf ? columnKey (header) : sortKey output.push ( 0) ? (levels - level) * 2 - 1 : 1} 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) => leaf ? changeMeasure (header, additive) : change (sortKey, additive)}/>) index += span - 1 } return output } return
key !== 'row_number').length > 1} copyTable={() => ({ columns: keys.map ((_, index) => ({ label: headingRows[0][index] })), headerRows: headingRows, rows: matrix })} identity={`${countsRequestParams (params)}:${JSON.stringify (keys)}`} className="counts-summary text-left text-sm"> {headingRows.map ((_, tier) => {tier === 0 && <> {numbered && } {Array.from ({ length: leading }, (_, index) => rows[index] == null ? : )} } {(cols.length > 0 && tier % 2 === 0) ? axisHeaderCells (tier / 2) : headerCells (Math.floor (tier / 2))} )} {rowHeaders.map ((row, index) => {rowLabels[index].map ((value, offset) => { const axis = offset - (numbered ? 1 : 0) if (axis >= 0 && row.kind !== 'data' && axis > row.values.length) return null const aggregate = axis >= 0 && row.kind !== 'data' && axis === row.values.length const span = axis < 0 ? 1 : spans[index][axis] ?? 1 return span === 0 ? null : = 0 ? 'rowheader' : undefined} data-counts-copy={value} data-counts-aggregate={row.kind !== 'data' ? '' : undefined} data-counts-total={row.kind !== 'data' ? row.kind : undefined} data-counts-row-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} })} {columnHeaders.map (column => {count (row, column)} )} )}
} const CountsSummaryResults = (props: Parameters[0]) => { const locale = useLocale () const { message } = useUITranslation ('counts') const headers = props.data.columnHeaders ?? [] const columns = props.data.cols ?? [] if (columns.length === 0) return 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 ({ 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 }} } export default CountsSummaryResults