diff --git a/src/components/PivotTable/PivotTableValueCell.js b/src/components/PivotTable/PivotTableValueCell.js index f20fe554d..47f771c6c 100644 --- a/src/components/PivotTable/PivotTableValueCell.js +++ b/src/components/PivotTable/PivotTableValueCell.js @@ -24,6 +24,13 @@ export const PivotTableValueCell = ({ column, }) + // engine.get returns undefined when the requested cell falls outside the + // current row/column maps (e.g. a degenerate matrix with no rows or + // columns). Render an empty cell rather than dereferencing undefined. + if (!cellContent) { + return + } + const isClickable = onToggleContextualMenu && cellContent.cellType === CELL_TYPE_VALUE && @@ -37,7 +44,7 @@ export const PivotTableValueCell = ({ onToggleContextualMenu(cellRef.current, { ouId: cellContent.ouId }) } - if (!cellContent || cellContent.empty) { + if (cellContent.empty) { return ( { + it('renders no indices for an empty axis (no partitions)', () => { + // A degenerate matrix (e.g. a dimension with no members) yields an + // empty axis map and therefore no partitions. This must render nothing + // rather than a phantom index 0 that isn't in the map (which made + // engine.get return undefined and PivotTableValueCell throw). + const result = clipPartitionedAxis({ + partitionSize: 400, + partitions: [], + axisMap: [], + widthMap: [], + viewportWidth: 1000, + viewportPosition: 0, + totalWidth: 0, + }) + + expect(result.indices).toEqual([]) + expect(result.pre).toBe(0) + expect(result.post).toBe(0) + }) + + it('still falls back to index 0 when scrolled past the last partition of a non-empty axis', () => { + const result = clipPartitionedAxis({ + partitionSize: 400, + partitions: [0], + axisMap: [0], + widthMap: [{ pre: 0, size: 100 }], + viewportWidth: 500, + viewportPosition: 100000, // far beyond the only partition + totalWidth: 100, + }) + + expect(result.indices).toEqual([0]) + }) +}) diff --git a/src/modules/pivotTable/clipPartitionedAxis.js b/src/modules/pivotTable/clipPartitionedAxis.js index c46a1e764..ff9a26568 100644 --- a/src/modules/pivotTable/clipPartitionedAxis.js +++ b/src/modules/pivotTable/clipPartitionedAxis.js @@ -9,6 +9,17 @@ export const clipPartitionedAxis = ({ }) => { const partition = Math.floor(viewportPosition / partitionSize) + // Empty axis (no rows/columns to render, e.g. a degenerate matrix where a + // dimension has no members). Render nothing instead of falling back to a + // phantom index 0 that isn't present in the axis map. + if (partitions.length === 0) { + return { + indices: [], + pre: 0, + post: 0, + } + } + if (partitions[partition] === undefined) { return { indices: [0], diff --git a/src/modules/response/event/__tests__/timeDimensions.spec.js b/src/modules/response/event/__tests__/timeDimensions.spec.js new file mode 100644 index 000000000..e7d8b8f8d --- /dev/null +++ b/src/modules/response/event/__tests__/timeDimensions.spec.js @@ -0,0 +1,39 @@ +import { transformResponse } from '../response.js' + +const months = ['202505', '202506', '202507'] + +// Minimal enrollment/aggregate-style response with NO data rows. +// The server still returns the full relative-period buckets in +// metaData.dimensions for the period dimensions. +const makeEmptyResponse = () => ({ + headers: [ + { name: 'value', valueType: 'NUMBER', meta: false }, + { name: 'enrollmentdate', valueType: 'TEXT', meta: true }, + { name: 'A03MvHHogjR.eventdate', valueType: 'DATE', meta: true }, + ], + metaData: { + items: months.reduce((o, m) => ({ ...o, [m]: { name: m } }), {}), + dimensions: { + enrollmentdate: [...months], + 'A03MvHHogjR.eventdate': [...months], + }, + }, + rows: [], +}) + +describe('transformResponse - time dimension members', () => { + it('preserves the bare program-level time dimension members when there are no rows', () => { + const { metaData } = transformResponse(makeEmptyResponse()) + + // Regression: applyDefaultHandler used to re-derive enrollmentdate's + // members from the (empty) rows and overwrite the server buckets with + // [], collapsing the pivot table (dataWidth -> 0 -> cellType crash). + expect(metaData.dimensions.enrollmentdate).toEqual(months) + }) + + it('preserves the stage-prefixed time dimension members when there are no rows', () => { + const { metaData } = transformResponse(makeEmptyResponse()) + + expect(metaData.dimensions['A03MvHHogjR.eventdate']).toEqual(months) + }) +}) diff --git a/src/modules/response/event/response.js b/src/modules/response/event/response.js index 35c62eeb3..1d5cfca4c 100644 --- a/src/modules/response/event/response.js +++ b/src/modules/response/event/response.js @@ -96,10 +96,21 @@ const EXCLUDED_HEADER_SUFFIXES = [ '.ou', ] +// Time/org-unit dimensions must keep the dimension members provided by the +// server (e.g. all the period buckets of a relative period like LAST_12_MONTHS) +// instead of having them re-derived from the data rows by applyDefaultHandler. +// EXCLUDED_HEADER_SUFFIXES already covers the program-stage-prefixed forms +// (e.g. ".eventdate"); their bare, program-level counterparts +// (e.g. "enrollmentdate") must be excluded too, otherwise a response with no +// rows would re-derive an empty members list and collapse the pivot table. +const isExcludedHeaderName = (name) => + EXCLUDED_HEADER_NAMES.has(name) || + EXCLUDED_HEADER_SUFFIXES.some( + (suffix) => name.endsWith(suffix) || name === suffix.slice(1) + ) + const isIncludedHeader = (header) => - Boolean(header.meta) && - !EXCLUDED_HEADER_NAMES.has(header.name) && - !EXCLUDED_HEADER_SUFFIXES.some((suffix) => header.name.endsWith(suffix)) + Boolean(header.meta) && !isExcludedHeaderName(header.name) export const transformResponse = (response, { hideNaData = false } = {}) => { // Do not modify the original response