Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
06f26d8
refactor: extract output type validity out of use-action-button [DHIS…
HendrikThePendric Sep 22, 2026
eb8595c
fix: drop wire-only dimensions when normalizing a loaded visualizatio…
HendrikThePendric Sep 22, 2026
c20b536
refactor: compare any two visualizations for equivalence [DHIS2-22011]
HendrikThePendric Sep 22, 2026
0602c1f
feat: show an unapplied changes indicator in the layout panel [DHIS2-…
HendrikThePendric Sep 22, 2026
19e13d4
docs: name the real save/load helpers in CLAUDE.md [DHIS2-22011]
HendrikThePendric Sep 22, 2026
92a36c5
chore: gitignore the superpowers SDD scratch workspace
HendrikThePendric Sep 22, 2026
745cea8
feat: dim the canvas and show a notice while changes are unapplied [D…
cooper-joe Sep 23, 2026
5d728ed
refactor: move the stale canvas state into CSS modules and i18n [DHIS…
HendrikThePendric Sep 23, 2026
0ad3725
refactor: extract the stale canvas state into UnappliedChangesOverlay…
HendrikThePendric Sep 23, 2026
c6c23c2
refactor: drive UnappliedChangesOverlay from props [DHIS2-22011]
HendrikThePendric Sep 23, 2026
57d58b6
refactor: clarify the visualization equivalence comments [DHIS2-22011]
HendrikThePendric Sep 23, 2026
3502c23
refactor: drop the unsupported custom value claim from dropWireOnlyDi…
HendrikThePendric Sep 24, 2026
d394fae
refactor: rename WIRE_ONLY_DIMENSIONS to DROPPED_LEGACY_DIMENSIONS [D…
HendrikThePendric Sep 24, 2026
3349ca5
Merge branch 'master' into feat/unapplied-changes-DHIS2-22011
HendrikThePendric Sep 24, 2026
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,6 @@ cypress/downloads
opensrc
docs/superpowers
.playwright-cli

# superpowers SDD scratch workspace
.superpowers
41 changes: 29 additions & 12 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -387,18 +387,35 @@ 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/state`) brings the API
payload to app shape: upgrades legacy dimensions, maps `PROGRAM_DATA_ELEMENT` → `DATA_ELEMENT`,
and strips the dropped legacy dimensions (`DROPPED_LEGACY_DIMENSIONS` in `@modules/dimension/ids`).
2. `getVisualizationUiConfig` (`@modules/visualization/state`) 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/state`) 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-23T14:32:18.188Z\n"
"PO-Revision-Date: 2026-09-23T14:32:18.188Z\n"

msgid ""
"Some dimensions were not added because they cannot be used in a "
Expand All @@ -25,6 +25,9 @@ msgstr ""
msgid "{{- name}} was not added because it is already used as the custom value."
msgstr "{{- name}} was not added because it is already used as the custom value."

msgid "Changes not applied"
msgstr "Changes not applied"

msgid "Retry"
msgstr "Retry"

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
53 changes: 53 additions & 0 deletions src/components/canvas/__tests__/unapplied-changes-overlay.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { render, screen } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import { UnappliedChangesOverlay } from '../unapplied-changes-overlay'

const renderOverlay = (
props: Partial<{
hasUnappliedChanges: boolean
isVisualizationLoading: boolean
}> = {}
) =>
render(
<UnappliedChangesOverlay
hasUnappliedChanges={false}
isVisualizationLoading={false}
{...props}
>
<div data-test="canvas-content" />
</UnappliedChangesOverlay>
)

/* The notice stays mounted so it can fade out, so "hidden" is the aria-hidden
* state rather than the absence of the element. */
const getNotice = () => screen.getByTestId('unapplied-changes')

describe('UnappliedChangesOverlay', () => {
it('renders its children', () => {
renderOverlay()

expect(screen.getByTestId('canvas-content')).toBeInTheDocument()
})

it('hides the notice when there are no unapplied changes', () => {
renderOverlay()

expect(getNotice()).toHaveAttribute('aria-hidden', 'true')
})

it('shows the notice when there are unapplied changes', () => {
renderOverlay({ hasUnappliedChanges: true })

expect(getNotice()).toHaveAttribute('aria-hidden', 'false')
expect(getNotice()).toHaveTextContent('Changes not applied')
})

it('hides the notice while the visualization is loading', () => {
renderOverlay({
hasUnappliedChanges: true,
isVisualizationLoading: true,
})

expect(getNotice()).toHaveAttribute('aria-hidden', 'true')
})
})
24 changes: 16 additions & 8 deletions src/components/canvas/canvas.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useHasUnappliedChanges } from '@components/layout-panel/bottom-bar/use-has-unapplied-changes'
import { PluginWrapper } from '@components/plugin-wrapper/plugin-wrapper'
import { StartScreen } from '@components/start-screen/start-screen'
import { useAppDispatch, useAppSelector, useCurrentUser } from '@hooks'
Expand All @@ -11,6 +12,7 @@ import { tLoadSavedVisualization } from '@store/thunks'
import { setUiActiveDimensionModal } from '@store/ui-slice'
import type { Sorting } from '@types'
import { useCallback, type FC } from 'react'
import { UnappliedChangesOverlay } from './unapplied-changes-overlay'

export const Canvas: FC = () => {
const dispatch = useAppDispatch()
Expand All @@ -21,6 +23,7 @@ export const Canvas: FC = () => {
const visualizationId = useAppSelector(
(state) => state.navigation.visualizationId
)
const hasUnappliedChanges = useHasUnappliedChanges()

const onRetryLoad = useCallback(() => {
if (visualizationId !== 'new') {
Expand Down Expand Up @@ -61,14 +64,19 @@ export const Canvas: FC = () => {
}

return (
<PluginWrapper
<UnappliedChangesOverlay
hasUnappliedChanges={hasUnappliedChanges}
isVisualizationLoading={isVisualizationLoading}
visualization={currentVis}
visualizationLoadError={visualizationLoadError ?? undefined}
onRetryLoad={onRetryLoad}
displayProperty={currentUser.settings.displayProperty}
onColumnHeaderClick={onColumnHeaderClick}
onDataSorted={onDataSorted}
/>
>
<PluginWrapper
isVisualizationLoading={isVisualizationLoading}
visualization={currentVis}
visualizationLoadError={visualizationLoadError ?? undefined}
onRetryLoad={onRetryLoad}
displayProperty={currentUser.settings.displayProperty}
onColumnHeaderClick={onColumnHeaderClick}
onDataSorted={onDataSorted}
/>
</UnappliedChangesOverlay>
)
}
45 changes: 45 additions & 0 deletions src/components/canvas/styles/unapplied-changes-overlay.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
.container {
position: relative;
block-size: 100%;
}

.content {
block-size: 100%;
transition: opacity 250ms cubic-bezier(0.4, 0, 0.2, 1);
}

.content.stale {
opacity: 0.66;
}

.notice {
position: absolute;
inset-block-start: 0;
inset-inline: 4px;
z-index: 1;
padding-block: 1px 8px;
padding-inline: 8px;
background: linear-gradient(
180deg,
var(--colors-grey300) 0%,
var(--colors-grey300) 72%,
transparent 100%
);
color: var(--colors-grey700);
font-size: 12px;
line-height: 16px;
letter-spacing: 0.01em;
visibility: hidden;
opacity: 0;
pointer-events: none;
/* visibility is in the transition so the notice leaves the accessibility
* tree once it has faded out rather than the moment it starts to */
transition:
opacity 250ms cubic-bezier(0.4, 0, 0.2, 1),
visibility 250ms;
}

.notice.visible {
visibility: visible;
opacity: 0.9;
}
32 changes: 32 additions & 0 deletions src/components/canvas/unapplied-changes-overlay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import i18n from '@dhis2/d2-i18n'
import cx from 'classnames'
import type { FC, PropsWithChildren } from 'react'
import classes from './styles/unapplied-changes-overlay.module.css'

type UnappliedChangesOverlayProps = PropsWithChildren<{
hasUnappliedChanges: boolean
isVisualizationLoading: boolean
}>

export const UnappliedChangesOverlay: FC<UnappliedChangesOverlayProps> = ({
children,
hasUnappliedChanges,
isVisualizationLoading,
}) => {
const isStale = hasUnappliedChanges && !isVisualizationLoading

return (
<div className={classes.container}>
<div className={cx(classes.content, { [classes.stale]: isStale })}>
{children}
</div>
<div
className={cx(classes.notice, { [classes.visible]: isStale })}
aria-hidden={!isStale}
data-test="unapplied-changes"
>
{i18n.t('Changes not applied')}
</div>
</div>
)
}
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,36 @@
import { renderWithAppWrapper } from '@test-utils/app-wrapper'
import { screen } from '@testing-library/react'
import type { RootState } from '@types'
import { describe, it, expect } from 'vitest'
import { metadata, populatedVis } from '../__fixtures__/unapplied-changes'
import { BottomBar } from '../bottom-bar'

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

describe('BottomBar', () => {
it('shows the output type buttons once a data source is selected', async () => {
await renderBottomBar({ currentVis: populatedVis })

expect(await screen.findByTestId('update-buttons')).toBeInTheDocument()
})

it('hides the output type buttons while the visualization is loading', async () => {
await renderBottomBar({
currentVis: populatedVis,
loader: { isVisualizationLoading: true },
} as Partial<RootState>)

expect(screen.queryByTestId('update-buttons')).not.toBeInTheDocument()
})
})
Loading
Loading