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
6 changes: 3 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -389,10 +389,10 @@ program-scope (`{dimension: 'ou', program: {id}}`) and TEI registration

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

1. `normalizeApiSavedVisualization` (`@modules/visualization/state`) brings the API
1. `normalizeApiSavedVisualization` (`@modules/visualization/normalize-legacy`) brings the API
payload to app shape: upgrades legacy dimensions, maps `PROGRAM_DATA_ELEMENT` → `DATA_ELEMENT`,
and strips the wire-only dimensions (`WIRE_ONLY_DIMENSIONS` in `@modules/dimension/ids`).
2. `getVisualizationUiConfig` (`@modules/visualization/state`) derives `visUiConfig` — layout
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
Expand All @@ -411,7 +411,7 @@ program-scope (`{dimension: 'ou', program: {id}}`) and TEI registration
`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
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
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 @@ -8,13 +8,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
@@ -1,6 +1,6 @@
import { getOutputTypeTooltipConfig } from '@components/layout-panel/bottom-bar/output-type-validity'
import { useAppSelector, useLayoutContext, useMetadataStore } from '@hooks'
import { isVisualizationEmpty } from '@modules/visualization/state'
import { isVisualizationEmpty } from '@modules/visualization/guards'
import { getCurrentVis } from '@store/current-vis-slice'
import {
getVisUiConfigOutputType,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useAppSelector, useMetadataStore } from '@hooks'
import { buildCurrentVisFromVisUiConfig } from '@modules/visualization/current-vis'
import { areVisualizationsEquivalent } from '@modules/visualization/state'
import { getCurrentVis } from '@store/current-vis-slice'
import { buildCurrentVisFromVisUiConfig } from '@store/thunks'
import { useMemo } from 'react'
import {
getAvailableOutputTypes,
Expand Down
2 changes: 1 addition & 1 deletion src/components/line-list/no-time-dimension-warning.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import i18n from '@dhis2/d2-i18n'
import { NoticeBox } from '@dhis2/ui'
import { isVisualizationWithTimeDimension } from '@modules/visualization/state'
import { isVisualizationWithTimeDimension } from '@modules/visualization/guards'
import type { CurrentVisualization } from '@types'
import { useMemo, type FC } from 'react'
import classes from './styles/no-time-dimension-warning.module.css'
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useCurrentUser, useAppSelector, useAppDispatch } from '@hooks'
import { isVisualizationEmpty } from '@modules/visualization/state'
import { isVisualizationEmpty } from '@modules/visualization/guards'
import type { Store } from '@reduxjs/toolkit'
import { getCurrentVis, setCurrentVis } from '@store/current-vis-slice'
import { getIsVisualizationLoading } from '@store/loader-slice'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ import { isAbortError } from '@modules/error/is-abort-error'
import { resolveLayoutContext } from '@modules/layout'
import { logger } from '@modules/logger'
import { isValueTypeNumeric } from '@modules/value-type'
import { isVisualizationWithTimeDimension } from '@modules/visualization/guards'
import { getSingleProgramFromVisualization } from '@modules/visualization/program'
import { isVisualizationWithTimeDimension } from '@modules/visualization/state'
import type {
CurrentUser,
CurrentVisualization,
Expand Down
2 changes: 1 addition & 1 deletion src/components/plugin-wrapper/plugin-wrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { CanvasErrorFallback } from '@components/canvas-error/canvas-error-fallb
import type { ColumnHeaderClickFn } from '@components/line-list/types'
import { Center, CircularLoader } from '@dhis2/ui'
import { assertNever } from '@modules/utils/guards'
import { isVisualizationEmpty } from '@modules/visualization/state'
import { isVisualizationEmpty } from '@modules/visualization/guards'
import type {
CurrentUser,
CurrentVisualization,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { eventVisualizationsApi } from '@api/event-visualizations-api'
import { toCurrentVis } from '@modules/visualization/state'
import { toCurrentVis } from '@modules/visualization/current-vis'
import { currentVisSlice } from '@store/current-vis-slice'
import { savedVisSlice } from '@store/saved-vis-slice'
import { renderHookWithReduxStoreProvider } from '@test-utils/render-with-redux-store-provider'
Expand Down
2 changes: 1 addition & 1 deletion src/components/toolbar/actions-bar/actions-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { useAppSelector, useCurrentUser } from '@hooks'
import {
isCurrentVisualizationPersisted,
isVisualizationPersistable,
} from '@modules/visualization/state'
} from '@modules/visualization/guards'
import { getCurrentVis } from '@store/current-vis-slice'
import { getSavedVis } from '@store/saved-vis-slice'
import type { SavedVisualization } from '@types'
Expand Down
2 changes: 1 addition & 1 deletion src/components/toolbar/actions-bar/file-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { useAppSelector } from '@hooks'
import {
isCurrentVisualizationPersisted,
isSavedVisualization,
} from '@modules/visualization/state'
} from '@modules/visualization/guards'
import { getCurrentVis } from '@store/current-vis-slice'
import { getSavedVis } from '@store/saved-vis-slice'
import { useMemo, type FC } from 'react'
Expand Down
2 changes: 1 addition & 1 deletion src/components/toolbar/actions-bar/save-button.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import i18n from '@dhis2/d2-i18n'
import { Button, IconSave16 } from '@dhis2/ui'
import { useAppSelector } from '@hooks'
import { isCurrentVisualizationPersisted } from '@modules/visualization/state'
import { isCurrentVisualizationPersisted } from '@modules/visualization/guards'
import { getCurrentVis } from '@store/current-vis-slice'
import { type FC } from 'react'
import classes from './styles/button.module.css'
Expand Down
6 changes: 3 additions & 3 deletions src/components/toolbar/actions-bar/use-toolbar-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,12 @@ import i18n from '@dhis2/d2-i18n'
import { useAppDispatch, useAppSelector } from '@hooks'
import { logger } from '@modules/logger'
import {
getSaveableVisualization,
getVisualizationState,
isCurrentVisualizationPersisted,
isVisualizationEmpty,
isVisualizationPersistable,
} from '@modules/visualization/state'
} from '@modules/visualization/guards'
import { getSaveableVisualization } from '@modules/visualization/save'
import { getVisualizationState } from '@modules/visualization/state'
import { getCurrentVis } from '@store/current-vis-slice'
import { setNavigationState } from '@store/navigation-slice'
import { getSavedVis, setSavedVisNameDescription } from '@store/saved-vis-slice'
Expand Down
6 changes: 2 additions & 4 deletions src/dashboard-plugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,8 @@ import { DashboardPluginWrapper } from '@dhis2/analytics'
// eslint-disable-next-line no-restricted-imports
import { useDataQuery } from '@dhis2/app-runtime'
import { logger } from '@modules/logger'
import {
normalizeApiSavedVisualization,
toCurrentVis,
} from '@modules/visualization/state'
import { toCurrentVis } from '@modules/visualization/current-vis'
import { normalizeApiSavedVisualization } from '@modules/visualization/normalize-legacy'
import type {
ApiSavedVisualization,
CurrentUser,
Expand Down
16 changes: 16 additions & 0 deletions src/modules/dimension/translation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
import type {
CurrentVisualization,
DimensionArray,
DimensionRecord,
OutputType,
VisualizationType,
} from '@types'
Expand All @@ -27,6 +28,21 @@ export const transformDimensions = (
return dimensionObj
})

export const removeDimensionPropertiesBeforeSaving = (
axis: DimensionArray
): DimensionArray => {
return axis.map((dim) => {
const dimension = { ...dim }
const propsToRemove = ['dimensionType', 'valueType']

propsToRemove.forEach((prop) => {
delete dimension[prop as keyof DimensionRecord]
})

return dimension
})
}

export const combineAllDimensionsFromVisualization = (
visualization: CurrentVisualization
): DimensionArray => [
Expand Down
8 changes: 3 additions & 5 deletions src/modules/visualization/__tests__/unapplied-changes.spec.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import { DEFAULT_OPTIONS } from '@constants/options'
import {
areVisualizationsEquivalent,
getVisualizationUiConfig,
} from '@modules/visualization/state'
import { buildCurrentVisFromVisUiConfig } from '@store/thunks'
import { buildCurrentVisFromVisUiConfig } from '@modules/visualization/current-vis'
import { areVisualizationsEquivalent } from '@modules/visualization/state'
import { getVisualizationUiConfig } from '@modules/visualization/ui-config'
import type { VisUiConfigState } from '@store/vis-ui-config-slice'
import { createMetadataStoreStub } from '@test-utils/metadata-store-stub'
import type {
Expand Down
12 changes: 7 additions & 5 deletions src/modules/visualization/__tests__/visualization.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@ import { DEFAULT_OPTIONS } from '@constants/options'
import { MetadataStore } from '@modules/metadata/store'
import { getDefaultOptions } from '@modules/options'
import {
getSaveableVisualization,
buildCurrentVisFromVisUiConfig,
toCurrentVis,
} from '@modules/visualization/current-vis'
import { normalizeApiSavedVisualization } from '@modules/visualization/normalize-legacy'
import { getSaveableVisualization } from '@modules/visualization/save'
import {
getVisualizationState,
getVisualizationUiConfig,
isDefaultOptionValue,
normalizeApiSavedVisualization,
toCurrentVis,
} from '@modules/visualization/state'
import { buildCurrentVisFromVisUiConfig } from '@store/thunks'
import { getVisualizationUiConfig } from '@modules/visualization/ui-config'
import type {
ApiSavedVisualization,
CurrentVisualization,
Expand Down
140 changes: 140 additions & 0 deletions src/modules/visualization/current-vis.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { DEFAULT_OPTIONS } from '@constants/options'
import {
buildAxis,
collectProgramDimensions,
resolveTeiFields,
} from '@modules/layout'
import { getEnabledOptions } from '@modules/options'
import type { CurrentVisState } from '@store/current-vis-slice'
import type { VisUiConfigState } from '@store/vis-ui-config-slice'
import type {
CurrentVisualization,
EventVisualizationOptions,
MetadataStore,
SavedVisualization,
} from '@types'
import { isCurrentVisualizationPersisted, isVisualizationEmpty } from './guards'

/* Keys on CurrentVisualization that are NOT part of EventVisualizationOptions.
* Combined with the option keys (derived from DEFAULT_OPTIONS) this gives the
* full set of CurrentVisualization keys at runtime. */
const CURRENT_VIS_NON_OPTION_KEYS: ReadonlyArray<
Exclude<keyof CurrentVisualization, keyof EventVisualizationOptions>
> = [
'type',
'outputType',
'columns',
'rows',
'filters',
'trackedEntityType',
'attributeDimensions',
'sorting',
'value',
'id',
'programDimensions',
]

const CURRENT_VIS_KEYS: ReadonlyArray<keyof CurrentVisualization> = [
...CURRENT_VIS_NON_OPTION_KEYS,
...(Object.keys(DEFAULT_OPTIONS) as Array<keyof EventVisualizationOptions>),
]

/**
* Extracts the CurrentVisualization-shaped subset of a SavedVisualization.
* A saved vis carries extra fields (access, createdBy, …) that the app never
* renders or compares.
*/
export const toCurrentVis = (
savedVis: SavedVisualization
): CurrentVisualization => {
const result: Record<string, unknown> = {}
for (const key of CURRENT_VIS_KEYS) {
if (savedVis[key] !== undefined) {
result[key] = savedVis[key]
}
}
return result as CurrentVisualization
}

const shouldPopulateCustomValueFields = (
currentVis: CurrentVisState,
visUiConfig: VisUiConfigState,
withCustomValue?: boolean
): boolean => {
// Only EVENT output can carry a custom value
if (visUiConfig.outputType !== 'EVENT') {
return false
}
if (withCustomValue !== undefined) {
return withCustomValue // explicit request: add or strip
}
return Boolean(currentVis.value?.id) // preserve what the current vis shows
}

const resolveCustomValueFields = (
currentVis: CurrentVisState,
visUiConfig: VisUiConfigState,
withCustomValue?: boolean
) => {
// Always include the `value` key: setCurrentVis merges into the previous
// currentVis, so omitting it would leave a stale value behind.
if (
!shouldPopulateCustomValueFields(
currentVis,
visUiConfig,
withCustomValue
)
) {
return { value: undefined, aggregationType: undefined }
}

const { customValue } = visUiConfig

if (!customValue) {
throw new Error(
'shouldPopulateCustomValueFields is true but visUiConfig.customValue is missing'
)
}
return {
value: { id: customValue.id },
aggregationType: customValue.aggregationType,
}
}

/* Rebuild a currentVis fresh from visUiConfig so stale currentVis fields can't
* leak through. Carries over only id and sorting from the previous currentVis.
* The custom value fields go after the options spread so the value's own
* aggregation type wins over the options default. `withCustomValue` overrides
* whether the result carries the custom value: true forces it on, false strips
* it; omit it to preserve the previous currentVis. */
export const buildCurrentVisFromVisUiConfig = ({
previousCurrentVis,
visUiConfig,
metadataStore,
withCustomValue,
}: {
previousCurrentVis: CurrentVisState
visUiConfig: VisUiConfigState
metadataStore: MetadataStore
withCustomValue?: boolean
}): CurrentVisualization => ({
id: isCurrentVisualizationPersisted(previousCurrentVis)
? previousCurrentVis.id
: undefined,
sorting: isVisualizationEmpty(previousCurrentVis)
? undefined
: previousCurrentVis.sorting,
type: visUiConfig.visualizationType,
outputType: visUiConfig.outputType,
columns: buildAxis(visUiConfig.layout.columns, visUiConfig, metadataStore),
rows: buildAxis(visUiConfig.layout.rows, visUiConfig, metadataStore),
filters: buildAxis(visUiConfig.layout.filters, visUiConfig, metadataStore),
programDimensions: collectProgramDimensions(visUiConfig, metadataStore),
...getEnabledOptions(visUiConfig.options),
...resolveTeiFields(visUiConfig, metadataStore),
...resolveCustomValueFields(
previousCurrentVis,
visUiConfig,
withCustomValue
),
})
Loading
Loading