diff --git a/backend/app/services/counts_table_export.rb b/backend/app/services/counts_table_export.rb index b17688c..e50d98b 100644 --- a/backend/app/services/counts_table_export.rb +++ b/backend/app/services/counts_table_export.rb @@ -167,13 +167,13 @@ class CountsTableExport cols = @query.axes.columns leading = [row_count, cols.any? ? 1 : 0].max columns = page.fetch(:column_headers) - Array.new([cols.length, 1].max) do |level| + headings = Array.new([cols.length, 1].max) do |level| prefix = Array.new(leading) { level.zero? ? @headers[_1].to_s : '' } prefix = [''] if row_count.zero? && cols.any? prefix + columns.each_with_index.map do |header, index| values = header[:values] if cols.empty? - header[:kind] == 'data' ? @labels.fetch('count', '') : total_label(header) + @labels.fetch('count', '') elsif level == values.length && header[:kind] != 'data' total_label(header) elsif level < values.length @@ -188,6 +188,25 @@ class CountsTableExport end end end + return headings if cols.empty? + + cols.each_index.flat_map do |level| + prefix = Array.new(leading, '') + prefix = headings.first.first(leading) if level.zero? + names = columns.each_with_index.map do |header, index| + next '' if header[:values].length < level + + previous = index.positive? ? columns[index - 1][:values] : nil + if (previous && previous.length >= level && + previous.first(level) == header[:values].first(level)) + '' + else + @headers[row_count + level].to_s + end + end + headings[level] = Array.new(leading, '') + headings[level].drop(leading) if level.zero? + [prefix + names, headings[level]] + end end def pivot_cell(row, column, page) diff --git a/frontend/src/components/counts/CountsControls.tsx b/frontend/src/components/counts/CountsControls.tsx index edb1966..3ac93ee 100644 --- a/frontend/src/components/counts/CountsControls.tsx +++ b/frontend/src/components/counts/CountsControls.tsx @@ -40,11 +40,9 @@ const CountsControls = ( const id = useId () const motion = useClientMotion () const [draft, setDraft] = useState (() => new URLSearchParams (applied)) - const order = applied.get ('order') - const nestedJSON = applied.get ('nested_json') - const rowNumbers = applied.get ('row_numbers') const thumbnail = selectedCountColumns (resource, applied, definition).includes ('thumbnail') - const previousThumbnail = useRef (thumbnail) + const previousApplied = useRef ({ resource, params: new URLSearchParams (applied) }) + const appliedState = applied.toString () const mode = draft.get ('mode') ?? 'summary' const columns = selectedCountColumns (resource, draft, definition) const details = mode === 'details' @@ -53,30 +51,42 @@ const CountsControls = ( && !countsNestedIsScalar (field.children, field.key, draft) && field.children.some (child => selectedCountChildren (draft) .has (`${field.key}.${child.key}`))) - // Preserve absent columns until a real presentation change or an explicit edit. useEffect (() => { - if (previousThumbnail.current === thumbnail) return - previousThumbnail.current = thumbnail - setDraft (previous => { - const next = new URLSearchParams (previous) - const columns = selectedCountColumns (resource, previous, definition) - .filter (column => column !== 'thumbnail') - next.set ('columns', (thumbnail ? ['thumbnail', ...columns] : columns).join (' ')) - return next - }) - }, [thumbnail, resource, definition]) - useEffect (() => { - setDraft (previous => { - const next = new URLSearchParams (previous) - const display = { order, nested_json: nestedJSON, row_numbers: rowNumbers } - for (const [key, value] of Object.entries (display)) + const nextApplied = new URLSearchParams (appliedState) + const previous = previousApplied.current + previousApplied.current = { resource, params: nextApplied } + setDraft (draft => { + if (previous.resource !== resource + || previous.params.get ('definition') !== nextApplied.get ('definition')) + return nextApplied + const keys = new Set ([...previous.params.keys (), ...nextApplied.keys ()]) + const changed = [...keys].filter (key => + previous.params.get (key) !== nextApplied.get (key)) + const before = selectedCountColumns (resource, previous.params, definition) + const after = selectedCountColumns (resource, nextApplied, definition) + const selectionChanged = before.filter (column => column !== 'thumbnail').join (' ') + !== after.filter (column => column !== 'thumbnail').join (' ') + const internal = ['execution_at', 'timezone', 'order', 'nested_json', 'row_numbers'] + if (changed.some (key => key === 'columns' ? selectionChanged : !internal.includes (key))) + return nextApplied + // Internal URL enrichment and immediate display changes preserve pending edits. + const next = new URLSearchParams (draft) + for (const key of changed) { - if (value == null) next.delete (key) - else next.set (key, value) + if (key === 'columns') + { + const columns = selectedCountColumns (resource, draft, definition) + .filter (column => column !== 'thumbnail') + next.set ('columns', (after.includes ('thumbnail') + ? ['thumbnail', ...columns] : columns).join (' ')) + continue + } + next.delete (key) + for (const value of nextApplied.getAll (key)) next.append (key, value) } - return next + return next.toString () === draft.toString () ? draft : next }) - }, [order, nestedJSON, rowNumbers]) + }, [appliedState, resource, definition]) const history = draft.get ('definition') === 'history' const update = (key: string, value: string) => setDraft (previous => { const next = new URLSearchParams (previous) diff --git a/frontend/src/components/counts/CountsSummaryResults.tsx b/frontend/src/components/counts/CountsSummaryResults.tsx index b12b70e..4c4e625 100644 --- a/frontend/src/components/counts/CountsSummaryResults.tsx +++ b/frontend/src/components/counts/CountsSummaryResults.tsx @@ -45,6 +45,7 @@ const CountsSummaryTable = ( ? '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)}` @@ -119,14 +120,13 @@ const CountsSummaryTable = ( 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) => [ + 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 header.kind === 'data' - ? message ('count').text : aggregateLabel (header) + 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) @@ -137,6 +137,48 @@ const CountsSummaryTable = ( 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) => { @@ -147,8 +189,10 @@ const CountsSummaryTable = ( 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 = aggregate ? aggregateLabel (header) - : (cols.length === 0 ? message ('count').text + 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) @@ -165,7 +209,8 @@ const CountsSummaryTable = ( 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} @@ -184,16 +229,18 @@ const CountsSummaryTable = ( identity={`${countsRequestParams (params)}:${JSON.stringify (keys)}`} className="text-left text-sm"> - {headingRows.map ((_, level) => - {level === 0 && <> - {numbered && } + {headingRows.map ((_, tier) => + {tier === 0 && <> + {numbered && } {Array.from ({ length: leading }, (_, index) => rows[index] == null - ? + ? : )} } - {headerCells (level)} + {(cols.length > 0 && tier % 2 === 0) + ? axisHeaderCells (tier / 2) : headerCells (Math.floor (tier / 2))} )} {rowHeaders.map ((row, index) => @@ -204,21 +251,19 @@ const CountsSummaryTable = ( 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} + 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 => + className="border px-2 py-1 dark:border-slate-700"> {count (row, column)} )} )} diff --git a/frontend/src/components/counts/countsTable.css b/frontend/src/components/counts/countsTable.css index 947cae5..d9b7b88 100644 --- a/frontend/src/components/counts/countsTable.css +++ b/frontend/src/components/counts/countsTable.css @@ -18,17 +18,6 @@ @apply bg-slate-900 text-slate-100; } -.counts-grid > tbody > tr > td[data-counts-aggregate]:not([data-counts-selected]) { - background-color: #e2e8f0; - color: #0f172a; - font-weight: 600; -} - -.dark .counts-grid > tbody > tr > td[data-counts-aggregate]:not([data-counts-selected]) { - background-color: #334155; - color: #f8fafc; -} - .counts-grid > thead { @apply bg-slate-200 text-slate-950; } @@ -55,25 +44,27 @@ @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'] { +/* Aggregate kind is semantic; only aggregate rows receive a background. */ +.counts-grid > tbody > tr > td[data-counts-total], +.counts-grid > thead > tr > th[data-counts-total] { + @apply border-l border-l-slate-400 font-semibold dark:border-l-slate-500; +} + +.counts-grid > tbody > tr > td[data-counts-row-total='subtotal']:not([data-counts-selected]) { 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'] { +.counts-grid > tbody > tr > td[data-counts-row-total='grand_total']:not([data-counts-selected]) { 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'] { +.dark .counts-grid > tbody > tr > td[data-counts-row-total='subtotal']:not([data-counts-selected]) { 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'] { +.dark .counts-grid > tbody > tr > td[data-counts-row-total='grand_total']:not([data-counts-selected]) { background-color: #475569; color: #fff; } diff --git a/frontend/src/components/layout/SidebarComponent.tsx b/frontend/src/components/layout/SidebarComponent.tsx index a1c5962..762e567 100644 --- a/frontend/src/components/layout/SidebarComponent.tsx +++ b/frontend/src/components/layout/SidebarComponent.tsx @@ -15,6 +15,7 @@ type Props = { children: ReactNode className?: string maxWidth?: number + maxWidthRatio?: number onWidthChange?: (width: number) => void sidebarKey: string side?: SidebarSide } @@ -67,6 +68,7 @@ const SidebarComponent: FC = ({ children, className, maxWidth, + maxWidthRatio, onWidthChange, sidebarKey, side = 'left', @@ -80,6 +82,7 @@ const SidebarComponent: FC = ({ ) const rootRef = useRef (null) const maxWidthRef = useRef (maxWidth) + const maxWidthRatioRef = useRef (maxWidthRatio) const getContainerWidth = useCallback (() => { const containerWidth = rootRef.current ?.closest ('[data-sidebar-container]') @@ -93,12 +96,14 @@ const SidebarComponent: FC = ({ const viewportWidth = typeof window === 'undefined' ? containerWidth : window.innerWidth const viewportMaxWidth = Math.floor (viewportWidth * MAX_VIEWPORT_WIDTH_RATIO) - const maxAllowedWidth = - maxWidthRef.current == null + const ratioMaxWidth = maxWidthRatioRef.current == null + ? Infinity : Math.floor (containerWidth * maxWidthRatioRef.current) + const configuredMaxWidth = maxWidthRef.current == null ? getSidebarMaxWidth (containerWidth) - : Math.max ( - MIN_SIDEBAR_WIDTH, + : Math.max (MIN_SIDEBAR_WIDTH, Math.min (viewportMaxWidth, maxWidthRef.current)) + const maxAllowedWidth = Math.max (MIN_SIDEBAR_WIDTH, + Math.min (ratioMaxWidth, configuredMaxWidth)) return Math.min (maxAllowedWidth, Math.max (MIN_SIDEBAR_WIDTH, nextWidth)) }, []) @@ -171,10 +176,11 @@ const SidebarComponent: FC = ({ useEffect (() => { maxWidthRef.current = maxWidth + maxWidthRatioRef.current = maxWidthRatio const containerWidth = getContainerWidth () const nextWidth = getClampedWidth (widthRef.current, containerWidth) applyWidth (nextWidth) - }, [applyWidth, getClampedWidth, getContainerWidth, maxWidth]) + }, [applyWidth, getClampedWidth, getContainerWidth, maxWidth, maxWidthRatio]) const updateWidth = useCallback ((clientX: number) => { const state = resizeRef.current diff --git a/frontend/src/lib/counts.ts b/frontend/src/lib/counts.ts index 6328677..01b9afc 100644 --- a/frontend/src/lib/counts.ts +++ b/frontend/src/lib/counts.ts @@ -383,6 +383,7 @@ export const countsRequestParams = (params: URLSearchParams): URLSearchParams => 'current', 'row_numbers']) result.delete (key) if (isCountsState (params) && countsUseLatest (params)) result.delete ('at') + result.sort () return result } diff --git a/frontend/src/pages/CountsResourcePage.tsx b/frontend/src/pages/CountsResourcePage.tsx index 5e6e69c..ffd9e50 100644 --- a/frontend/src/pages/CountsResourcePage.tsx +++ b/frontend/src/pages/CountsResourcePage.tsx @@ -1,4 +1,4 @@ -import { useQuery } from '@tanstack/react-query' +import { useQuery, useQueryClient } from '@tanstack/react-query' import { useEffect, useRef, useState } from 'react' import { useSearchParams } from 'react-router-dom' @@ -38,15 +38,10 @@ import type { CountsResource } from '@/lib/counts' const CountsResourceContent = ({ resource }: { resource: CountsResource }) => { const definition = useCountsDefinition () + const queryClient = useQueryClient () const [source, setParams] = useSearchParams () const params = normaliseCountsParams (resource, source, definition) const canonical = params.toString () - const controlParams = new URLSearchParams (params) - controlParams.delete ('nested_json') - controlParams.delete ('order') - controlParams.delete ('row_numbers') - controlParams.set ('columns', selectedCountColumns (resource, params, definition) - .filter (column => column !== 'thumbnail').sort ().join (' ')) const request = countsRequestParams (params) const [open, setOpen] = useState (false) const [sidebarOpen, setSidebarOpen] = useState (false) @@ -65,8 +60,10 @@ const CountsResourceContent = ({ resource }: { resource: CountsResource }) => { const [exporting, setExporting] = useState (false) const exportInFlight = useRef (false) const [exportError, setExportError] = useState (false) - const { data, isPending, isError, isPlaceholderData, refetch } = useQuery ({ + const { data, dataUpdatedAt, isFetching, isPending, isError, + isPlaceholderData, refetch } = useQuery ({ enabled: params.has ('timezone'), + staleTime: params.has ('execution_at') ? 5 * 60 * 1000 : 0, queryKey: countsKeys.index (resource, cacheParams.toString (), locale), queryFn: () => fetchCounts (resource, params, locale), retry: false, placeholderData: (previous, query) => { @@ -80,25 +77,22 @@ const CountsResourceContent = ({ resource }: { resource: CountsResource }) => { } }) useEffect (() => { - if (source.toString () !== canonical) - setParams (canonical, { replace: true }) - }, [canonical, source, setParams]) - - useEffect (() => { - const timezone = Intl.DateTimeFormat ().resolvedOptions ().timeZone - if (params.get ('timezone') !== timezone) + const next = new URLSearchParams (canonical) + if (!next.has ('timezone')) + next.set ('timezone', Intl.DateTimeFormat ().resolvedOptions ().timeZone) + if (data?.executionAt != null && !isPlaceholderData && !isFetching && !isError + && params.has ('timezone') && !params.has ('execution_at')) { - const next = new URLSearchParams (canonical) - next.set ('timezone', timezone) - setParams (next, { replace: true }) - } - else if (data?.executionAt != null && !isPlaceholderData && !params.has ('execution_at')) - { - const next = new URLSearchParams (canonical) next.set ('execution_at', data.executionAt) - setParams (next, { replace: true }) + // Pin the response under its server-confirmed time before changing the URL. + queryClient.setQueryData ( + countsKeys.index (resource, countsRequestParams (next).toString (), locale), + data, { updatedAt: dataUpdatedAt }) } - }, [canonical, data, isPlaceholderData, params, setParams]) + if (source.toString () !== next.toString ()) + setParams (next, { replace: true }) + }, [canonical, data, dataUpdatedAt, isError, isFetching, isPlaceholderData, + locale, params, queryClient, resource, setParams, source]) const update = (key: string, value: string, extra?: Record) => { @@ -166,8 +160,8 @@ const CountsResourceContent = ({ resource }: { resource: CountsResource }) => { - +