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