Skip to content
Draft
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,5 @@ cypress/downloads
.scannerwork
opensrc
docs/superpowers
.superpowers
.playwright-cli
47 changes: 33 additions & 14 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -353,8 +353,8 @@ compound ID from a `DimensionRecord`. It applies these rules in order:
- **Event org unit**: `ou` with `programStage` → compound `stageId.ou`
- **Enrollment org unit**: `enrollmentOu` → compound `programId.enrollmentOu`.
`toAppLocalDimensions` renames API `ou` (with program, no programStage) to `enrollmentOu`
at the API → app-local boundary. `toApiDimensionId` does the inverse on save — but only
in some outputType/visType combinations (see table below).
at the API → app-local boundary. `toEventVisualizationDimensionId` does the inverse on
save — but only in some outputType/visType combinations (see table below).
- **Registration org unit**: `enrollmentOu` with `trackedEntityType` (no program/stage) →
compound `tetId.enrollmentOu`. The TEI registration OU shares the `enrollmentOu` dimension
ID with the program-scope enrollment OU; the prefix (programId vs trackedEntityTypeId)
Expand Down Expand Up @@ -387,18 +387,37 @@ program-scope (`{dimension: 'ou', program: {id}}`) and TEI registration

### Save/load translation at the visualization API boundary

**Loading** (API → frontend): `acSetVisualization` reads each dimension's `program` and
`programStage` from the populated `columns`/`rows`/`filters` objects and calls `getFullDimensionId`
(or `formatDimensionId` in the line-listing-app). For EVENT/ENROLLMENT this produces
`stageId.dimensionId` (dropping the programId). For TRACKED_ENTITY it produces
`programId.stageId.dimensionId` or `programId.dimensionId`.

**Saving** (frontend → API): `getAxesFromUi` (or equivalent) decomposes the internal compound ID
via `getDimensionIdParts` (`extractDimensionIdParts` in the line-listing-app) and sends each
dimension to the API with a plain `dimension` ID plus separate `program` and `programStage`
objects. The backend's `mergeAnalyticalObject` hydrates the stage from the database (including its
parent program via `loadProgramForStage`), then `getQualifiedDimension` rebuilds the persisted
string as `programId.stageId.dimensionId`.
**Loading** (API → frontend), in order:

1. `normalizeApiSavedVisualization` (`@modules/visualization/normalize-legacy`) brings the API
payload to app shape: upgrades legacy dimensions and strips the wire-only dimensions
(`WIRE_ONLY_DIMENSIONS` in `@modules/dimension/ids`). The `PROGRAM_DATA_ELEMENT` →
`DATA_ELEMENT` mapping is not its concern — that runs per analytics request, in
`transformDimensions` (`@modules/dimension/translation`).
2. `getVisualizationUiConfig` (`@modules/visualization/ui-config`) derives `visUiConfig` — layout
arrays, items, conditions and options — from the normalized visualization. It applies two
dimension translations on the way:
- `toAppLocalDimensions` (`@modules/dimension/translation`) renames API `ou` with a program but
no programStage to `enrollmentOu`.
- `getCompoundDimensionId` (`@modules/dimension/ids`) builds the canonical app-local compound ID
from each `DimensionRecord`, reading its `program` and `programStage`. For EVENT/ENROLLMENT
this produces `stageId.dimensionId` (dropping the programId); for TRACKED_ENTITY,
`programId.stageId.dimensionId` or `programId.dimensionId`.

The metadata provider (`@modules/metadata/visualization`) applies the same two translations when
it registers a loaded visualization's dimensions.

**Saving** (frontend → API), in order:

1. `buildAxis` (`@modules/layout`) rebuilds each axis from the layout's compound IDs, calling
`toEventVisualizationDimensionId` (`@modules/dimension/translation`) for the POST dimension ID
and emitting separate `program` and `programStage` objects. See the `enrollmentOu` mapping table
above for the outputType/visType rules it applies.
2. `getSaveableVisualization` (`@modules/visualization/save`) drops the non-persisted dimension
props (`dimensionType`, `valueType`) and formats sorting for the API.
3. The backend's `mergeAnalyticalObject` hydrates the stage from the database (including its parent
program via `loadProgramForStage`), then `getQualifiedDimension` rebuilds the persisted string as
`programId.stageId.dimensionId`.

### `programDimensions` field on eventVisualizations

Expand Down
10 changes: 8 additions & 2 deletions i18n/en.pot
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ msgstr ""
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1)\n"
"POT-Creation-Date: 2026-09-17T12:36:05.389Z\n"
"PO-Revision-Date: 2026-09-17T12:36:05.389Z\n"
"POT-Creation-Date: 2026-09-22T09:13:46.110Z\n"
"PO-Revision-Date: 2026-09-22T09:13:46.112Z\n"

msgid ""
"Some dimensions were not added because they cannot be used in a "
Expand Down Expand Up @@ -307,6 +307,9 @@ msgstr "Switch to {{- trackedEntityTypeName}} list"
msgid "Update {{- trackedEntityTypeName}} list"
msgstr "Update {{- trackedEntityTypeName}} list"

msgid "Unapplied changes"
msgstr "Unapplied changes"

msgid "Not valid with registration org. unit"
msgstr "Not valid with registration org. unit"

Expand Down Expand Up @@ -337,6 +340,9 @@ msgstr "Not valid with Completed on"
msgid "Not valid with multiple tracked entity types"
msgstr "Not valid with multiple tracked entity types"

msgid "Not valid without a tracked entity type"
msgstr "Not valid without a tracked entity type"

msgid "Not valid with program indicators"
msgstr "Not valid with program indicators"

Expand Down
6 changes: 2 additions & 4 deletions src/api/event-visualizations-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,8 @@ import {
getTrackedEntityTypeFields,
programStageFields,
} from '@modules/query'
import {
getSaveableVisualization,
normalizeApiSavedVisualization,
} from '@modules/visualization/state'
import { normalizeApiSavedVisualization } from '@modules/visualization/normalize-legacy'
import { getSaveableVisualization } from '@modules/visualization/save'
import { getDimensionMetadataFields } from '@modules/visualization/vis-type'
import type {
ApiSavedVisualization,
Expand Down
2 changes: 1 addition & 1 deletion src/components/canvas/canvas.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { PluginWrapper } from '@components/plugin-wrapper/plugin-wrapper'
import { StartScreen } from '@components/start-screen/start-screen'
import { useAppDispatch, useAppSelector, useCurrentUser } from '@hooks'
import { isVisualizationEmpty } from '@modules/visualization/state'
import { isVisualizationEmpty } from '@modules/visualization/guards'
import { getCurrentVis, setCurrentVis } from '@store/current-vis-slice'
import {
getIsVisualizationLoading,
Expand Down
4 changes: 2 additions & 2 deletions src/components/download-menu/use-download.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@ import { Analytics } from '@dhis2/analytics'
import { useConfig, useDataEngine } from '@dhis2/app-runtime'
import { useAppSelector, useCurrentUser } from '@hooks'
import { transformVisualizationForAnalyticsRequest } from '@modules/analytics-request'
import { getSingleProgramFromVisualization } from '@modules/visualization/program'
import {
isCurrentVisualizationNew,
isCurrentVisualizationPersisted,
isVisualizationEmpty,
isVisualizationWithTimeDimension,
} from '@modules/visualization/state'
} from '@modules/visualization/guards'
import { getSingleProgramFromVisualization } from '@modules/visualization/program'
import { getCurrentVis } from '@store/current-vis-slice'
import { useCallback, useState } from 'react'
import type { DownloadFn } from './types'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { ModalDownloadDropdown } from '@components/download-menu/modal-download-
import { PluginWrapper } from '@components/plugin-wrapper/plugin-wrapper'
import { InterpretationModal as AnalyticsInterpretationModal } from '@dhis2/analytics'
import { useAppSelector } from '@hooks'
import { isSavedVisualization } from '@modules/visualization/state'
import { isSavedVisualization } from '@modules/visualization/guards'
import { getIsVisualizationLoading } from '@store/loader-slice'
import { getSavedVis } from '@store/saved-vis-slice'
import type { MetadataInput } from '@types'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { CurrentVisualization } from '@types'

export const PROGRAM_ID = 'program1'
export const STAGE_ID = 'stage1'
export const DIMENSION_ID = `${STAGE_ID}.de1`

/* An event program, so the EVENT output type is the applicable one — the same
* shape a real layout dimension carries. Without the program the layout would
* have no program at all, which makes every output type inapplicable. */
export const metadata = {
[PROGRAM_ID]: {
id: PROGRAM_ID,
name: 'Program 1',
programType: 'WITHOUT_REGISTRATION',
},
[STAGE_ID]: { id: STAGE_ID, name: 'Stage 1', program: { id: PROGRAM_ID } },
[DIMENSION_ID]: {
id: DIMENSION_ID,
name: 'Data element 1',
dimensionId: 'de1',
programId: PROGRAM_ID,
programStageId: STAGE_ID,
dimensionType: 'DATA_ELEMENT',
valueType: 'TEXT',
},
}

/* digitGroupSeparator is seeded onto the default store's visUiConfig from the
* mocked system settings (src/test-utils/__fixtures__/system-settings.json),
* so a currentVis that matches the default ui config must carry the same
* value. The real load path is not affected: the API always returns a
* digitGroupSeparator, so both sides agree there. */
export const populatedVis = {
type: 'LINE_LIST',
outputType: 'EVENT',
digitGroupSeparator: 'SPACE',
columns: [],
rows: [],
filters: [],
} as unknown as CurrentVisualization
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { renderWithAppWrapper } from '@test-utils/app-wrapper'
import { screen } from '@testing-library/react'
import type { Layout, RootState } from '@types'
import { describe, it, expect } from 'vitest'
import {
DIMENSION_ID,
metadata,
populatedVis,
} from '../__fixtures__/unapplied-changes'
import { BottomBar } from '../bottom-bar'

const layoutWithDimension = {
columns: [DIMENSION_ID],
rows: [],
filters: [],
} as unknown as Layout

const renderBottomBar = (preloadedState: Partial<RootState>) =>
renderWithAppWrapper(<BottomBar />, {
metadata,
partialStore: {
preloadedState: {
dimensionSelection: {
dataSourceId: 'program1',
} as RootState['dimensionSelection'],
...preloadedState,
},
},
})

describe('BottomBar unapplied changes indicator', () => {
it('is absent when the visualization matches the ui config', async () => {
await renderBottomBar({ currentVis: populatedVis })

expect(
screen.queryByTestId('unapplied-changes')
).not.toBeInTheDocument()
})

it('is shown when there are unapplied changes', async () => {
await renderBottomBar({
currentVis: populatedVis,
visUiConfig: { layout: layoutWithDimension },
} as Partial<RootState>)

expect(await screen.findByText('Unapplied changes')).toBeVisible()
})

it('is absent while the visualization is loading, along with the buttons', async () => {
await renderBottomBar({
currentVis: populatedVis,
visUiConfig: { layout: layoutWithDimension },
loader: { isVisualizationLoading: true },
} as Partial<RootState>)

expect(
screen.queryByTestId('unapplied-changes')
).not.toBeInTheDocument()
expect(screen.queryByTestId('update-buttons')).not.toBeInTheDocument()
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { tUpdateCurrentVisFromVisUiConfig } from '@store/thunks'
import {
addVisUiConfigLayoutDimension,
initialState as visUiConfigInitialState,
} from '@store/vis-ui-config-slice'
import { renderHookWithAppWrapper } from '@test-utils/app-wrapper'
import { act } from '@testing-library/react'
import type { RootState } from '@types'
import { describe, it, expect } from 'vitest'
import {
DIMENSION_ID,
metadata,
populatedVis,
} from '../__fixtures__/unapplied-changes'
import { useHasUnappliedChanges } from '../use-has-unapplied-changes'

const renderHookWithVis = (
currentVis: Partial<RootState>['currentVis'],
visUiConfig?: Partial<RootState>['visUiConfig']
) =>
renderHookWithAppWrapper(useHasUnappliedChanges, {
metadata,
partialStore: {
preloadedState: {
currentVis,
...(visUiConfig && { visUiConfig }),
},
},
})

describe('useHasUnappliedChanges', () => {
it('is false for an empty visualization', async () => {
const { result } = await renderHookWithVis({})

expect(result.current).toBe(false)
})

it('is false for a visualization whose ui config matches it', async () => {
const { result } = await renderHookWithVis(populatedVis)

expect(result.current).toBe(false)
})

/* An empty layout makes every output type inapplicable, so there is no
* button to hint at — and the builder could not produce a
* TRACKED_ENTITY_INSTANCE visualization from it either. */
it('is false when no output type is applicable', async () => {
const { result } = await renderHookWithVis(
{ ...populatedVis, outputType: 'TRACKED_ENTITY_INSTANCE' },
{
...visUiConfigInitialState,
outputType: 'TRACKED_ENTITY_INSTANCE',
}
)

expect(result.current).toBe(false)
})

/* The layout holds an event program dimension, so EVENT is applicable and
* TRACKED_ENTITY_INSTANCE is not. Applying requires switching output type,
* which means what is on screen cannot be what the config describes — and
* the comparison could not run for the selected type anyway. */
it('is true when the selected output type is inapplicable but another one is', async () => {
const { result } = await renderHookWithVis(populatedVis, {
...visUiConfigInitialState,
outputType: 'TRACKED_ENTITY_INSTANCE',
layout: { columns: [DIMENSION_ID], rows: [], filters: [] },
})

expect(result.current).toBe(true)
})

it('becomes true when a dimension is added to the layout', async () => {
const { result, store } = await renderHookWithVis(populatedVis)

expect(result.current).toBe(false)

await act(async () => {
store.dispatch(
addVisUiConfigLayoutDimension({
axis: 'columns',
dimensionId: DIMENSION_ID,
})
)
})

expect(result.current).toBe(true)
})

it('becomes false again once the change is applied', async () => {
const { result, store } = await renderHookWithVis(populatedVis)

await act(async () => {
store.dispatch(
addVisUiConfigLayoutDimension({
axis: 'columns',
dimensionId: DIMENSION_ID,
})
)
})

expect(result.current).toBe(true)

await act(async () => {
store.dispatch(tUpdateCurrentVisFromVisUiConfig())
})

expect(result.current).toBe(false)
})
})
Loading
Loading