diff --git a/CLAUDE.md b/CLAUDE.md index 0d7171d5..f99f253e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 @@ -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 diff --git a/src/api/event-visualizations-api.ts b/src/api/event-visualizations-api.ts index 9f664ae3..18d91a25 100644 --- a/src/api/event-visualizations-api.ts +++ b/src/api/event-visualizations-api.ts @@ -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, diff --git a/src/components/canvas/canvas.tsx b/src/components/canvas/canvas.tsx index 6bd1d409..c3479f98 100644 --- a/src/components/canvas/canvas.tsx +++ b/src/components/canvas/canvas.tsx @@ -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, diff --git a/src/components/download-menu/use-download.ts b/src/components/download-menu/use-download.ts index 6c9fe055..e2957d2a 100644 --- a/src/components/download-menu/use-download.ts +++ b/src/components/download-menu/use-download.ts @@ -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' diff --git a/src/components/interpretation-modal/interpretation-modal.tsx b/src/components/interpretation-modal/interpretation-modal.tsx index d3820a4b..b3eb2028 100644 --- a/src/components/interpretation-modal/interpretation-modal.tsx +++ b/src/components/interpretation-modal/interpretation-modal.tsx @@ -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' diff --git a/src/components/layout-panel/bottom-bar/action-buttons/use-action-button.ts b/src/components/layout-panel/bottom-bar/action-buttons/use-action-button.ts index c510f3a4..2fb9b6b7 100644 --- a/src/components/layout-panel/bottom-bar/action-buttons/use-action-button.ts +++ b/src/components/layout-panel/bottom-bar/action-buttons/use-action-button.ts @@ -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, diff --git a/src/components/layout-panel/bottom-bar/use-has-unapplied-changes.ts b/src/components/layout-panel/bottom-bar/use-has-unapplied-changes.ts index 17002ff3..7239373b 100644 --- a/src/components/layout-panel/bottom-bar/use-has-unapplied-changes.ts +++ b/src/components/layout-panel/bottom-bar/use-has-unapplied-changes.ts @@ -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, diff --git a/src/components/line-list/no-time-dimension-warning.tsx b/src/components/line-list/no-time-dimension-warning.tsx index 769073ee..11558872 100644 --- a/src/components/line-list/no-time-dimension-warning.tsx +++ b/src/components/line-list/no-time-dimension-warning.tsx @@ -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' diff --git a/src/components/plugin-wrapper/__tests__/plugin-wrapper.spec.tsx b/src/components/plugin-wrapper/__tests__/plugin-wrapper.spec.tsx index 9f2489df..3d63a1da 100644 --- a/src/components/plugin-wrapper/__tests__/plugin-wrapper.spec.tsx +++ b/src/components/plugin-wrapper/__tests__/plugin-wrapper.spec.tsx @@ -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' diff --git a/src/components/plugin-wrapper/hooks/use-line-list-analytics-data.ts b/src/components/plugin-wrapper/hooks/use-line-list-analytics-data.ts index 6c69df32..db972b39 100644 --- a/src/components/plugin-wrapper/hooks/use-line-list-analytics-data.ts +++ b/src/components/plugin-wrapper/hooks/use-line-list-analytics-data.ts @@ -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, diff --git a/src/components/plugin-wrapper/plugin-wrapper.tsx b/src/components/plugin-wrapper/plugin-wrapper.tsx index 374b606e..893a4d04 100644 --- a/src/components/plugin-wrapper/plugin-wrapper.tsx +++ b/src/components/plugin-wrapper/plugin-wrapper.tsx @@ -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, diff --git a/src/components/toolbar/actions-bar/__tests__/use-toolbar-actions.spec.ts b/src/components/toolbar/actions-bar/__tests__/use-toolbar-actions.spec.ts index 7142d55e..a51d32c9 100644 --- a/src/components/toolbar/actions-bar/__tests__/use-toolbar-actions.spec.ts +++ b/src/components/toolbar/actions-bar/__tests__/use-toolbar-actions.spec.ts @@ -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' diff --git a/src/components/toolbar/actions-bar/actions-bar.tsx b/src/components/toolbar/actions-bar/actions-bar.tsx index a778be06..15080113 100644 --- a/src/components/toolbar/actions-bar/actions-bar.tsx +++ b/src/components/toolbar/actions-bar/actions-bar.tsx @@ -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' diff --git a/src/components/toolbar/actions-bar/file-menu.tsx b/src/components/toolbar/actions-bar/file-menu.tsx index 0f33733d..5e521925 100644 --- a/src/components/toolbar/actions-bar/file-menu.tsx +++ b/src/components/toolbar/actions-bar/file-menu.tsx @@ -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' diff --git a/src/components/toolbar/actions-bar/save-button.tsx b/src/components/toolbar/actions-bar/save-button.tsx index ff9768f4..4582880b 100644 --- a/src/components/toolbar/actions-bar/save-button.tsx +++ b/src/components/toolbar/actions-bar/save-button.tsx @@ -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' diff --git a/src/components/toolbar/actions-bar/use-toolbar-actions.ts b/src/components/toolbar/actions-bar/use-toolbar-actions.ts index c76b86a4..7b7c7ddd 100644 --- a/src/components/toolbar/actions-bar/use-toolbar-actions.ts +++ b/src/components/toolbar/actions-bar/use-toolbar-actions.ts @@ -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' diff --git a/src/dashboard-plugin.tsx b/src/dashboard-plugin.tsx index 9efdefb5..da0f0389 100644 --- a/src/dashboard-plugin.tsx +++ b/src/dashboard-plugin.tsx @@ -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, diff --git a/src/modules/dimension/translation.ts b/src/modules/dimension/translation.ts index 0ff5be9a..4b196f2b 100644 --- a/src/modules/dimension/translation.ts +++ b/src/modules/dimension/translation.ts @@ -6,6 +6,7 @@ import { import type { CurrentVisualization, DimensionArray, + DimensionRecord, OutputType, VisualizationType, } from '@types' @@ -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 => [ diff --git a/src/modules/visualization/__tests__/unapplied-changes.spec.ts b/src/modules/visualization/__tests__/unapplied-changes.spec.ts index 83bee532..1908fbea 100644 --- a/src/modules/visualization/__tests__/unapplied-changes.spec.ts +++ b/src/modules/visualization/__tests__/unapplied-changes.spec.ts @@ -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 { diff --git a/src/modules/visualization/__tests__/visualization.spec.ts b/src/modules/visualization/__tests__/visualization.spec.ts index 987065d2..6be1ab8a 100644 --- a/src/modules/visualization/__tests__/visualization.spec.ts +++ b/src/modules/visualization/__tests__/visualization.spec.ts @@ -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, diff --git a/src/modules/visualization/current-vis.ts b/src/modules/visualization/current-vis.ts new file mode 100644 index 00000000..df148368 --- /dev/null +++ b/src/modules/visualization/current-vis.ts @@ -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 +> = [ + 'type', + 'outputType', + 'columns', + 'rows', + 'filters', + 'trackedEntityType', + 'attributeDimensions', + 'sorting', + 'value', + 'id', + 'programDimensions', +] + +const CURRENT_VIS_KEYS: ReadonlyArray = [ + ...CURRENT_VIS_NON_OPTION_KEYS, + ...(Object.keys(DEFAULT_OPTIONS) as Array), +] + +/** + * 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 = {} + 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 + ), +}) diff --git a/src/modules/visualization/guards.ts b/src/modules/visualization/guards.ts new file mode 100644 index 00000000..dda27177 --- /dev/null +++ b/src/modules/visualization/guards.ts @@ -0,0 +1,87 @@ +import { layoutGetAllDimensions } from '@dhis2/analytics' +import { isTimeDimensionId } from '@modules/dimension/time' +import type { + CurrentVisualization, + EmptyVisualization, + SavedVisualization, +} from '@types' + +const getProgramDimensionsCount = ( + visualization: CurrentVisualization | EmptyVisualization +): number => { + if (!('programDimensions' in visualization)) { + return 0 + } + return visualization.programDimensions?.length ?? 0 +} + +const visualizationHasProgramId = ( + visualization: CurrentVisualization | EmptyVisualization +): boolean => getProgramDimensionsCount(visualization) > 0 + +const visualizationHasTrackedEntityTypeId = ( + visualization: CurrentVisualization | EmptyVisualization +): boolean => Boolean(visualization?.trackedEntityType?.id) + +// Shape check: does the visualization carry the minimum fields required for +// the API to accept a save payload (POST or PUT) +export const isVisualizationPersistable = ( + visualization: CurrentVisualization | EmptyVisualization +): boolean => + visualization.outputType === 'TRACKED_ENTITY_INSTANCE' + ? visualizationHasTrackedEntityTypeId(visualization) + : visualizationHasProgramId(visualization) + +export const isVisualizationWithTimeDimension = (vis: CurrentVisualization) => + layoutGetAllDimensions(vis).some( + ({ dimensionType, dimension, items }) => + (dimensionType === 'PERIOD' || isTimeDimensionId(dimension)) && + Array.isArray(items) && + items.length > 0 + ) + +export const isVisualizationEmpty = ( + visualization: + CurrentVisualization | SavedVisualization | EmptyVisualization +): visualization is EmptyVisualization => + Object.keys(visualization).length === 0 + +// Structural check for the minimal fields shared by CurrentVisualization and +// SavedVisualization. Declaring the return as the union lets TypeScript +// narrow each slice input to its specific member (Empty is excluded either +// way), so we get useful narrowing in both currentVis and savedVis contexts +// without resorting to overloads. +const isPopulatedVisualization = ( + visualization: + CurrentVisualization | SavedVisualization | EmptyVisualization +): visualization is SavedVisualization | CurrentVisualization => { + const candidate = visualization as Partial + return ( + typeof candidate.type === 'string' && + Array.isArray(candidate.columns) && + Array.isArray(candidate.rows) && + Array.isArray(candidate.filters) + ) +} + +export const isSavedVisualization = ( + visualization: SavedVisualization | EmptyVisualization +): visualization is SavedVisualization => + isPopulatedVisualization(visualization) && + typeof visualization.id === 'string' && + // `access` is SavedVisualization-only: CurrentVisualization doesn't carry + // it, so its presence distinguishes a full saved vis from a persisted + // currentVis that merely has an id. + 'access' in visualization + +export const isCurrentVisualizationPersisted = ( + visualization: CurrentVisualization | EmptyVisualization +): visualization is CurrentVisualization & { id: string } => + isPopulatedVisualization(visualization) && + typeof visualization.id === 'string' + +export const isCurrentVisualizationNew = ( + visualization: CurrentVisualization | EmptyVisualization +): visualization is CurrentVisualization => + isPopulatedVisualization(visualization) && + typeof visualization.id !== 'string' diff --git a/src/modules/visualization/normalize-legacy.ts b/src/modules/visualization/normalize-legacy.ts new file mode 100644 index 00000000..1bb7b363 --- /dev/null +++ b/src/modules/visualization/normalize-legacy.ts @@ -0,0 +1,297 @@ +import { + CONTEXTLESS_DIMENSION_TYPES, + ENROLLMENT_SCOPED_DIMENSION_IDS, + META_DIMENSION_IDS, + WIRE_ONLY_DIMENSIONS, +} from '@modules/dimension/ids' +import { + KNOWN_TIME_FIELD_VALUES, + outputTypeTimeDimensionMap, + timeFieldTimeDimensionMap, +} from '@modules/dimension/time' +import { toEventVisualizationDimensionId } from '@modules/dimension/translation' +import type { + ApiSavedVisualization, + DimensionId, + DimensionRecord, + OutputType, + SavedVisualization, + VisualizationType, +} from '@types' + +/* Dimension types whose values are not bound to any program or stage — + * program indicators and tracked entity attributes are owned by a program + * in the metadata model but their analytics IDs are plain (never carry + * program/stage prefixes). Combined with CONTEXTLESS_DIMENSION_TYPES (eg. + * organisation unit group sets), this is the set we must never decorate + * with the legacy top-level program/programStage refs. */ +const NO_CONTEXT_DIMENSION_TYPES: ReadonlySet = new Set([ + 'PROGRAM_INDICATOR', + 'PROGRAM_ATTRIBUTE', + ...CONTEXTLESS_DIMENSION_TYPES, +]) + +/* Old dimension IDs (created by the legacy event-visualizer / Event Reports + * app) mapped onto the canonical IDs this app and the backend analytics API + * use. `createdDate` is a genuine persisted alias of `created`; the other two + * are normalised defensively (the backend never persists them). */ +const LEGACY_DIMENSION_ID_RENAMES: Record = { + createdDate: 'created', + completedDate: 'completed', + lastUpdatedOn: 'lastUpdated', +} + +/* The top-level source context the per-dimension steps read while normalising. + * It is not part of the output vis — the legacy top-level program/programStage + * and timeField are consumed here and dropped from the result. */ +type LegacyDimensionContext = { + outputType: OutputType | undefined + visualizationType: VisualizationType + timeField: string | undefined + programRef: { id: string } | undefined + stageRef: { id: string } | undefined +} + +// Legacy line-listing stored the period as a bare `pe`; turn it into the +// concrete time dimension the app uses. +const materializeLegacyPeDimension = ( + dim: DimensionRecord, + context: LegacyDimensionContext, + vis: SavedVisualization +): DimensionRecord => { + if (dim.dimension !== 'pe') { + return dim + } + const targetDim = + (context.timeField && timeFieldTimeDimensionMap[context.timeField]) || + (context.outputType && outputTypeTimeDimensionMap[context.outputType]) + if (!targetDim) { + return dim + } + vis.legacy = true + return { ...dim, dimension: targetDim, dimensionType: 'PERIOD' } +} + +const renameLegacyDimensionId = ( + dim: DimensionRecord, + vis: SavedVisualization +): DimensionRecord => { + const renamed = LEGACY_DIMENSION_ID_RENAMES[dim.dimension] + if (!renamed) { + return dim + } + vis.legacy = true + return { ...dim, dimension: renamed } +} + +// Meta dims, contextless dim types, program indicators and tracked entity +// attributes don't carry program/stage context. +const dimensionTakesNoProgramContext = (dim: DimensionRecord): boolean => + META_DIMENSION_IDS.has(dim.dimension) || + (typeof dim.dimensionType === 'string' && + NO_CONTEXT_DIMENSION_TYPES.has(dim.dimensionType)) + +// Propagate the old event-visualizer top-level program/programStage onto a +// dimension that doesn't carry them. Enrollment-scoped IDs are tied to the +// program, not a stage, so they get program only. This doesn't flip `legacy`: +// the top-level program/programStage presence already seeded it. +const applyProgramStageContext = ( + dim: DimensionRecord, + { programRef, stageRef }: LegacyDimensionContext +): DimensionRecord => { + let out = dim + if (programRef && !out.program) { + out = { ...out, program: programRef } + } + const skipStageRef = ENROLLMENT_SCOPED_DIMENSION_IDS.has(out.dimension) + if (!skipStageRef && stageRef && !out.programStage) { + out = { ...out, programStage: stageRef } + } + return out +} + +// A legacy vis stored the enrollment org unit as bare `ou`; this app uses +// `enrollmentOu` where the wire form needs it (EVENT/TEI LINE_LIST). Upgrade +// with the same rule the save path uses — a no-op for ENROLLMENT/PIVOT (where +// `ou` is canonical) and for the stage event OU (which has a programStage). +const upgradeLegacyEnrollmentOu = ( + dim: DimensionRecord, + context: LegacyDimensionContext, + vis: SavedVisualization +): DimensionRecord => { + if ( + !context.outputType || + dim.dimension !== 'ou' || + !dim.program?.id || + dim.programStage + ) { + return dim + } + const canonicalOu = toEventVisualizationDimensionId({ + dimensionId: 'enrollmentOu', + programId: dim.program.id, + outputType: context.outputType, + visualizationType: context.visualizationType, + }) + if (canonicalOu === dim.dimension) { + return dim + } + vis.legacy = true + return { ...dim, dimension: canonicalOu } +} + +/* Normalise one dimension, in order: materialise a legacy `pe`, rename old IDs, + * then — unless the dim takes no program/stage context — propagate top-level + * program/programStage and upgrade a legacy enrollment `ou`. The rename runs + * before the context check so a meta dim renamed from a legacy ID (e.g. + * `createdDate` → `created`) is recognised as context-free. Steps flip + * `vis.legacy` as they upgrade the shape. */ +const normalizeLegacyDimension = ( + dim: DimensionRecord, + context: LegacyDimensionContext, + vis: SavedVisualization +): DimensionRecord => { + const renamed = renameLegacyDimensionId( + materializeLegacyPeDimension(dim, context, vis), + vis + ) + if (dimensionTakesNoProgramContext(renamed)) { + return renamed + } + return upgradeLegacyEnrollmentOu( + applyProgramStageContext(renamed, context), + context, + vis + ) +} + +/** + * Legacy → canonical normalisation for saved visualizations received from the + * eventVisualizations API. Converts the legacy shapes (old line-listing + * `legacy: true`, old event-visualizer top-level program/programStage, and old + * dimension IDs) into the canonical shape this app persists. + * + * Scope: + * - Propagate top-level program/programStage onto individual dimensions + * - Ensure `programDimensions` includes the top-level program + * - Convert legacy `pe` dimension into the proper time dimension + * - Rename old dimension IDs (`createdDate`/`completedDate`/`lastUpdatedOn`) + * to their canonical form + * - Convert legacy `orgUnitField` into an `ou` filter + * - Convert top-level `programStatus` into a `programStatus` filter dimension + * - Drop `timeField` when it holds a known backend enum value (e.g. + * `EVENT_DATE`) — the corresponding "which column" information is now + * encoded in the concrete time dimension produced above, so leaving + * `timeField` would duplicate it. Preserve `timeField` when it holds a + * data-element / attribute UID, since that's still a live analytics + * parameter + * - Drop top-level `program` and `programStage` + * - Drop the wire-only `dy`/`latitude`/`longitude` dimensions + * - Mark output as `legacy: true` whenever any of the above upgraded the + * persisted shape, so the vis cannot be overwritten in place — only "Save + * as" is allowed. Overwriting would silently persist in the canonical + * format, breaking older apps that still read the legacy shape. + * + * Out of scope (handled downstream — these run on every load, not just legacy + * visualizations, so they do not imply the `legacy` flag): + * - `completedOnly` → `eventStatus=COMPLETED` filter (not legacy-only) + * - `PROGRAM_DATA_ELEMENT` → `DATA_ELEMENT` (wire → app shape) + */ +export const normalizeApiSavedVisualization = ( + apiVis: ApiSavedVisualization +): SavedVisualization => { + const { + program, + programStage, + orgUnitField, + timeField, + legacy, + programStatus, + columns = [], + rows = [], + filters = [], + sortOrder, + topLimit, + ...rest + } = apiVis + const normalizedVis = rest as SavedVisualization + const context: LegacyDimensionContext = { + outputType: rest.outputType as OutputType | undefined, + visualizationType: rest.type as VisualizationType, + timeField, + programRef: program ? { id: program.id } : undefined, + stageRef: programStage ? { id: programStage.id } : undefined, + } + + // Legacy when a top-level signal is present, or a step below upgrades the + // shape. A legacy vis can't be saved in place — that would rewrite it in + // the canonical format and break older apps that read the original. + if (legacy || program || programStage || orgUnitField || programStatus) { + normalizedVis.legacy = true + } + + const rawFilters = [ + ...filters, + ...(orgUnitField + ? [{ dimension: 'ou', items: [{ id: orgUnitField }] }] + : []), + ...(programStatus + ? [{ dimension: 'programStatus', items: [{ id: programStatus }] }] + : []), + ] + + /* Wire-only dimensions mark where a legacy Event Report put its value + * column; the app expresses that with the custom value fields instead, and + * carries no layout position for them. Dropping them changes the persisted + * shape, so it flips `legacy` like every other upgrade here. */ + const dropWireOnlyDimensions = (dims: DimensionRecord[]) => { + const kept = dims.filter( + (dim) => !WIRE_ONLY_DIMENSIONS.has(dim.dimension) + ) + if (kept.length !== dims.length) { + normalizedVis.legacy = true + } + return kept + } + + normalizedVis.columns = dropWireOnlyDimensions(columns).map((dim) => + normalizeLegacyDimension(dim, context, normalizedVis) + ) + normalizedVis.rows = dropWireOnlyDimensions(rows).map((dim) => + normalizeLegacyDimension(dim, context, normalizedVis) + ) + normalizedVis.filters = dropWireOnlyDimensions(rawFilters).map((dim) => + normalizeLegacyDimension(dim, context, normalizedVis) + ) + + if ( + program && + !normalizedVis.programDimensions?.some((p) => p.id === program.id) + ) { + normalizedVis.programDimensions = [ + ...(normalizedVis.programDimensions ?? []), + program, + ] + } + + // `timeField` holding a known backend enum value has been materialised + // into a concrete time dimension above; keep it only when it holds a + // data-element / attribute UID (non-legacy usage that the analytics + // request still needs). Dropping a known-enum `timeField` is an upgrade. + const preserveTimeField = + typeof timeField === 'string' && !KNOWN_TIME_FIELD_VALUES.has(timeField) + if (preserveTimeField) { + normalizedVis.timeField = timeField + } else if (typeof timeField === 'string') { + normalizedVis.legacy = true + } + + if (sortOrder !== 0) { + normalizedVis.sortOrder = sortOrder + } + if (topLimit !== 0) { + normalizedVis.topLimit = topLimit + } + + return normalizedVis +} diff --git a/src/modules/visualization/save.ts b/src/modules/visualization/save.ts new file mode 100644 index 00000000..1fc7e5de --- /dev/null +++ b/src/modules/visualization/save.ts @@ -0,0 +1,55 @@ +import { getHeadersMap } from '@modules/analytics-request' +import { removeDimensionPropertiesBeforeSaving } from '@modules/dimension/translation' +import type { + CurrentVisualization, + SavedVisualization, + SortDirection, +} from '@types' + +const getDimensionIdFromHeaderName = ( + headerName: string, + visualization: CurrentVisualization +) => + Object.entries(getHeadersMap(visualization)).find( + ([, value]) => value === headerName + )?.[0] + +export const getSaveableVisualization = ( + vis: SavedVisualization +): SavedVisualization => { + const visualization = { ...vis } + + visualization.columns = removeDimensionPropertiesBeforeSaving( + visualization.columns + ) + visualization.filters = removeDimensionPropertiesBeforeSaving( + visualization.filters + ) + visualization.rows = removeDimensionPropertiesBeforeSaving( + visualization.rows + ) + + // Use the first sorting item only and format for saving + const sorting = vis.sorting?.length + ? [ + { + dimension: + getDimensionIdFromHeaderName( + vis.sorting[0].dimension, + vis + ) || vis.sorting[0].dimension, + direction: vis.sorting[0].direction + ? (vis.sorting[0].direction.toUpperCase() as SortDirection) + : 'ASC', + }, + ] + : undefined + + const result: Partial = { + ...visualization, + sorting, + } + // Remove legacy flag when saving — a legacy-loaded vis is re-saved in the new format. + delete result.legacy + return result as SavedVisualization +} diff --git a/src/modules/visualization/state.ts b/src/modules/visualization/state.ts index e886b6d9..07201467 100644 --- a/src/modules/visualization/state.ts +++ b/src/modules/visualization/state.ts @@ -1,118 +1,16 @@ import { AXES } from '@constants/axis' import { DEFAULT_OPTIONS } from '@constants/options' -import { layoutGetAllDimensions } from '@dhis2/analytics' -import { getHeadersMap } from '@modules/analytics-request' -import { getConditionsFromVisualization } from '@modules/conditions' -import { - CONTEXTLESS_DIMENSION_TYPES, - ENROLLMENT_SCOPED_DIMENSION_IDS, - getCompoundDimensionId, - META_DIMENSION_IDS, - WIRE_ONLY_DIMENSIONS, -} from '@modules/dimension/ids' -import { - isTimeDimensionId, - KNOWN_TIME_FIELD_VALUES, - outputTypeTimeDimensionMap, - timeFieldTimeDimensionMap, -} from '@modules/dimension/time' -import { - toAppLocalDimensions, - toEventVisualizationDimensionId, -} from '@modules/dimension/translation' -import { getRepetitionsFromVisualisation } from '@modules/repetitions' +import { removeDimensionPropertiesBeforeSaving } from '@modules/dimension/translation' import type { - ApiSavedVisualization, CurrentVisualization, DimensionArray, - DimensionId, - DimensionRecord, EmptyVisualization, - EventVisualizationOptions, - OutputType, SavedVisualization, - SortDirection, VisualizationState, - VisualizationType, } from '@types' import deepEqual from 'deep-equal' - -const getProgramDimensionsCount = ( - visualization: CurrentVisualization | EmptyVisualization -): number => { - if (!('programDimensions' in visualization)) { - return 0 - } - return visualization.programDimensions?.length ?? 0 -} - -const visualizationHasProgramId = ( - visualization: CurrentVisualization | EmptyVisualization -): boolean => getProgramDimensionsCount(visualization) > 0 - -const visualizationHasTrackedEntityTypeId = ( - visualization: CurrentVisualization | EmptyVisualization -): boolean => Boolean(visualization?.trackedEntityType?.id) - -// Shape check: does the visualization carry the minimum fields required for -// the API to accept a save payload (POST or PUT) -export const isVisualizationPersistable = ( - visualization: CurrentVisualization | EmptyVisualization -): boolean => - visualization.outputType === 'TRACKED_ENTITY_INSTANCE' - ? visualizationHasTrackedEntityTypeId(visualization) - : visualizationHasProgramId(visualization) - -export const isVisualizationWithTimeDimension = (vis: CurrentVisualization) => - layoutGetAllDimensions(vis).some( - ({ dimensionType, dimension, items }) => - (dimensionType === 'PERIOD' || isTimeDimensionId(dimension)) && - Array.isArray(items) && - items.length > 0 - ) - -// Keys on CurrentVisualization that are NOT part of EventVisualizationOptions. -// Combined with the option keys (derived from DEFAULT_OPTIONS below) this -// gives the full set of CurrentVisualization keys at runtime. -const CURRENT_VIS_NON_OPTION_KEYS: ReadonlyArray< - Exclude -> = [ - 'type', - 'outputType', - 'columns', - 'rows', - 'filters', - 'trackedEntityType', - 'attributeDimensions', - 'sorting', - 'value', - 'id', - 'programDimensions', -] - -const CURRENT_VIS_KEYS: ReadonlyArray = [ - ...CURRENT_VIS_NON_OPTION_KEYS, - ...(Object.keys(DEFAULT_OPTIONS) as Array), -] - -/** - * Extracts the CurrentVisualization-shaped subset of a SavedVisualization. - * Used to compare a saved visualization to the current (edited) one — - * the current vis is already in CurrentVisualization shape, but the saved - * vis carries extra fields (access, createdBy, …) that we don't care about - * when determining whether there are unsaved changes. - */ -export const toCurrentVis = ( - savedVis: SavedVisualization -): CurrentVisualization => { - const result: Record = {} - for (const key of CURRENT_VIS_KEYS) { - if (savedVis[key] !== undefined) { - result[key] = savedVis[key] - } - } - return result as CurrentVisualization -} +import { toCurrentVis } from './current-vis' +import { isVisualizationEmpty } from './guards' /* Derived from the layout: any real change is already caught by comparing the * axes, so comparing these adds nothing. And the two array fields @@ -227,458 +125,3 @@ export const getVisualizationState = ( return 'DIRTY' } } - -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 - }) -} - -const getDimensionIdFromHeaderName = ( - headerName: string, - visualization: CurrentVisualization -) => - Object.entries(getHeadersMap(visualization)).find( - ([, value]) => value === headerName - )?.[0] - -export const getSaveableVisualization = ( - vis: SavedVisualization -): SavedVisualization => { - const visualization = { ...vis } - - visualization.columns = removeDimensionPropertiesBeforeSaving( - visualization.columns - ) - visualization.filters = removeDimensionPropertiesBeforeSaving( - visualization.filters - ) - visualization.rows = removeDimensionPropertiesBeforeSaving( - visualization.rows - ) - - // Use the first sorting item only and format for saving - const sorting = vis.sorting?.length - ? [ - { - dimension: - getDimensionIdFromHeaderName( - vis.sorting[0].dimension, - vis - ) || vis.sorting[0].dimension, - direction: vis.sorting[0].direction - ? (vis.sorting[0].direction.toUpperCase() as SortDirection) - : 'ASC', - }, - ] - : undefined - - const result: Partial = { - ...visualization, - sorting, - } - // Remove legacy flag when saving — a legacy-loaded vis is re-saved in the new format. - delete result.legacy - return result as SavedVisualization -} - -export const isVisualizationEmpty = ( - visualization: - CurrentVisualization | SavedVisualization | EmptyVisualization -): visualization is EmptyVisualization => - Object.keys(visualization).length === 0 - -// Structural check for the minimal fields shared by CurrentVisualization and -// SavedVisualization. Declaring the return as the union lets TypeScript -// narrow each slice input to its specific member (Empty is excluded either -// way), so we get useful narrowing in both currentVis and savedVis contexts -// without resorting to overloads. -const isPopulatedVisualization = ( - visualization: - CurrentVisualization | SavedVisualization | EmptyVisualization -): visualization is SavedVisualization | CurrentVisualization => { - const candidate = visualization as Partial - return ( - typeof candidate.type === 'string' && - Array.isArray(candidate.columns) && - Array.isArray(candidate.rows) && - Array.isArray(candidate.filters) - ) -} - -export const isSavedVisualization = ( - visualization: SavedVisualization | EmptyVisualization -): visualization is SavedVisualization => - isPopulatedVisualization(visualization) && - typeof visualization.id === 'string' && - // `access` is SavedVisualization-only: CurrentVisualization doesn't carry - // it, so its presence distinguishes a full saved vis from a persisted - // currentVis that merely has an id. - 'access' in visualization - -export const isCurrentVisualizationPersisted = ( - visualization: CurrentVisualization | EmptyVisualization -): visualization is CurrentVisualization & { id: string } => - isPopulatedVisualization(visualization) && - typeof visualization.id === 'string' - -export const isCurrentVisualizationNew = ( - visualization: CurrentVisualization | EmptyVisualization -): visualization is CurrentVisualization => - isPopulatedVisualization(visualization) && - typeof visualization.id !== 'string' - -const toAppLocalAxes = (dims: DimensionArray): DimensionArray => - toAppLocalDimensions( - dims.filter((dim) => !WIRE_ONLY_DIMENSIONS.has(dim.dimension)) - ) - -const OPTION_KEYS = Object.keys(DEFAULT_OPTIONS) as Array< - keyof EventVisualizationOptions -> - -const extractOptions = ( - vis: CurrentVisualization -): Partial => { - const extracted: Record = {} - for (const key of OPTION_KEYS) { - if (vis[key] !== undefined) { - extracted[key] = vis[key] - } - } - return extracted as Partial -} - -export const getVisualizationUiConfig = ( - raw: CurrentVisualization, - baseOptions: EventVisualizationOptions = DEFAULT_OPTIONS -) => { - const vis: CurrentVisualization = { - ...raw, - columns: toAppLocalAxes(raw.columns ?? []), - rows: toAppLocalAxes(raw.rows ?? []), - filters: toAppLocalAxes(raw.filters ?? []), - } - const outputType = vis.outputType - const tetId = vis.trackedEntityType?.id - const toDimId = (dim: DimensionArray[number]) => - getCompoundDimensionId(dim, outputType, tetId) - - return { - visualizationType: vis.type, - outputType, - layout: { - columns: (vis.columns ?? []).map(toDimId), - filters: (vis.filters ?? []).map(toDimId), - rows: (vis.rows ?? []).map(toDimId), - }, - itemsByDimension: [ - ...(vis.columns ?? []), - ...(vis.rows ?? []), - ...(vis.filters ?? []), - ].reduce( - (obj, dim) => { - obj[toDimId(dim)] = (dim.items ?? []) - .map((item) => item.id) - .filter(Boolean) as string[] - return obj - }, - {} as Record - ), - conditionsByDimension: getConditionsFromVisualization(vis, outputType), - repetitionsByDimension: getRepetitionsFromVisualisation(vis), - options: { ...baseOptions, ...extractOptions(vis) }, - ...(vis.value?.id && { - customValue: { - id: vis.value.id, - aggregationType: vis.aggregationType || 'DEFAULT', - }, - }), - } -} - -/* Dimension types whose values are not bound to any program or stage — - * program indicators and tracked entity attributes are owned by a program - * in the metadata model but their analytics IDs are plain (never carry - * program/stage prefixes). Combined with CONTEXTLESS_DIMENSION_TYPES (eg. - * organisation unit group sets), this is the set we must never decorate - * with the legacy top-level program/programStage refs. */ -const NO_CONTEXT_DIMENSION_TYPES: ReadonlySet = new Set([ - 'PROGRAM_INDICATOR', - 'PROGRAM_ATTRIBUTE', - ...CONTEXTLESS_DIMENSION_TYPES, -]) - -/* Old dimension IDs (created by the legacy event-visualizer / Event Reports - * app) mapped onto the canonical IDs this app and the backend analytics API - * use. `createdDate` is a genuine persisted alias of `created`; the other two - * are normalised defensively (the backend never persists them). */ -const LEGACY_DIMENSION_ID_RENAMES: Record = { - createdDate: 'created', - completedDate: 'completed', - lastUpdatedOn: 'lastUpdated', -} - -/* The top-level source context the per-dimension steps read while normalising. - * It is not part of the output vis — the legacy top-level program/programStage - * and timeField are consumed here and dropped from the result. */ -type LegacyDimensionContext = { - outputType: OutputType | undefined - visualizationType: VisualizationType - timeField: string | undefined - programRef: { id: string } | undefined - stageRef: { id: string } | undefined -} - -// Legacy line-listing stored the period as a bare `pe`; turn it into the -// concrete time dimension the app uses. -const materializeLegacyPeDimension = ( - dim: DimensionRecord, - context: LegacyDimensionContext, - vis: SavedVisualization -): DimensionRecord => { - if (dim.dimension !== 'pe') { - return dim - } - const targetDim = - (context.timeField && timeFieldTimeDimensionMap[context.timeField]) || - (context.outputType && outputTypeTimeDimensionMap[context.outputType]) - if (!targetDim) { - return dim - } - vis.legacy = true - return { ...dim, dimension: targetDim, dimensionType: 'PERIOD' } -} - -const renameLegacyDimensionId = ( - dim: DimensionRecord, - vis: SavedVisualization -): DimensionRecord => { - const renamed = LEGACY_DIMENSION_ID_RENAMES[dim.dimension] - if (!renamed) { - return dim - } - vis.legacy = true - return { ...dim, dimension: renamed } -} - -// Meta dims, contextless dim types, program indicators and tracked entity -// attributes don't carry program/stage context. -const dimensionTakesNoProgramContext = (dim: DimensionRecord): boolean => - META_DIMENSION_IDS.has(dim.dimension) || - (typeof dim.dimensionType === 'string' && - NO_CONTEXT_DIMENSION_TYPES.has(dim.dimensionType)) - -// Propagate the old event-visualizer top-level program/programStage onto a -// dimension that doesn't carry them. Enrollment-scoped IDs are tied to the -// program, not a stage, so they get program only. This doesn't flip `legacy`: -// the top-level program/programStage presence already seeded it. -const applyProgramStageContext = ( - dim: DimensionRecord, - { programRef, stageRef }: LegacyDimensionContext -): DimensionRecord => { - let out = dim - if (programRef && !out.program) { - out = { ...out, program: programRef } - } - const skipStageRef = ENROLLMENT_SCOPED_DIMENSION_IDS.has(out.dimension) - if (!skipStageRef && stageRef && !out.programStage) { - out = { ...out, programStage: stageRef } - } - return out -} - -// A legacy vis stored the enrollment org unit as bare `ou`; this app uses -// `enrollmentOu` where the wire form needs it (EVENT/TEI LINE_LIST). Upgrade -// with the same rule the save path uses — a no-op for ENROLLMENT/PIVOT (where -// `ou` is canonical) and for the stage event OU (which has a programStage). -const upgradeLegacyEnrollmentOu = ( - dim: DimensionRecord, - context: LegacyDimensionContext, - vis: SavedVisualization -): DimensionRecord => { - if ( - !context.outputType || - dim.dimension !== 'ou' || - !dim.program?.id || - dim.programStage - ) { - return dim - } - const canonicalOu = toEventVisualizationDimensionId({ - dimensionId: 'enrollmentOu', - programId: dim.program.id, - outputType: context.outputType, - visualizationType: context.visualizationType, - }) - if (canonicalOu === dim.dimension) { - return dim - } - vis.legacy = true - return { ...dim, dimension: canonicalOu } -} - -/* Normalise one dimension, in order: materialise a legacy `pe`, rename old IDs, - * then — unless the dim takes no program/stage context — propagate top-level - * program/programStage and upgrade a legacy enrollment `ou`. The rename runs - * before the context check so a meta dim renamed from a legacy ID (e.g. - * `createdDate` → `created`) is recognised as context-free. Steps flip - * `vis.legacy` as they upgrade the shape. */ -const normalizeLegacyDimension = ( - dim: DimensionRecord, - context: LegacyDimensionContext, - vis: SavedVisualization -): DimensionRecord => { - const renamed = renameLegacyDimensionId( - materializeLegacyPeDimension(dim, context, vis), - vis - ) - if (dimensionTakesNoProgramContext(renamed)) { - return renamed - } - return upgradeLegacyEnrollmentOu( - applyProgramStageContext(renamed, context), - context, - vis - ) -} - -/** - * Legacy → canonical normalisation for saved visualizations received from the - * eventVisualizations API. Converts the legacy shapes (old line-listing - * `legacy: true`, old event-visualizer top-level program/programStage, and old - * dimension IDs) into the canonical shape this app persists. - * - * Scope: - * - Propagate top-level program/programStage onto individual dimensions - * - Ensure `programDimensions` includes the top-level program - * - Convert legacy `pe` dimension into the proper time dimension - * - Rename old dimension IDs (`createdDate`/`completedDate`/`lastUpdatedOn`) - * to their canonical form - * - Convert legacy `orgUnitField` into an `ou` filter - * - Convert top-level `programStatus` into a `programStatus` filter dimension - * - Drop `timeField` when it holds a known backend enum value (e.g. - * `EVENT_DATE`) — the corresponding "which column" information is now - * encoded in the concrete time dimension produced above, so leaving - * `timeField` would duplicate it. Preserve `timeField` when it holds a - * data-element / attribute UID, since that's still a live analytics - * parameter - * - Drop top-level `program` and `programStage` - * - Drop the wire-only `dy`/`latitude`/`longitude` dimensions - * - Mark output as `legacy: true` whenever any of the above upgraded the - * persisted shape, so the vis cannot be overwritten in place — only "Save - * as" is allowed. Overwriting would silently persist in the canonical - * format, breaking older apps that still read the legacy shape. - * - * Out of scope (handled downstream — these run on every load, not just legacy - * visualizations, so they do not imply the `legacy` flag): - * - `completedOnly` → `eventStatus=COMPLETED` filter (not legacy-only) - * - `PROGRAM_DATA_ELEMENT` → `DATA_ELEMENT` (wire → app shape) - */ -export const normalizeApiSavedVisualization = ( - apiVis: ApiSavedVisualization -): SavedVisualization => { - const { - program, - programStage, - orgUnitField, - timeField, - legacy, - programStatus, - columns = [], - rows = [], - filters = [], - sortOrder, - topLimit, - ...rest - } = apiVis - const normalizedVis = rest as SavedVisualization - const context: LegacyDimensionContext = { - outputType: rest.outputType as OutputType | undefined, - visualizationType: rest.type as VisualizationType, - timeField, - programRef: program ? { id: program.id } : undefined, - stageRef: programStage ? { id: programStage.id } : undefined, - } - - // Legacy when a top-level signal is present, or a step below upgrades the - // shape. A legacy vis can't be saved in place — that would rewrite it in - // the canonical format and break older apps that read the original. - if (legacy || program || programStage || orgUnitField || programStatus) { - normalizedVis.legacy = true - } - - const rawFilters = [ - ...filters, - ...(orgUnitField - ? [{ dimension: 'ou', items: [{ id: orgUnitField }] }] - : []), - ...(programStatus - ? [{ dimension: 'programStatus', items: [{ id: programStatus }] }] - : []), - ] - - /* Wire-only dimensions mark where a legacy Event Report put its value - * column; the app expresses that with the custom value fields instead, and - * carries no layout position for them. Dropping them changes the persisted - * shape, so it flips `legacy` like every other upgrade here. */ - const dropWireOnlyDimensions = (dims: DimensionRecord[]) => { - const kept = dims.filter( - (dim) => !WIRE_ONLY_DIMENSIONS.has(dim.dimension) - ) - if (kept.length !== dims.length) { - normalizedVis.legacy = true - } - return kept - } - - normalizedVis.columns = dropWireOnlyDimensions(columns).map((dim) => - normalizeLegacyDimension(dim, context, normalizedVis) - ) - normalizedVis.rows = dropWireOnlyDimensions(rows).map((dim) => - normalizeLegacyDimension(dim, context, normalizedVis) - ) - normalizedVis.filters = dropWireOnlyDimensions(rawFilters).map((dim) => - normalizeLegacyDimension(dim, context, normalizedVis) - ) - - if ( - program && - !normalizedVis.programDimensions?.some((p) => p.id === program.id) - ) { - normalizedVis.programDimensions = [ - ...(normalizedVis.programDimensions ?? []), - program, - ] - } - - // `timeField` holding a known backend enum value has been materialised - // into a concrete time dimension above; keep it only when it holds a - // data-element / attribute UID (non-legacy usage that the analytics - // request still needs). Dropping a known-enum `timeField` is an upgrade. - const preserveTimeField = - typeof timeField === 'string' && !KNOWN_TIME_FIELD_VALUES.has(timeField) - if (preserveTimeField) { - normalizedVis.timeField = timeField - } else if (typeof timeField === 'string') { - normalizedVis.legacy = true - } - - if (sortOrder !== 0) { - normalizedVis.sortOrder = sortOrder - } - if (topLimit !== 0) { - normalizedVis.topLimit = topLimit - } - - return normalizedVis -} diff --git a/src/modules/visualization/ui-config.ts b/src/modules/visualization/ui-config.ts new file mode 100644 index 00000000..4494135c --- /dev/null +++ b/src/modules/visualization/ui-config.ts @@ -0,0 +1,82 @@ +import { DEFAULT_OPTIONS } from '@constants/options' +import { getConditionsFromVisualization } from '@modules/conditions' +import { + getCompoundDimensionId, + WIRE_ONLY_DIMENSIONS, +} from '@modules/dimension/ids' +import { toAppLocalDimensions } from '@modules/dimension/translation' +import { getRepetitionsFromVisualisation } from '@modules/repetitions' +import type { + CurrentVisualization, + DimensionArray, + EventVisualizationOptions, +} from '@types' + +const toAppLocalAxes = (dims: DimensionArray): DimensionArray => + toAppLocalDimensions( + dims.filter((dim) => !WIRE_ONLY_DIMENSIONS.has(dim.dimension)) + ) + +const OPTION_KEYS = Object.keys(DEFAULT_OPTIONS) as Array< + keyof EventVisualizationOptions +> + +const extractOptions = ( + vis: CurrentVisualization +): Partial => { + const extracted: Record = {} + for (const key of OPTION_KEYS) { + if (vis[key] !== undefined) { + extracted[key] = vis[key] + } + } + return extracted as Partial +} + +export const getVisualizationUiConfig = ( + raw: CurrentVisualization, + baseOptions: EventVisualizationOptions = DEFAULT_OPTIONS +) => { + const vis: CurrentVisualization = { + ...raw, + columns: toAppLocalAxes(raw.columns ?? []), + rows: toAppLocalAxes(raw.rows ?? []), + filters: toAppLocalAxes(raw.filters ?? []), + } + const outputType = vis.outputType + const tetId = vis.trackedEntityType?.id + const toDimId = (dim: DimensionArray[number]) => + getCompoundDimensionId(dim, outputType, tetId) + + return { + visualizationType: vis.type, + outputType, + layout: { + columns: (vis.columns ?? []).map(toDimId), + filters: (vis.filters ?? []).map(toDimId), + rows: (vis.rows ?? []).map(toDimId), + }, + itemsByDimension: [ + ...(vis.columns ?? []), + ...(vis.rows ?? []), + ...(vis.filters ?? []), + ].reduce( + (obj, dim) => { + obj[toDimId(dim)] = (dim.items ?? []) + .map((item) => item.id) + .filter(Boolean) as string[] + return obj + }, + {} as Record + ), + conditionsByDimension: getConditionsFromVisualization(vis, outputType), + repetitionsByDimension: getRepetitionsFromVisualisation(vis), + options: { ...baseOptions, ...extractOptions(vis) }, + ...(vis.value?.id && { + customValue: { + id: vis.value.id, + aggregationType: vis.aggregationType || 'DEFAULT', + }, + }), + } +} diff --git a/src/store/current-vis-slice.ts b/src/store/current-vis-slice.ts index b86f4803..59230e38 100644 --- a/src/store/current-vis-slice.ts +++ b/src/store/current-vis-slice.ts @@ -1,4 +1,4 @@ -import { isCurrentVisualizationPersisted } from '@modules/visualization/state' +import { isCurrentVisualizationPersisted } from '@modules/visualization/guards' import type { PayloadAction } from '@reduxjs/toolkit' import { createSlice } from '@reduxjs/toolkit' import type { CurrentVisualization, EmptyVisualization } from '@types' diff --git a/src/store/thunks.ts b/src/store/thunks.ts index bc82d780..00c1b9e3 100644 --- a/src/store/thunks.ts +++ b/src/store/thunks.ts @@ -3,32 +3,17 @@ import { eventVisualizationsApi } from '@api/event-visualizations-api' import { legendSetsApi } from '@api/legend-sets-api' import { extractDataSourceIdFromVisualization } from '@modules/data-source' import { canDimensionHaveLegendSets } from '@modules/dimension/grouping' -import { - buildAxis, - collectProgramDimensions, - resolveTeiFields, -} from '@modules/layout' import { logger } from '@modules/logger' -import { getEnabledOptions } from '@modules/options' -import { setLastUsedVisualizationTypeToLocalStorage } from '@modules/visualization/local-storage' import { - getVisualizationUiConfig, - isCurrentVisualizationPersisted, - isVisualizationEmpty, + buildCurrentVisFromVisUiConfig, toCurrentVis, -} from '@modules/visualization/state' +} from '@modules/visualization/current-vis' +import { isVisualizationEmpty } from '@modules/visualization/guards' +import { setLastUsedVisualizationTypeToLocalStorage } from '@modules/visualization/local-storage' +import { getVisualizationUiConfig } from '@modules/visualization/ui-config' import { createAsyncThunk } from '@reduxjs/toolkit' -import type { - AppDispatch, - CurrentVisualization, - MetadataStore, - RootState, -} from '@types' -import { - clearCurrentVis, - setCurrentVis, - type CurrentVisState, -} from './current-vis-slice' +import type { AppDispatch, RootState } from '@types' +import { clearCurrentVis, setCurrentVis } from './current-vis-slice' import { setDataSourceId } from './dimensions-selection-slice' import { setIsVisualizationLoading, @@ -40,7 +25,6 @@ import { clearVisUiConfig, setVisUiConfig, setVisUiConfigGroupingByDimension, - type VisUiConfigState, } from './vis-ui-config-slice' type AppAsyncThunkConfig = { @@ -131,89 +115,6 @@ export const tLoadSavedVisualization = createAsyncThunk< } ) -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 - ), -}) - export const tUpdateCurrentVisFromVisUiConfig = (withCustomValue?: boolean) => (