Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/components/PivotTable/PivotTableValueCell.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <PivotTableEmptyCell ref={cellRef} classes={['value']} />
}

const isClickable =
onToggleContextualMenu &&
cellContent.cellType === CELL_TYPE_VALUE &&
Expand All @@ -37,7 +44,7 @@ export const PivotTableValueCell = ({
onToggleContextualMenu(cellRef.current, { ouId: cellContent.ouId })
}

if (!cellContent || cellContent.empty) {
if (cellContent.empty) {
return (
<PivotTableEmptyCell
onClick={isClickable ? onClick : undefined}
Expand Down
37 changes: 37 additions & 0 deletions src/modules/pivotTable/__tests__/clipPartitionedAxis.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { clipPartitionedAxis } from '../clipPartitionedAxis.js'

describe('clipPartitionedAxis', () => {
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])
})
})
11 changes: 11 additions & 0 deletions src/modules/pivotTable/clipPartitionedAxis.js
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
39 changes: 39 additions & 0 deletions src/modules/response/event/__tests__/timeDimensions.spec.js
Original file line number Diff line number Diff line change
@@ -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)
})
})
17 changes: 14 additions & 3 deletions src/modules/response/event/response.js
Original file line number Diff line number Diff line change
Expand Up @@ -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. "<stageId>.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
Expand Down
Loading