From f7e21d014e8f815b25b7bc32d051662db97e32af Mon Sep 17 00:00:00 2001 From: Hendrik de Graaf Date: Wed, 23 Sep 2026 17:51:53 +0200 Subject: [PATCH 01/33] feat: fetch plural event, enrollment and tracked entity type labels --- src/modules/__tests__/query.spec.ts | 27 +++++++++++++++++++++++++++ src/modules/metadata/visualization.ts | 3 +++ src/modules/query.ts | 11 +++++++++-- src/types/data-source.ts | 7 ++----- src/types/program.ts | 13 +++++++++---- src/types/visualization.ts | 4 ++-- 6 files changed, 52 insertions(+), 13 deletions(-) create mode 100644 src/modules/__tests__/query.spec.ts diff --git a/src/modules/__tests__/query.spec.ts b/src/modules/__tests__/query.spec.ts new file mode 100644 index 000000000..37e1138a2 --- /dev/null +++ b/src/modules/__tests__/query.spec.ts @@ -0,0 +1,27 @@ +import { getProgramFields, getTrackedEntityTypeFields } from '@modules/query' +import { describe, expect, it } from 'vitest' + +describe('getProgramFields', () => { + it('requests both singular and plural event and enrollment labels', () => { + const fields = getProgramFields('displayName').split(',') + + expect(fields).toContain('displayEventLabel') + expect(fields).toContain('displayEventsLabel') + expect(fields).toContain('displayEnrollmentLabel') + expect(fields).toContain('displayEnrollmentsLabel') + }) + + it('requests the plural label on the nested tracked entity type', () => { + expect(getProgramFields('displayName')).toContain( + 'trackedEntityType[id,displayName~rename(name),displayTrackedEntityTypesLabel]' + ) + }) +}) + +describe('getTrackedEntityTypeFields', () => { + it('requests the plural tracked entity type label', () => { + expect(getTrackedEntityTypeFields('displayName').split(',')).toContain( + 'displayTrackedEntityTypesLabel' + ) + }) +}) diff --git a/src/modules/metadata/visualization.ts b/src/modules/metadata/visualization.ts index 8d75549ae..57c2c12aa 100644 --- a/src/modules/metadata/visualization.ts +++ b/src/modules/metadata/visualization.ts @@ -72,6 +72,9 @@ export const extractTrackedEntityTypeMetadata = ( [visualization.trackedEntityType.id]: { id: visualization.trackedEntityType.id, name: visualization.trackedEntityType.name, + displayTrackedEntityTypesLabel: + visualization.trackedEntityType + .displayTrackedEntityTypesLabel, }, } : {} diff --git a/src/modules/query.ts b/src/modules/query.ts index 315e3f37e..9a67a62fd 100644 --- a/src/modules/query.ts +++ b/src/modules/query.ts @@ -29,7 +29,9 @@ export const getProgramFields = ( `${displayNameProp}~rename(name)`, 'displayEnrollmentDateLabel', 'displayEnrollmentLabel', + 'displayEnrollmentsLabel', 'displayEventLabel', + 'displayEventsLabel', 'displayIncidentDate', 'displayIncidentDateLabel', 'displayOrgUnitLabel', @@ -38,7 +40,7 @@ export const getProgramFields = ( 'enrollmentDateLabel', 'incidentDateLabel', `programStages[${programStageFields}]`, - `trackedEntityType[id,${displayNameProp}~rename(name)]`, + `trackedEntityType[${getTrackedEntityTypeFields(displayNameProp)}]`, ].join(',') /** @@ -46,7 +48,12 @@ export const getProgramFields = ( */ export const getTrackedEntityTypeFields = ( displayNameProp: CurrentUser['settings']['displayNameProperty'] -): string => ['id', `${displayNameProp}~rename(name)`].join(',') +): string => + [ + 'id', + `${displayNameProp}~rename(name)`, + 'displayTrackedEntityTypesLabel', + ].join(',') /* * Option sets and legend sets have no shortName, so their names always come diff --git a/src/types/data-source.ts b/src/types/data-source.ts index f4f2465b4..22dffe211 100644 --- a/src/types/data-source.ts +++ b/src/types/data-source.ts @@ -1,7 +1,7 @@ import type { ProgramType } from './dhis2-openapi-schemas' import type { DimensionType } from './dimension' import type { MetadataItem } from './metadata' -import type { Program } from './program' +import type { Program, TrackedEntityTypeRef } from './program' export type DataSourceProgramWithRegistration = Omit< Program, @@ -9,10 +9,7 @@ export type DataSourceProgramWithRegistration = Omit< > & { programType: Extract // Required field for tracker program - trackedEntityType: { - id: string - name: string - } + trackedEntityType: TrackedEntityTypeRef } export type DataSourceProgramWithoutRegistration = Omit< Program, diff --git a/src/types/program.ts b/src/types/program.ts index 623246bc6..d05de4ccf 100644 --- a/src/types/program.ts +++ b/src/types/program.ts @@ -11,7 +11,9 @@ export type Program = Pick< | 'displayIncidentDate' | 'displayEnrollmentDateLabel' | 'displayEnrollmentLabel' + | 'displayEnrollmentsLabel' | 'displayEventLabel' + | 'displayEventsLabel' | 'displayIncidentDateLabel' | 'displayOrgUnitLabel' | 'displayProgramStageLabel' @@ -23,8 +25,11 @@ export type Program = Pick< id: string name: string programStages?: ProgramStage[] - trackedEntityType?: { - id: string - name: string - } + trackedEntityType?: TrackedEntityTypeRef +} + +export type TrackedEntityTypeRef = { + id: string + name: string + displayTrackedEntityTypesLabel?: string } diff --git a/src/types/visualization.ts b/src/types/visualization.ts index 26c82a662..529349ebf 100644 --- a/src/types/visualization.ts +++ b/src/types/visualization.ts @@ -8,7 +8,7 @@ import type { import type { DimensionType } from './dimension' import type { MetadataInputMap } from './metadata' import type { EventVisualizationOptions } from './options' -import type { Program } from './program' +import type { Program, TrackedEntityTypeRef } from './program' import type { ProgramStage } from './program-stage' import type { VisualizationType } from './visualization-type' @@ -71,7 +71,7 @@ type SavedVisualizationFieldOverrides = { program: Program programStage: ProgramStage programDimensions?: ProgramDimensionArray - trackedEntityType?: IdNameRecord + trackedEntityType?: TrackedEntityTypeRef // Custom dimension metadata arrays (from getDimensionMetadataFields) dataElementDimensions: DataElementDimensionArray From 0d3300c877c18674fc90158bd6e9ad0b7a0745f9 Mon Sep 17 00:00:00 2001 From: Hendrik de Graaf Date: Wed, 23 Sep 2026 17:53:55 +0200 Subject: [PATCH 02/33] feat: resolve visualization titles from output type and program labels --- .../visualization/__tests__/title.spec.ts | 245 ++++++++++++++++++ src/modules/visualization/title.ts | 128 +++++++++ src/types/metadata.ts | 3 +- 3 files changed, 375 insertions(+), 1 deletion(-) create mode 100644 src/modules/visualization/__tests__/title.spec.ts create mode 100644 src/modules/visualization/title.ts diff --git a/src/modules/visualization/__tests__/title.spec.ts b/src/modules/visualization/__tests__/title.spec.ts new file mode 100644 index 000000000..097a9695e --- /dev/null +++ b/src/modules/visualization/__tests__/title.spec.ts @@ -0,0 +1,245 @@ +import { MetadataStore } from '@modules/metadata/store' +import { getVisualizationTitle } from '@modules/visualization/title' +import type { CurrentVisualization, MetadataInputMap } from '@types' +import { describe, expect, it } from 'vitest' + +const PROGRAM_ID = 'prg1' +const TET_ID = 'tet1' + +const buildStore = (items: MetadataInputMap = {}) => { + const store = new MetadataStore({}) + store.addMetadata({ + [PROGRAM_ID]: { + id: PROGRAM_ID, + name: 'Programme', + programType: 'WITHOUT_REGISTRATION', + }, + ...items, + }) + return store +} + +const buildVis = ( + overrides: Partial = {} +): CurrentVisualization => + ({ + type: 'LINE_LIST', + outputType: 'EVENT', + columns: [{ dimension: 'ou', program: { id: PROGRAM_ID } }], + rows: [], + filters: [], + ...overrides, + }) as CurrentVisualization + +describe('getVisualizationTitle', () => { + it('returns undefined when the title is hidden', () => { + const vis = buildVis({ hideTitle: true, title: 'Ignored' }) + + expect(getVisualizationTitle(vis, buildStore())).toBeUndefined() + }) + + it('returns the custom title when one is set', () => { + const vis = buildVis({ title: 'My title' }) + + expect(getVisualizationTitle(vis, buildStore())).toBe('My title') + }) + + it('treats a whitespace-only title as absent and generates instead', () => { + const vis = buildVis({ title: ' ' }) + + expect(getVisualizationTitle(vis, buildStore())).toBe('Events') + }) + + it('generates the bare plural when the program has no custom labels', () => { + expect(getVisualizationTitle(buildVis(), buildStore())).toBe('Events') + expect( + getVisualizationTitle( + buildVis({ type: 'PIVOT_TABLE' }), + buildStore() + ) + ).toBe('Events') + }) + + it('prefers the plural event label for both visualization types', () => { + const store = buildStore({ + [PROGRAM_ID]: { + id: PROGRAM_ID, + name: 'Programme', + programType: 'WITHOUT_REGISTRATION', + displayEventLabel: 'visit', + displayEventsLabel: 'Visits', + }, + }) + + expect(getVisualizationTitle(buildVis(), store)).toBe('Visits') + expect( + getVisualizationTitle(buildVis({ type: 'PIVOT_TABLE' }), store) + ).toBe('Visits') + }) + + it('falls back to the singular event label with per-type wording', () => { + const store = buildStore({ + [PROGRAM_ID]: { + id: PROGRAM_ID, + name: 'Programme', + programType: 'WITHOUT_REGISTRATION', + displayEventLabel: 'Visit', + }, + }) + + expect(getVisualizationTitle(buildVis(), store)).toBe('Visit list') + expect( + getVisualizationTitle(buildVis({ type: 'PIVOT_TABLE' }), store) + ).toBe('Visit count') + }) + + it('uses the enrollment labels for enrollment output', () => { + const store = buildStore({ + [PROGRAM_ID]: { + id: PROGRAM_ID, + name: 'Programme', + programType: 'WITH_REGISTRATION', + displayEnrollmentLabel: 'Pregnancy', + }, + }) + + expect( + getVisualizationTitle(buildVis({ outputType: 'ENROLLMENT' }), store) + ).toBe('Pregnancy list') + expect( + getVisualizationTitle( + buildVis({ outputType: 'ENROLLMENT', type: 'PIVOT_TABLE' }), + store + ) + ).toBe('Pregnancy count') + }) + + it('prefers the plural enrollment label', () => { + const store = buildStore({ + [PROGRAM_ID]: { + id: PROGRAM_ID, + name: 'Programme', + programType: 'WITH_REGISTRATION', + displayEnrollmentLabel: 'Pregnancy', + displayEnrollmentsLabel: 'Pregnancies', + }, + }) + + expect( + getVisualizationTitle(buildVis({ outputType: 'ENROLLMENT' }), store) + ).toBe('Pregnancies') + }) + + it('generates Enrollments when the program has no enrollment labels', () => { + expect( + getVisualizationTitle( + buildVis({ outputType: 'ENROLLMENT' }), + buildStore() + ) + ).toBe('Enrollments') + }) + + it('uses the tracked entity type plural label when present', () => { + const store = buildStore({ + [TET_ID]: { + id: TET_ID, + name: 'Person', + displayTrackedEntityTypesLabel: 'Patients', + }, + }) + const vis = buildVis({ + outputType: 'TRACKED_ENTITY_INSTANCE', + trackedEntityType: { id: TET_ID, name: 'Person' }, + }) + + expect(getVisualizationTitle(vis, store)).toBe('Patients') + }) + + it('falls back to the tracked entity type name with per-type wording', () => { + const store = buildStore({ [TET_ID]: { id: TET_ID, name: 'Patient' } }) + const vis = buildVis({ + outputType: 'TRACKED_ENTITY_INSTANCE', + trackedEntityType: { id: TET_ID, name: 'Patient' }, + }) + + expect(getVisualizationTitle(vis, store)).toBe('Patient list') + expect( + getVisualizationTitle({ ...vis, type: 'PIVOT_TABLE' }, store) + ).toBe('Patient count') + }) + + it('replaces the output-type title with the custom value on a pivot', () => { + const store = buildStore({ + de1: { id: 'de1', name: 'MCH Apgar score' }, + }) + const vis = buildVis({ + type: 'PIVOT_TABLE', + value: { id: 'de1' }, + aggregationType: 'AVERAGE', + }) + + expect(getVisualizationTitle(vis, store)).toBe( + 'MCH Apgar score · Average' + ) + }) + + it('ignores a custom value that is missing its aggregation type', () => { + const store = buildStore({ + de1: { id: 'de1', name: 'MCH Apgar score' }, + }) + const vis = buildVis({ type: 'PIVOT_TABLE', value: { id: 'de1' } }) + + expect(getVisualizationTitle(vis, store)).toBe('Events') + }) + + it('ignores a custom value whose item is unknown to the metadata store', () => { + const vis = buildVis({ + type: 'PIVOT_TABLE', + value: { id: 'missing' }, + aggregationType: 'AVERAGE', + }) + + expect(getVisualizationTitle(vis, buildStore())).toBe('Events') + }) + + it('ignores a custom value on a line list', () => { + const store = buildStore({ + de1: { id: 'de1', name: 'MCH Apgar score' }, + }) + const vis = buildVis({ + value: { id: 'de1' }, + aggregationType: 'AVERAGE', + }) + + expect(getVisualizationTitle(vis, store)).toBe('Events') + }) + + it('falls back to the bare plural for an empty layout', () => { + const vis = buildVis({ columns: [], rows: [], filters: [] }) + + expect(getVisualizationTitle(vis, buildStore())).toBe('Events') + }) + + it('picks the first program in axis order for multi-program layouts', () => { + const store = buildStore({ + [PROGRAM_ID]: { + id: PROGRAM_ID, + name: 'First', + programType: 'WITH_REGISTRATION', + displayEventsLabel: 'Visits', + }, + prg2: { + id: 'prg2', + name: 'Second', + programType: 'WITH_REGISTRATION', + displayEventsLabel: 'Doses', + }, + }) + const vis = buildVis({ + columns: [{ dimension: 'ou', program: { id: PROGRAM_ID } }], + filters: [{ dimension: 'x', program: { id: 'prg2' } }], + } as Partial) + + expect(getVisualizationTitle(vis, store)).toBe('Visits') + }) +}) diff --git a/src/modules/visualization/title.ts b/src/modules/visualization/title.ts new file mode 100644 index 000000000..8fd9e1422 --- /dev/null +++ b/src/modules/visualization/title.ts @@ -0,0 +1,128 @@ +import { aggregationTypeDisplayNames } from '@constants/aggregation-types' +import i18n from '@dhis2/d2-i18n' +import { isPopulatedString } from '@modules/utils/guards' +import type { CurrentVisualization, MetadataStore, Program } from '@types' + +const getFirstProgramId = ( + visualization: CurrentVisualization +): string | undefined => { + for (const axis of [ + visualization.columns, + visualization.rows, + visualization.filters, + ]) { + for (const dimension of axis ?? []) { + if (dimension.program?.id) { + return dimension.program.id + } + } + } + return undefined +} + +const getCountOrListTitle = ( + label: string, + visualization: CurrentVisualization +): string => + visualization.type === 'PIVOT_TABLE' + ? i18n.t('{{- label}} count', { label }) + : i18n.t('{{- label}} list', { label }) + +const getCustomValueTitle = ( + visualization: CurrentVisualization, + metadataStore: MetadataStore +): string | undefined => { + if ( + visualization.type !== 'PIVOT_TABLE' || + !visualization.value?.id || + !visualization.aggregationType + ) { + return undefined + } + const itemName = metadataStore.getMetadataItem(visualization.value.id)?.name + const aggregationName = + aggregationTypeDisplayNames[visualization.aggregationType] + + return isPopulatedString(itemName) && isPopulatedString(aggregationName) + ? `${itemName} · ${aggregationName}` + : undefined +} + +const getTrackedEntityTitle = ( + visualization: CurrentVisualization, + metadataStore: MetadataStore +): string | undefined => { + const tetId = visualization.trackedEntityType?.id + const tet = tetId ? metadataStore.getMetadataItem(tetId) : undefined + const pluralLabel = + tet && 'displayTrackedEntityTypesLabel' in tet + ? tet.displayTrackedEntityTypesLabel + : undefined + + if (isPopulatedString(pluralLabel)) { + return pluralLabel + } + return isPopulatedString(tet?.name) + ? getCountOrListTitle(tet.name, visualization) + : undefined +} + +const getProgramLabels = ( + visualization: CurrentVisualization, + metadataStore: MetadataStore +): Program | undefined => { + const programId = getFirstProgramId(visualization) + return programId + ? metadataStore.getProgramMetadataItem(programId) + : undefined +} + +export const getAutoTitle = ( + visualization: CurrentVisualization, + metadataStore: MetadataStore +): string | undefined => { + const customValueTitle = getCustomValueTitle(visualization, metadataStore) + if (customValueTitle) { + return customValueTitle + } + + if (visualization.outputType === 'TRACKED_ENTITY_INSTANCE') { + return getTrackedEntityTitle(visualization, metadataStore) + } + + const program = getProgramLabels(visualization, metadataStore) + + if (visualization.outputType === 'ENROLLMENT') { + if (isPopulatedString(program?.displayEnrollmentsLabel)) { + return program.displayEnrollmentsLabel + } + if (isPopulatedString(program?.displayEnrollmentLabel)) { + return getCountOrListTitle( + program.displayEnrollmentLabel, + visualization + ) + } + return i18n.t('Enrollments') + } + + if (isPopulatedString(program?.displayEventsLabel)) { + return program.displayEventsLabel + } + if (isPopulatedString(program?.displayEventLabel)) { + return getCountOrListTitle(program.displayEventLabel, visualization) + } + return i18n.t('Events') +} + +export const getVisualizationTitle = ( + visualization: CurrentVisualization, + metadataStore: MetadataStore +): string | undefined => { + if (visualization.hideTitle) { + return undefined + } + if (isPopulatedString(visualization.title)) { + return visualization.title + } + return getAutoTitle(visualization, metadataStore) +} diff --git a/src/types/metadata.ts b/src/types/metadata.ts index f7ba3a6ee..c39971e2a 100644 --- a/src/types/metadata.ts +++ b/src/types/metadata.ts @@ -4,7 +4,7 @@ import type { LegendSet, } from './dhis2-openapi-schemas' import type { DimensionType } from './dimension' -import type { Program, ProgramStage } from '.' +import type { Program, ProgramStage, TrackedEntityTypeRef } from '.' /** PHASES * 1. Data is provided as a single object, object map, or object array @@ -129,6 +129,7 @@ export type MetadataItem = | UserOrgUnitMetadataItem | Program | ProgramStage + | TrackedEntityTypeRef export type MetadataMap = Map export type Subscriber = () => void From cb428ef77faee5ca101079314150a045aaf1df27 Mon Sep 17 00:00:00 2001 From: Hendrik de Graaf Date: Wed, 23 Sep 2026 17:54:56 +0200 Subject: [PATCH 03/33] feat: hide the title on legacy visualizations that have none --- .../__tests__/visualization.spec.ts | 45 +++++++++++++++++++ src/modules/visualization/normalize-legacy.ts | 10 +++++ 2 files changed, 55 insertions(+) diff --git a/src/modules/visualization/__tests__/visualization.spec.ts b/src/modules/visualization/__tests__/visualization.spec.ts index 116400fb4..49293d25c 100644 --- a/src/modules/visualization/__tests__/visualization.spec.ts +++ b/src/modules/visualization/__tests__/visualization.spec.ts @@ -661,6 +661,51 @@ describe('normalizeApiSavedVisualization', () => { expect(result.legacy).toBe(true) }) + it('maps a legacy visualization with no title to a hidden title', () => { + const result = normalizeApiSavedVisualization( + buildApiVis({ legacy: true, hideTitle: false }) + ) + + expect(result.hideTitle).toBe(true) + }) + + it('maps a legacy visualization with a title to a shown title', () => { + const result = normalizeApiSavedVisualization( + buildApiVis({ legacy: true, hideTitle: true, title: 'Kept' }) + ) + + expect(result.hideTitle).toBe(false) + expect(result.title).toBe('Kept') + }) + + it('treats a legacy whitespace-only title as no title', () => { + const result = normalizeApiSavedVisualization( + buildApiVis({ legacy: true, title: ' ' }) + ) + + expect(result.hideTitle).toBe(true) + }) + + it('honours the stored hideTitle on a non-legacy visualization', () => { + const result = normalizeApiSavedVisualization( + buildApiVis({ hideTitle: false }) + ) + + expect(result.hideTitle).toBe(false) + }) + + it('hides the title on a visualization made legacy by an upgrade', () => { + const result = normalizeApiSavedVisualization( + buildApiVis({ + hideTitle: false, + program: { id: PID }, + } as Partial) + ) + + expect(result.legacy).toBe(true) + expect(result.hideTitle).toBe(true) + }) + it('leaves ENROLLMENT enrollment `ou` as `ou`', () => { const result = normalizeApiSavedVisualization( buildApiVis({ diff --git a/src/modules/visualization/normalize-legacy.ts b/src/modules/visualization/normalize-legacy.ts index 5209de54d..e761ccadc 100644 --- a/src/modules/visualization/normalize-legacy.ts +++ b/src/modules/visualization/normalize-legacy.ts @@ -11,6 +11,7 @@ import { timeFieldTimeDimensionMap, } from '@modules/dimension/time' import { toEventVisualizationDimensionId } from '@modules/dimension/translation' +import { isPopulatedString } from '@modules/utils/guards' import type { ApiSavedVisualization, DimensionId, @@ -305,5 +306,14 @@ export const normalizeApiSavedVisualization = ( normalizedVis.topLimit = topLimit } + /* The apps that produced legacy visualizations never generated a title, so + * an empty title there means "no title" rather than "auto generated". + * `hideTitle` cannot distinguish the two on its own: it is a primitive + * boolean in the backend and always comes back as `false`. This app strips + * `legacy` on save, so its own output never takes this branch. */ + if (normalizedVis.legacy) { + normalizedVis.hideTitle = !isPopulatedString(normalizedVis.title) + } + return normalizedVis } From f177460f243ebf6c44392591d19415e016ef32de Mon Sep 17 00:00:00 2001 From: Hendrik de Graaf Date: Wed, 23 Sep 2026 18:00:03 +0200 Subject: [PATCH 04/33] feat: render the resolved title in every visualization path --- .../line-list/__tests__/line-list.spec.tsx | 74 +++++++++++++++++++ .../plugin-wrapper/plugin-wrapper.tsx | 27 +++++-- 2 files changed, 96 insertions(+), 5 deletions(-) diff --git a/src/components/line-list/__tests__/line-list.spec.tsx b/src/components/line-list/__tests__/line-list.spec.tsx index 93e8def8a..2af13e116 100644 --- a/src/components/line-list/__tests__/line-list.spec.tsx +++ b/src/components/line-list/__tests__/line-list.spec.tsx @@ -656,4 +656,78 @@ describe('LineList', () => { await user.click(sortButton) }) }) + + describe('title', () => { + /* The captured fixtures all carry the top-level program/programStage + * the old apps wrote, so the normaliser marks them legacy. Moving that + * context onto the dimensions is what a visualization saved by this + * app looks like. */ + const asCanonicalPayload = (fixture: LineListFixture) => { + const payload = fixture.eventVisualization as Record< + string, + unknown + > & { + program: { id: string } + programStage: { id: string } + columns: Array> + } + const canonical: Record = { ...payload } + delete canonical.program + delete canonical.programStage + delete canonical.legacy + + return { + ...canonical, + programDimensions: [payload.program], + columns: payload.columns.map((dimension) => ({ + ...dimension, + program: payload.program, + ...(dimension.dimension === 'ou' + ? { programStage: payload.programStage } + : {}), + })), + } + } + + it('renders no title for a legacy visualization that has none', async () => { + await renderLineList(simpleLineList) + + expect(screen.queryByText('Enrollments')).not.toBeInTheDocument() + }) + + it('renders a generated title for a non-legacy visualization', async () => { + await renderLineList(simpleLineList, { + eventVisualization: asCanonicalPayload(simpleLineList), + }) + + expect(await screen.findByText('Enrollments')).toBeInTheDocument() + }) + + it('renders the custom title of a legacy visualization that has one', async () => { + await renderLineList(simpleLineList, { + eventVisualization: { + ...(simpleLineList.eventVisualization as Record< + string, + unknown + >), + title: 'My saved title', + }, + }) + + expect( + await screen.findByText('My saved title') + ).toBeInTheDocument() + }) + + it('renders no title when the title is hidden', async () => { + await renderLineList(simpleLineList, { + eventVisualization: { + ...asCanonicalPayload(simpleLineList), + hideTitle: true, + }, + }) + + expect(screen.queryByText('Enrollments')).not.toBeInTheDocument() + }) + }) }) diff --git a/src/components/plugin-wrapper/plugin-wrapper.tsx b/src/components/plugin-wrapper/plugin-wrapper.tsx index 893a4d049..05af982b2 100644 --- a/src/components/plugin-wrapper/plugin-wrapper.tsx +++ b/src/components/plugin-wrapper/plugin-wrapper.tsx @@ -1,10 +1,12 @@ import type { EngineError } from '@api/parse-engine-error' +import { useMetadataStore } from '@components/app-wrapper/metadata-provider/metadata-provider' import { CanvasError } from '@components/canvas-error/canvas-error' import { CanvasErrorFallback } from '@components/canvas-error/canvas-error-fallback' 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/guards' +import { getVisualizationTitle } from '@modules/visualization/title' import type { CurrentUser, CurrentVisualization, @@ -85,6 +87,21 @@ export const PluginWrapper: FC = ({ [visualization, relativePeriodDate] ) + const metadataStore = useMetadataStore() + + /* The title is resolved here rather than in each plugin because this is + * the one component the canvas, the dashboard plugin and the + * interpretation modal all render through. `hideTitle` is set alongside + * it so the pivot table engine, which gates the title on that flag, + * agrees with the line list. */ + const visualizationWithTitle = useMemo(() => { + if (isVisualizationEmpty(visualization)) { + return visualization + } + const title = getVisualizationTitle(visualization, metadataStore) + return { ...visualization, title, hideTitle: !title } + }, [visualization, metadataStore]) + const [hasAnalyticsData, setHasAnalyticsData] = useState(false) const onResponseReceived = useCallback(() => { @@ -119,7 +136,7 @@ export const PluginWrapper: FC = ({ ) } - if (isVisualizationEmpty(visualization)) { + if (isVisualizationEmpty(visualizationWithTitle)) { return null } @@ -142,10 +159,10 @@ export const PluginWrapper: FC = ({ filters={filters} isLoading={isVisualizationLoading || !hasAnalyticsData} /> - {visualization.type === 'LINE_LIST' && ( + {visualizationWithTitle.type === 'LINE_LIST' && ( = ({ onResponseReceived={onResponseReceived} /> )} - {visualization.type === 'PIVOT_TABLE' && ( + {visualizationWithTitle.type === 'PIVOT_TABLE' && ( Date: Wed, 23 Sep 2026 18:02:11 +0200 Subject: [PATCH 05/33] feat: replace the title input with an auto, none and custom control --- .../options/fields/__tests__/title.spec.tsx | 113 ++++++++++++++++++ .../options/fields/styles/title.module.css | 4 + src/components/options/fields/title.tsx | 102 ++++++++++++++-- 3 files changed, 206 insertions(+), 13 deletions(-) create mode 100644 src/components/options/fields/__tests__/title.spec.tsx create mode 100644 src/components/options/fields/styles/title.module.css diff --git a/src/components/options/fields/__tests__/title.spec.tsx b/src/components/options/fields/__tests__/title.spec.tsx new file mode 100644 index 000000000..75708c8fe --- /dev/null +++ b/src/components/options/fields/__tests__/title.spec.tsx @@ -0,0 +1,113 @@ +import { Title } from '@components/options/fields/title' +import { + initialState as visUiConfigInitialState, + visUiConfigSlice, +} from '@store/vis-ui-config-slice' +import { renderWithReduxStoreProvider } from '@test-utils/render-with-redux-store-provider' +import { setupStore } from '@test-utils/setup-store' +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import type { EventVisualizationOptions } from '@types' +import { describe, expect, it } from 'vitest' + +const setupTestStore = (options: Partial = {}) => + setupStore( + { [visUiConfigSlice.name]: visUiConfigSlice.reducer }, + { + visUiConfig: { + ...visUiConfigInitialState, + options: { ...visUiConfigInitialState.options, ...options }, + }, + } + ) + +const renderTitle = (options: Partial = {}) => { + const store = setupTestStore(options) + renderWithReduxStoreProvider(, store) + return { store } +} + +describe('Title', () => { + it('selects Auto generated when there is no title', () => { + renderTitle() + + expect(screen.getByLabelText('Auto generated')).toBeChecked() + expect(screen.queryByPlaceholderText('Add a title')).toBeNull() + }) + + it('selects Custom and shows the title when one is set', () => { + renderTitle({ title: 'Saved title' }) + + expect(screen.getByLabelText('Custom')).toBeChecked() + expect(screen.getByPlaceholderText('Add a title')).toHaveValue( + 'Saved title' + ) + }) + + it('selects None when the title is hidden', () => { + renderTitle({ hideTitle: true }) + + expect(screen.getByLabelText('None')).toBeChecked() + }) + + it('shows the text input only in Custom mode', async () => { + const user = userEvent.setup() + renderTitle() + + await user.click(screen.getByLabelText('Custom')) + + expect(screen.getByPlaceholderText('Add a title')).toBeInTheDocument() + }) + + it('stores the typed title and clears the hidden flag', async () => { + const user = userEvent.setup() + const { store } = renderTitle() + + await user.click(screen.getByLabelText('Custom')) + await user.type(screen.getByPlaceholderText('Add a title'), 'Mine') + + expect(store.getState().visUiConfig.options.title).toBe('Mine') + expect(store.getState().visUiConfig.options.hideTitle).toBe(false) + }) + + it('clears the stored title when switching to Auto generated', async () => { + const user = userEvent.setup() + const { store } = renderTitle({ title: 'Saved title' }) + + await user.click(screen.getByLabelText('Auto generated')) + + expect(store.getState().visUiConfig.options.title).toBe('') + expect(store.getState().visUiConfig.options.hideTitle).toBe(false) + }) + + it('hides the title when switching to None', async () => { + const user = userEvent.setup() + const { store } = renderTitle({ title: 'Saved title' }) + + await user.click(screen.getByLabelText('None')) + + expect(store.getState().visUiConfig.options.hideTitle).toBe(true) + }) + + it('keeps the typed title when toggling away and back', async () => { + const user = userEvent.setup() + renderTitle() + + await user.click(screen.getByLabelText('Custom')) + await user.type(screen.getByPlaceholderText('Add a title'), 'Mine') + await user.click(screen.getByLabelText('Auto generated')) + await user.click(screen.getByLabelText('Custom')) + + expect(screen.getByPlaceholderText('Add a title')).toHaveValue('Mine') + }) + + it('leaves an empty Custom input stored as Auto generated', async () => { + const user = userEvent.setup() + const { store } = renderTitle() + + await user.click(screen.getByLabelText('Custom')) + + expect(store.getState().visUiConfig.options.title).toBe('') + expect(store.getState().visUiConfig.options.hideTitle).toBe(false) + }) +}) diff --git a/src/components/options/fields/styles/title.module.css b/src/components/options/fields/styles/title.module.css new file mode 100644 index 000000000..585d1f97b --- /dev/null +++ b/src/components/options/fields/styles/title.module.css @@ -0,0 +1,4 @@ +.customInput { + margin-block-start: var(--spacers-dp4); + margin-inline-start: 23px; +} diff --git a/src/components/options/fields/title.tsx b/src/components/options/fields/title.tsx index 45aef7a24..4aa424106 100644 --- a/src/components/options/fields/title.tsx +++ b/src/components/options/fields/title.tsx @@ -1,19 +1,95 @@ import i18n from '@dhis2/d2-i18n' -import { type FC } from 'react' -import { TextBaseOption } from './text-base-option' +import { Field, InputField, Radio } from '@dhis2/ui' +import { useOptionsField } from '@hooks' +import { isPopulatedString } from '@modules/utils/guards' +import { useCallback, useState, type FC } from 'react' +import classes from './styles/title.module.css' + +type TitleMode = 'AUTO' | 'NONE' | 'CUSTOM' + +const getStoredMode = ( + title: string | undefined, + hideTitle: boolean | undefined +): TitleMode => { + if (hideTitle) { + return 'NONE' + } + return isPopulatedString(title) ? 'CUSTOM' : 'AUTO' +} type TitleProps = { label: string } -export const Title: FC<TitleProps> = ({ label }) => ( - <TextBaseOption - type="text" - inputWidth="280px" - label={label} - placeholder={i18n.t('Add a title')} - option={{ - name: 'title', - }} - /> -) +/* The mode is derived from `title` and `hideTitle` rather than stored, so a + * custom title typed and then toggled away from has nowhere persistent to + * live. Keeping it here means it survives toggling while the modal is open + * and is discarded when the modal unmounts, which is what the spec asks for. */ +export const Title: FC<TitleProps> = ({ label }) => { + const [title, setTitle] = useOptionsField('title') + const [hideTitle, setHideTitle] = useOptionsField('hideTitle') + + const [mode, setMode] = useState<TitleMode>(() => + getStoredMode(title, hideTitle) + ) + const [draft, setDraft] = useState<string>(title ?? '') + + const applyMode = useCallback( + (nextMode: TitleMode) => { + setMode(nextMode) + setHideTitle(nextMode === 'NONE') + setTitle(nextMode === 'CUSTOM' ? draft : '') + }, + [draft, setHideTitle, setTitle] + ) + + const applyDraft = useCallback( + (value: string) => { + setDraft(value) + setTitle(value) + }, + [setTitle] + ) + + return ( + <Field name="title" label={label}> + <Radio + dense + name="titleMode" + label={i18n.t('Auto generated')} + checked={mode === 'AUTO'} + onChange={() => applyMode('AUTO')} + dataTest="title-mode-auto" + /> + <Radio + dense + name="titleMode" + label={i18n.t('None')} + checked={mode === 'NONE'} + onChange={() => applyMode('NONE')} + dataTest="title-mode-none" + /> + <Radio + dense + name="titleMode" + label={i18n.t('Custom')} + checked={mode === 'CUSTOM'} + onChange={() => applyMode('CUSTOM')} + dataTest="title-mode-custom" + /> + {mode === 'CUSTOM' && ( + <div className={classes.customInput}> + <InputField + dense + name="title" + inputWidth="280px" + placeholder={i18n.t('Add a title')} + value={draft} + onChange={({ value }) => applyDraft(value ?? '')} + dataTest="title-input" + /> + </div> + )} + </Field> + ) +} From e0cc28a405ea11c97a4acac23b16237401452a72 Mon Sep 17 00:00:00 2001 From: Hendrik de Graaf <hendrik@dhis2.org> Date: Wed, 23 Sep 2026 18:03:43 +0200 Subject: [PATCH 06/33] feat: reset the title configuration when the visualization type changes --- src/store/__tests__/listeners.spec.ts | 46 ++++++++++++++++++++++++--- src/store/listeners.ts | 9 +++++- 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/src/store/__tests__/listeners.spec.ts b/src/store/__tests__/listeners.spec.ts index 9b659a159..0f4641e15 100644 --- a/src/store/__tests__/listeners.spec.ts +++ b/src/store/__tests__/listeners.spec.ts @@ -7,6 +7,8 @@ import { navigationSlice, setNavigationState } from '../navigation-slice' import type { NavigationState } from '../navigation-slice' import { tClearVisualization, tLoadSavedVisualization } from '../thunks' import { + setVisUiConfigOption, + setVisUiConfigOutputType, setVisUiConfigVisualizationType, visUiConfigSlice, } from '../vis-ui-config-slice' @@ -82,17 +84,20 @@ describe('navigationSlice listener', () => { }) describe('visUiConfig visualization type listener', () => { + const createVisUiConfigStore = () => + configureStore({ + reducer: { visUiConfig: visUiConfigSlice.reducer }, + middleware: (getDefaultMiddleware) => + getDefaultMiddleware().prepend(listenerMiddleware.middleware), + }) + beforeEach(() => { listenerMiddleware.clearListeners() registerAppListeners() }) it('stores the chosen visualization type as the last used one', async () => { - const store = configureStore({ - reducer: { visUiConfig: visUiConfigSlice.reducer }, - middleware: (getDefaultMiddleware) => - getDefaultMiddleware().prepend(listenerMiddleware.middleware), - }) + const store = createVisUiConfigStore() store.dispatch(setVisUiConfigVisualizationType('PIVOT_TABLE')) await Promise.resolve() @@ -101,4 +106,35 @@ describe('visUiConfig visualization type listener', () => { 'PIVOT_TABLE' ) }) + + it('resets the title to auto generated when the visualization type changes', async () => { + const store = createVisUiConfigStore() + store.dispatch(setVisUiConfigOption({ key: 'title', value: 'Mine' })) + + store.dispatch(setVisUiConfigVisualizationType('PIVOT_TABLE')) + await Promise.resolve() + + expect(store.getState().visUiConfig.options.title).toBe('') + expect(store.getState().visUiConfig.options.hideTitle).toBe(false) + }) + + it('clears a hidden title when the visualization type changes', async () => { + const store = createVisUiConfigStore() + store.dispatch(setVisUiConfigOption({ key: 'hideTitle', value: true })) + + store.dispatch(setVisUiConfigVisualizationType('PIVOT_TABLE')) + await Promise.resolve() + + expect(store.getState().visUiConfig.options.hideTitle).toBe(false) + }) + + it('leaves the title alone when the output type changes', async () => { + const store = createVisUiConfigStore() + store.dispatch(setVisUiConfigOption({ key: 'title', value: 'Mine' })) + + store.dispatch(setVisUiConfigOutputType('ENROLLMENT')) + await Promise.resolve() + + expect(store.getState().visUiConfig.options.title).toBe('Mine') + }) }) diff --git a/src/store/listeners.ts b/src/store/listeners.ts index 26569743b..573d33659 100644 --- a/src/store/listeners.ts +++ b/src/store/listeners.ts @@ -11,6 +11,7 @@ import { setUiActiveDimensionModal } from './ui-slice' import { addVisUiConfigLayoutDimension, addVisUiConfigLayoutDimensions, + setVisUiConfigOption, setVisUiConfigVisualizationType, } from './vis-ui-config-slice' @@ -46,8 +47,14 @@ export const registerAppListeners = () => { startAppListening({ actionCreator: setVisUiConfigVisualizationType, - effect: (action) => { + effect: (action, { dispatch }) => { setLastUsedVisualizationTypeToLocalStorage(action.payload) + + /* The generated title differs per visualization type ("Visit + * count" vs "Visit list"), so a custom title written for one type + * rarely suits the other. Output type changes keep the title. */ + dispatch(setVisUiConfigOption({ key: 'title', value: '' })) + dispatch(setVisUiConfigOption({ key: 'hideTitle', value: false })) }, }) From 0b110e09d243c35f9bd18038a3867b66a2f0a9c8 Mon Sep 17 00:00:00 2001 From: Hendrik de Graaf <hendrik@dhis2.org> Date: Wed, 23 Sep 2026 18:06:14 +0200 Subject: [PATCH 07/33] chore: extract i18n strings --- i18n/en.pot | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index f473440ba..39552fc94 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -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-23T14:32:18.188Z\n" -"PO-Revision-Date: 2026-09-23T14:32:18.188Z\n" +"POT-Creation-Date: 2026-10-01T13:13:57.484Z\n" +"PO-Revision-Date: 2026-10-01T13:13:57.484Z\n" msgid "" "Some dimensions were not added because they cannot be used in a " @@ -594,6 +594,12 @@ msgstr "Skip rounding" msgid "Add a subtitle" msgstr "Add a subtitle" +msgid "Auto generated" +msgstr "Auto generated" + +msgid "Custom" +msgstr "Custom" + msgid "Add a title" msgstr "Add a title" @@ -911,9 +917,6 @@ msgstr "Min value (sum in org unit hierarchy)" msgid "Max value (sum in org unit hierarchy)" msgstr "Max value (sum in org unit hierarchy)" -msgid "Custom" -msgstr "Custom" - msgid "equal to (=)" msgstr "equal to (=)" @@ -1300,6 +1303,18 @@ msgstr "Completed" msgid "Scheduled" msgstr "Scheduled" +msgid "{{- label}} count" +msgstr "{{- label}} count" + +msgid "{{- label}} list" +msgstr "{{- label}} list" + +msgid "Enrollments" +msgstr "Enrollments" + +msgid "Events" +msgstr "Events" + msgid "" "Track or compare changes over time. Recommend period as category. (adjust " "for EVER)" From 4dcd4aead54047138798ba3f8547cdc37806660a Mon Sep 17 00:00:00 2001 From: Hendrik de Graaf <hendrik@dhis2.org> Date: Wed, 23 Sep 2026 18:10:25 +0200 Subject: [PATCH 08/33] refactor: reuse the shared toggleable option style for the title input --- src/components/options/fields/styles/title.module.css | 4 ---- src/components/options/fields/title.tsx | 4 ++-- 2 files changed, 2 insertions(+), 6 deletions(-) delete mode 100644 src/components/options/fields/styles/title.module.css diff --git a/src/components/options/fields/styles/title.module.css b/src/components/options/fields/styles/title.module.css deleted file mode 100644 index 585d1f97b..000000000 --- a/src/components/options/fields/styles/title.module.css +++ /dev/null @@ -1,4 +0,0 @@ -.customInput { - margin-block-start: var(--spacers-dp4); - margin-inline-start: 23px; -} diff --git a/src/components/options/fields/title.tsx b/src/components/options/fields/title.tsx index 4aa424106..856d47e0d 100644 --- a/src/components/options/fields/title.tsx +++ b/src/components/options/fields/title.tsx @@ -3,7 +3,7 @@ import { Field, InputField, Radio } from '@dhis2/ui' import { useOptionsField } from '@hooks' import { isPopulatedString } from '@modules/utils/guards' import { useCallback, useState, type FC } from 'react' -import classes from './styles/title.module.css' +import classes from './styles/option.module.css' type TitleMode = 'AUTO' | 'NONE' | 'CUSTOM' @@ -78,7 +78,7 @@ export const Title: FC<TitleProps> = ({ label }) => { dataTest="title-mode-custom" /> {mode === 'CUSTOM' && ( - <div className={classes.customInput}> + <div className={classes.optionToggleable}> <InputField dense name="title" From 1d4b264204963c7ade183d5542a686d32ffa0b76 Mon Sep 17 00:00:00 2001 From: Hendrik de Graaf <hendrik@dhis2.org> Date: Thu, 24 Sep 2026 14:33:29 +0200 Subject: [PATCH 09/33] refactor: extract condition texts, item names and layout dimensions as pure helpers --- .../axis/use-tooltip-content-data.ts | 77 ++---------- .../layout-panel/use-layout-dimensions.ts | 24 +--- src/hooks/use-conditions-texts.ts | 112 +++-------------- src/modules/condition-texts.ts | 114 ++++++++++++++++++ src/modules/dimension/item-names.ts | 72 +++++++++++ src/modules/dimension/layout-dimension.ts | 35 ++++++ src/modules/utils/dates.ts | 28 +++-- vitest.config.mts | 3 + 8 files changed, 269 insertions(+), 196 deletions(-) create mode 100644 src/modules/condition-texts.ts create mode 100644 src/modules/dimension/item-names.ts create mode 100644 src/modules/dimension/layout-dimension.ts diff --git a/src/components/layout-panel/axis/use-tooltip-content-data.ts b/src/components/layout-panel/axis/use-tooltip-content-data.ts index 5effcc077..18a542909 100644 --- a/src/components/layout-panel/axis/use-tooltip-content-data.ts +++ b/src/components/layout-panel/axis/use-tooltip-content-data.ts @@ -1,32 +1,14 @@ -import { ouIdHelper } from '@dhis2/analytics' -import i18n from '@dhis2/d2-i18n' import { useMetadataItems, useAppSelector } from '@hooks' -import { extractPlainDimensionId } from '@modules/dimension/ids' -import { isProgramMetadataItem } from '@modules/metadata/item-guards' import { - isStartEndDate, - useLocalizedStartEndDateFormatter, -} from '@modules/utils/dates' + getItemDisplayNames, + getItemMetadataIds, +} from '@modules/dimension/item-names' +import { isProgramMetadataItem } from '@modules/metadata/item-guards' +import { useLocalizedStartEndDateFormatter } from '@modules/utils/dates' import { getVisUiConfigItemsByDimension } from '@store/vis-ui-config-slice' import { useMemo } from 'react' import type { LayoutDimension } from './chip' -// Helper function to format a list of metadata IDs into a labeled string -const getNameList = ( - idList: Array<string>, - label: string, - metadataItems: ReturnType<typeof useMetadataItems> -) => - idList.reduce((levelString, levelId, index) => { - if (index > 0) { - levelString += ', ' - } - const levelName = metadataItems[levelId]?.name - levelString += levelName ?? levelId - - return levelString - }, `${label}: `) - export const useTooltipContentData = (dimension: LayoutDimension) => { const itemIds = useAppSelector((state) => getVisUiConfigItemsByDimension(state, dimension.id) @@ -36,7 +18,7 @@ export const useTooltipContentData = (dimension: LayoutDimension) => { // Collect all metadata IDs that will be needed const metadataIds = useMemo(() => { - const ids = new Set<string>() + const ids = new Set<string>(getItemMetadataIds(itemIds)) if (programId) { ids.add(programId) @@ -45,18 +27,6 @@ export const useTooltipContentData = (dimension: LayoutDimension) => { ids.add(programStageId) } - // Add IDs from itemIds processing - itemIds.forEach((id) => { - if ( - ouIdHelper.hasLevelPrefix(id) || - ouIdHelper.hasGroupPrefix(id) - ) { - ids.add(ouIdHelper.removePrefix(id)) - } else { - ids.add(extractPlainDimensionId(id)) - } - }) - return Array.from(ids) }, [programId, programStageId, itemIds]) @@ -85,39 +55,12 @@ export const useTooltipContentData = (dimension: LayoutDimension) => { const programName = programMetadata?.name ?? '' const stageName = programStage?.name ?? '' - // Item display names - const levelIds: Array<string> = [] - const groupIds: Array<string> = [] - const itemDisplayNames: Array<string> = [] - - itemIds.forEach((id) => { - if (ouIdHelper.hasLevelPrefix(id)) { - levelIds.push(ouIdHelper.removePrefix(id)) - } else if (ouIdHelper.hasGroupPrefix(id)) { - groupIds.push(ouIdHelper.removePrefix(id)) - } else { - const plainId = extractPlainDimensionId(id) - itemDisplayNames.push( - isStartEndDate(plainId) - ? formatStartEndDate(plainId) - : (metadataItems[plainId]?.name ?? id) - ) - } + const itemDisplayNames = getItemDisplayNames({ + itemIds, + metadataItems, + formatStartEndDate, }) - // Add level and group names - if (levelIds.length > 0) { - itemDisplayNames.push( - getNameList(levelIds, i18n.t('Levels'), metadataItems) - ) - } - - if (groupIds.length > 0) { - itemDisplayNames.push( - getNameList(groupIds, i18n.t('Groups'), metadataItems) - ) - } - return { programName, stageName, diff --git a/src/components/layout-panel/use-layout-dimensions.ts b/src/components/layout-panel/use-layout-dimensions.ts index 231098366..eaa3b028e 100644 --- a/src/components/layout-panel/use-layout-dimensions.ts +++ b/src/components/layout-panel/use-layout-dimensions.ts @@ -4,6 +4,7 @@ import { useDimensionMetadataItems, useMetadataItems, } from '@hooks' +import { toLayoutDimension } from '@modules/dimension/layout-dimension' import { buildSuffixContext, getDimensionSuffix, @@ -99,28 +100,7 @@ export const useDimensionsWithSuffixes = ( throw new Error(`missing metadata for dimension ${id}`) } - const dimension: LayoutDimension = { - id, - name: metadataItem.name || id, - dimensionId: metadataItem.dimensionId ?? id, - dimensionType: metadataItem.dimensionType, - programStageId: metadataItem.programStageId, - programId: metadataItem.programId, - trackedEntityTypeId: metadataItem.trackedEntityTypeId, - suffix: getDimensionSuffix(metadataItem, context), - } - - if (metadataItem.optionSetId) { - dimension.optionSet = metadataItem.optionSetId - } - if (metadataItem.valueType) { - dimension.valueType = metadataItem.valueType - } - if (metadataItem.dimensionItemType) { - dimension.dimensionItemType = metadataItem.dimensionItemType - } - - acc[id] = dimension + acc[id] = toLayoutDimension(id, metadataItem, context) return acc }, {} diff --git a/src/hooks/use-conditions-texts.ts b/src/hooks/use-conditions-texts.ts index 7def1442c..78d3ca650 100644 --- a/src/hooks/use-conditions-texts.ts +++ b/src/hooks/use-conditions-texts.ts @@ -1,19 +1,9 @@ import type { LayoutDimension } from '@components/layout-panel/axis/chip' -import { ouIdHelper } from '@dhis2/analytics' import { useMetadataItems } from '@hooks' import { - parseConditionsStringToArray, - shouldUseLegendSetConditions, - shouldUseOptionSetConditions, - shouldUseBooleanConditions, - shouldUseOrgUnitConditions, - getLegendSetConditionMetadataIds, - getOptionSetIdAndSelectedOptionCodes, - getBooleanConditionTexts, - getOrgUnitConditionMetadataIds, - getOperatorConditionTexts, -} from '@modules/conditions' -import { isOptionSetMetadataItem } from '@modules/metadata/item-guards' + getConditionsMetadataIds, + getConditionsTexts, +} from '@modules/condition-texts' import type { SavedVisualization } from '@types' import { useMemo } from 'react' @@ -39,90 +29,20 @@ export const useConditionsTexts = ({ dimension, formatValueOptions, }: UseConditionsTextsParams): string[] => { - const conditionsList = useMemo( - () => parseConditionsStringToArray(conditions?.condition ?? ''), - [conditions?.condition] + const metadataIds = useMemo( + () => getConditionsMetadataIds({ conditions, dimension }), + [conditions, dimension] ) - const metadataIds = useMemo(() => { - if (shouldUseLegendSetConditions(conditions)) { - return getLegendSetConditionMetadataIds(conditions, conditionsList) - } - if (shouldUseOrgUnitConditions(conditions, dimension, conditionsList)) { - return getOrgUnitConditionMetadataIds(conditionsList, true) - } - if ( - shouldUseOptionSetConditions(conditions, dimension, conditionsList) - ) { - const { optionSetId } = getOptionSetIdAndSelectedOptionCodes( - dimension, - conditionsList - ) - return [optionSetId] - } - - return [] - }, [conditionsList, conditions, dimension]) const metadataItems = useMetadataItems(metadataIds) - const names = useMemo(() => { - if (shouldUseLegendSetConditions(conditions)) { - // Prefer name, fallback to ID - return metadataIds.map((id) => metadataItems[id]?.name ?? id) - } - if (shouldUseOrgUnitConditions(conditions, dimension, conditionsList)) { - // Prefer name from original ID, fallback to unprefixed ID name, then ID - const idsWithoutUnprefixed = getOrgUnitConditionMetadataIds( - conditionsList, - false - ) - return idsWithoutUnprefixed.map((id) => { - const metadataItem = metadataItems[id] - if (metadataItem?.name) { - return metadataItem.name - } - // Try unprefixed version if original has no name - const unprefixedId = ouIdHelper.removePrefix(id) - const unprefixedMetadataItem = metadataItems[unprefixedId] - return unprefixedMetadataItem?.name ?? id - }) - } - if ( - shouldUseOptionSetConditions(conditions, dimension, conditionsList) - ) { - const { optionSetId, selectedOptionCodes } = - getOptionSetIdAndSelectedOptionCodes(dimension, conditionsList) - const optionSetMetadata = metadataItems[optionSetId] - - if (isOptionSetMetadataItem(optionSetMetadata)) { - const selectedOptionCodesLookup = new Set(selectedOptionCodes) - return ( - optionSetMetadata.options - .filter((option) => - selectedOptionCodesLookup.has(option.code) - ) - // Prefer name - .map((option) => option.name) - ) - } else { - // Fallback to ID - return selectedOptionCodes - } - } - if (shouldUseBooleanConditions(conditions, dimension, conditionsList)) { - return getBooleanConditionTexts(conditionsList) - } - return getOperatorConditionTexts( - dimension, - conditionsList, - formatValueOptions - ) - }, [ - conditions, - dimension, - conditionsList, - metadataIds, - metadataItems, - formatValueOptions, - ]) - return names + return useMemo( + () => + getConditionsTexts({ + conditions, + dimension, + formatValueOptions, + metadataItems, + }), + [conditions, dimension, formatValueOptions, metadataItems] + ) } diff --git a/src/modules/condition-texts.ts b/src/modules/condition-texts.ts new file mode 100644 index 000000000..0b9eaa80a --- /dev/null +++ b/src/modules/condition-texts.ts @@ -0,0 +1,114 @@ +import type { LayoutDimension } from '@components/layout-panel/axis/chip' +import { ouIdHelper } from '@dhis2/analytics' +import { + getBooleanConditionTexts, + getLegendSetConditionMetadataIds, + getOperatorConditionTexts, + getOptionSetIdAndSelectedOptionCodes, + getOrgUnitConditionMetadataIds, + parseConditionsStringToArray, + shouldUseBooleanConditions, + shouldUseLegendSetConditions, + shouldUseOptionSetConditions, + shouldUseOrgUnitConditions, +} from '@modules/conditions' +import { isOptionSetMetadataItem } from '@modules/metadata/item-guards' +import type { MetadataItem, SavedVisualization } from '@types' + +export type Conditions = { + condition?: string | string[] + legendSet?: string +} + +export type ConditionsFormatValueOptions = { + locale?: string + digitGroupSeparator?: SavedVisualization['digitGroupSeparator'] + baseUrl?: string +} + +type ConditionsMetadataItems = Record<string, MetadataItem | undefined> + +/* Split from getConditionsTexts so a React caller can feed the IDs to a + * subscribing hook, while a plain caller reads them straight from the store. */ +export const getConditionsMetadataIds = ({ + conditions, + dimension, +}: { + conditions: Conditions + dimension: LayoutDimension +}): string[] => { + const conditionsList = parseConditionsStringToArray( + conditions?.condition ?? '' + ) + + if (shouldUseLegendSetConditions(conditions)) { + return getLegendSetConditionMetadataIds(conditions, conditionsList) + } + if (shouldUseOrgUnitConditions(conditions, dimension, conditionsList)) { + return getOrgUnitConditionMetadataIds(conditionsList, true) + } + if (shouldUseOptionSetConditions(conditions, dimension, conditionsList)) { + return [ + getOptionSetIdAndSelectedOptionCodes(dimension, conditionsList) + .optionSetId, + ] + } + return [] +} + +export const getConditionsTexts = ({ + conditions, + dimension, + formatValueOptions, + metadataItems, +}: { + conditions: Conditions + dimension: LayoutDimension + formatValueOptions: ConditionsFormatValueOptions + metadataItems: ConditionsMetadataItems +}): string[] => { + const conditionsList = parseConditionsStringToArray( + conditions?.condition ?? '' + ) + + if (shouldUseLegendSetConditions(conditions)) { + return getLegendSetConditionMetadataIds(conditions, conditionsList).map( + (id) => metadataItems[id]?.name ?? id + ) + } + + if (shouldUseOrgUnitConditions(conditions, dimension, conditionsList)) { + /* Passed `false` so the prefixed ID is returned: the prefixed entry is + * preferred for naming, with the unprefixed one as the fallback. */ + return getOrgUnitConditionMetadataIds(conditionsList, false).map( + (id) => + metadataItems[id]?.name ?? + metadataItems[ouIdHelper.removePrefix(id)]?.name ?? + id + ) + } + + if (shouldUseOptionSetConditions(conditions, dimension, conditionsList)) { + const { optionSetId, selectedOptionCodes } = + getOptionSetIdAndSelectedOptionCodes(dimension, conditionsList) + const optionSetMetadata = metadataItems[optionSetId] + + if (!isOptionSetMetadataItem(optionSetMetadata)) { + return selectedOptionCodes + } + const selectedOptionCodesLookup = new Set(selectedOptionCodes) + return optionSetMetadata.options + .filter((option) => selectedOptionCodesLookup.has(option.code)) + .map((option) => option.name) + } + + if (shouldUseBooleanConditions(conditions, dimension, conditionsList)) { + return getBooleanConditionTexts(conditionsList) + } + + return getOperatorConditionTexts( + dimension, + conditionsList, + formatValueOptions + ) +} diff --git a/src/modules/dimension/item-names.ts b/src/modules/dimension/item-names.ts new file mode 100644 index 000000000..5d5b12673 --- /dev/null +++ b/src/modules/dimension/item-names.ts @@ -0,0 +1,72 @@ +import { ouIdHelper } from '@dhis2/analytics' +import i18n from '@dhis2/d2-i18n' +import { extractPlainDimensionId } from '@modules/dimension/ids' +import { isStartEndDate } from '@modules/utils/dates' +import type { MetadataItem } from '@types' + +type ItemMetadataItems = Record<string, MetadataItem | undefined> + +export const getItemMetadataIds = (itemIds: string[]): string[] => { + const ids = new Set<string>() + + for (const id of itemIds) { + ids.add( + ouIdHelper.hasLevelPrefix(id) || ouIdHelper.hasGroupPrefix(id) + ? ouIdHelper.removePrefix(id) + : extractPlainDimensionId(id) + ) + } + + return Array.from(ids) +} + +const getNameList = ( + ids: string[], + label: string, + metadataItems: ItemMetadataItems +): string => + `${label}: ${ids.map((id) => metadataItems[id]?.name ?? id).join(', ')}` + +/* Org unit levels and groups are collapsed into one "Levels: a, b" entry each, + * rather than listed alongside the individually selected org units. */ +export const getItemDisplayNames = ({ + itemIds, + metadataItems, + formatStartEndDate, +}: { + itemIds: string[] + metadataItems: ItemMetadataItems + formatStartEndDate: (startEndDate: string) => string +}): string[] => { + const levelIds: string[] = [] + const groupIds: string[] = [] + const displayNames: string[] = [] + + for (const id of itemIds) { + if (ouIdHelper.hasLevelPrefix(id)) { + levelIds.push(ouIdHelper.removePrefix(id)) + } else if (ouIdHelper.hasGroupPrefix(id)) { + groupIds.push(ouIdHelper.removePrefix(id)) + } else { + const plainId = extractPlainDimensionId(id) + displayNames.push( + isStartEndDate(plainId) + ? formatStartEndDate(plainId) + : (metadataItems[plainId]?.name ?? id) + ) + } + } + + if (levelIds.length > 0) { + displayNames.push( + getNameList(levelIds, i18n.t('Levels'), metadataItems) + ) + } + if (groupIds.length > 0) { + displayNames.push( + getNameList(groupIds, i18n.t('Groups'), metadataItems) + ) + } + + return displayNames +} diff --git a/src/modules/dimension/layout-dimension.ts b/src/modules/dimension/layout-dimension.ts new file mode 100644 index 000000000..239a199e2 --- /dev/null +++ b/src/modules/dimension/layout-dimension.ts @@ -0,0 +1,35 @@ +import type { LayoutDimension } from '@components/layout-panel/axis/chip' +import { + getDimensionSuffix, + type SuffixContext, +} from '@modules/dimension/suffix' +import type { DimensionMetadataItem } from '@types' + +export const toLayoutDimension = ( + id: string, + metadataItem: DimensionMetadataItem, + suffixContext: SuffixContext +): LayoutDimension => { + const dimension: LayoutDimension = { + id, + name: metadataItem.name || id, + dimensionId: metadataItem.dimensionId ?? id, + dimensionType: metadataItem.dimensionType, + programStageId: metadataItem.programStageId, + programId: metadataItem.programId, + trackedEntityTypeId: metadataItem.trackedEntityTypeId, + suffix: getDimensionSuffix(metadataItem, suffixContext), + } + + if (metadataItem.optionSetId) { + dimension.optionSet = metadataItem.optionSetId + } + if (metadataItem.valueType) { + dimension.valueType = metadataItem.valueType + } + if (metadataItem.dimensionItemType) { + dimension.dimensionItemType = metadataItem.dimensionItemType + } + + return dimension +} diff --git a/src/modules/utils/dates.ts b/src/modules/utils/dates.ts index 173d19f46..f9a931a8c 100644 --- a/src/modules/utils/dates.ts +++ b/src/modules/utils/dates.ts @@ -15,25 +15,31 @@ export const isStartEndDate = (id: string): boolean => type UseLocalizedStartEndDateFormatterResult = (startEndDate: string) => string +/* `locale` undefined resolves to the runtime default, which is what the + * dashboard plugin gets: it has no access to the user's settings. */ +export const getStartEndDateFormatter = ( + locale?: string +): UseLocalizedStartEndDateFormatterResult => { + const formatter = new Intl.DateTimeFormat(locale, { dateStyle: 'long' }) + + return (startEndDate) => + getStartEndDate(startEndDate) + .map((dateStr: string) => formatter.format(new Date(dateStr))) + .join(' - ') +} + export const useLocalizedStartEndDateFormatter = (): UseLocalizedStartEndDateFormatterResult => { const currentUser = useCurrentUser() + const uiLocale = currentUser.settings.uiLocale const formatter = useMemo( - () => - new Intl.DateTimeFormat(currentUser.settings.uiLocale, { - dateStyle: 'long', - }), - [currentUser] + () => getStartEndDateFormatter(uiLocale), + [uiLocale] ) return useCallback( - (startEndDate) => - getStartEndDate(startEndDate) - .map((dateStr: string) => - formatter.format(new Date(dateStr)) - ) - .join(' - '), + (startEndDate) => formatter(startEndDate), [formatter] ) } diff --git a/vitest.config.mts b/vitest.config.mts index a378c2797..c03332ab6 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -18,6 +18,9 @@ export default defineConfig({ ...configDefaults.exclude, '**/.d2/**', '**/.claude/**', + /* Installing a git dependency leaves the extracted package, tests + * and all, in the project-local pnpm store. */ + '**/.pnpm-store/**', // Third-party sources fetched for reference by `npx opensrc` '**/opensrc/**', ], From 21bdc21053745552da113496229938b568f1ab30 Mon Sep 17 00:00:00 2001 From: Hendrik de Graaf <hendrik@dhis2.org> Date: Thu, 24 Sep 2026 14:40:02 +0200 Subject: [PATCH 10/33] feat: show a filter line on line lists and pivot tables --- package.json | 2 +- pnpm-lock.yaml | 11 +- src/components/canvas/canvas.tsx | 1 + .../line-list/__tests__/line-list.spec.tsx | 33 +++ src/components/line-list/line-list.tsx | 8 + .../plugin-wrapper/line-list-plugin.tsx | 3 + .../plugin-wrapper/pivot-table-plugin.tsx | 10 +- .../plugin-wrapper/plugin-wrapper.tsx | 21 ++ .../__tests__/filter-text.spec.ts | 234 ++++++++++++++++++ src/modules/visualization/filter-text.ts | 178 +++++++++++++ src/types/analytics/pivot-table.ts | 3 + 11 files changed, 497 insertions(+), 7 deletions(-) create mode 100644 src/modules/visualization/__tests__/filter-text.spec.ts create mode 100644 src/modules/visualization/filter-text.ts diff --git a/package.json b/package.json index 4a41d9101..de02cc283 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "vitest-canvas-mock": "^1.2.0" }, "dependencies": { - "@dhis2/analytics": "^29.6.2", + "@dhis2/analytics": "git+https://github.com/d2-ci/analytics.git#ca872daa11f1d7ed8d8e545cea9841d80ed91ed0", "@dhis2/app-runtime": "^3.17.4", "@dhis2/ui": "^10.18.0", "@dnd-kit/core": "^6.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index af9869810..eac826cc1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: '@dhis2/analytics': - specifier: ^29.6.2 - version: 29.6.2(@dhis2/app-runtime@3.17.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@dhis2/d2-i18n@1.2.0)(@dhis2/ui@10.18.0(@dhis2/app-runtime@3.17.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@dhis2/d2-i18n@1.2.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-jsx@4.0.1(@babel/core@7.29.7)(react@18.3.1)))(prop-types@15.8.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-jsx@4.0.1(@babel/core@7.29.7)(react@18.3.1)) + specifier: git+https://github.com/d2-ci/analytics.git#ca872daa11f1d7ed8d8e545cea9841d80ed91ed0 + version: https://codeload.github.com/d2-ci/analytics/tar.gz/ca872daa11f1d7ed8d8e545cea9841d80ed91ed0(@dhis2/app-runtime@3.17.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@dhis2/d2-i18n@1.2.0)(@dhis2/ui@10.18.0(@dhis2/app-runtime@3.17.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@dhis2/d2-i18n@1.2.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-jsx@4.0.1(@babel/core@7.29.7)(react@18.3.1)))(prop-types@15.8.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-jsx@4.0.1(@babel/core@7.29.7)(react@18.3.1)) '@dhis2/app-runtime': specifier: ^3.17.4 version: 3.17.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -1435,8 +1435,9 @@ packages: react-dom: ^16.13 || ^18 styled-jsx: ^4 - '@dhis2/analytics@29.6.2': - resolution: {integrity: sha512-S6viY02Ay/pQifScUiEorVFYzfDATDaLeF83SprzWb9sGmxilXL0ljF3ElTyJQo0TexXo/sagzp91LiQWtjkTg==} + '@dhis2/analytics@https://codeload.github.com/d2-ci/analytics/tar.gz/ca872daa11f1d7ed8d8e545cea9841d80ed91ed0': + resolution: {gitHosted: true, integrity: sha512-RkRaSNiXpfWYCnYdw9fDtPBkOuaNh2Lqs1E+/hzOmOWdS+EuWj2iySCf5vPi7QPafdf4elKL45RG8hmFqx92fA==, tarball: https://codeload.github.com/d2-ci/analytics/tar.gz/ca872daa11f1d7ed8d8e545cea9841d80ed91ed0} + version: 29.7.0 peerDependencies: '@dhis2/app-runtime': ^3 '@dhis2/d2-i18n': ^1.1 @@ -9770,7 +9771,7 @@ snapshots: react-dom: 18.3.1(react@18.3.1) styled-jsx: 4.0.1(@babel/core@7.29.7)(react@18.3.1) - '@dhis2/analytics@29.6.2(@dhis2/app-runtime@3.17.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@dhis2/d2-i18n@1.2.0)(@dhis2/ui@10.18.0(@dhis2/app-runtime@3.17.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@dhis2/d2-i18n@1.2.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-jsx@4.0.1(@babel/core@7.29.7)(react@18.3.1)))(prop-types@15.8.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-jsx@4.0.1(@babel/core@7.29.7)(react@18.3.1))': + '@dhis2/analytics@https://codeload.github.com/d2-ci/analytics/tar.gz/ca872daa11f1d7ed8d8e545cea9841d80ed91ed0(@dhis2/app-runtime@3.17.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@dhis2/d2-i18n@1.2.0)(@dhis2/ui@10.18.0(@dhis2/app-runtime@3.17.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@dhis2/d2-i18n@1.2.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-jsx@4.0.1(@babel/core@7.29.7)(react@18.3.1)))(prop-types@15.8.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-jsx@4.0.1(@babel/core@7.29.7)(react@18.3.1))': dependencies: '@dhis2/app-runtime': 3.17.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@dhis2/d2-i18n': 1.2.0 diff --git a/src/components/canvas/canvas.tsx b/src/components/canvas/canvas.tsx index f22c82dfe..024d86358 100644 --- a/src/components/canvas/canvas.tsx +++ b/src/components/canvas/canvas.tsx @@ -74,6 +74,7 @@ export const Canvas: FC = () => { visualizationLoadError={visualizationLoadError ?? undefined} onRetryLoad={onRetryLoad} displayProperty={currentUser.settings.displayProperty} + locale={currentUser.settings.uiLocale} onColumnHeaderClick={onColumnHeaderClick} onDataSorted={onDataSorted} /> diff --git a/src/components/line-list/__tests__/line-list.spec.tsx b/src/components/line-list/__tests__/line-list.spec.tsx index 2af13e116..f08bd39b9 100644 --- a/src/components/line-list/__tests__/line-list.spec.tsx +++ b/src/components/line-list/__tests__/line-list.spec.tsx @@ -719,6 +719,39 @@ describe('LineList', () => { ).toBeInTheDocument() }) + it('renders a filter line below the title for a filtered visualization', async () => { + const canonical = asCanonicalPayload(simpleLineList) + await renderLineList(simpleLineList, { + eventVisualization: { + ...canonical, + filters: [ + { + dimension: 'programStatus', + dimensionType: 'STATUS', + program: ( + simpleLineList.eventVisualization as { + program: { id: string } + } + ).program, + items: [{ id: 'ACTIVE' }], + }, + ], + }, + }) + + expect( + await screen.findByText(/Enrollment status: Active/) + ).toBeInTheDocument() + }) + + it('renders no filter line when there are no filters', async () => { + await renderLineList(simpleLineList, { + eventVisualization: asCanonicalPayload(simpleLineList), + }) + + expect(screen.queryByText(/Enrollment status/)).toBeNull() + }) + it('renders no title when the title is hidden', async () => { await renderLineList(simpleLineList, { eventVisualization: { diff --git a/src/components/line-list/line-list.tsx b/src/components/line-list/line-list.tsx index 56a10a9bc..8d042c586 100644 --- a/src/components/line-list/line-list.tsx +++ b/src/components/line-list/line-list.tsx @@ -62,6 +62,7 @@ type LineListProps = { isInDashboard?: boolean isInModal?: boolean onColumnHeaderClick?: ColumnHeaderClickFn + filterText?: string } export const LineList: FC<LineListProps> = (props) => @@ -82,6 +83,7 @@ const LineListInternal: FC<LineListProps> = ({ isInDashboard = false, isInModal = false, onColumnHeaderClick, + filterText, }) => { const { isDisconnected } = useDhis2ConnectionStatus() const { headers, rows, pager, legendSets } = useTransformedLineListData( @@ -165,6 +167,12 @@ const LineListInternal: FC<LineListProps> = ({ colSpan={colSpan} /> )} + {filterText && ( + <HeadingRow + text={filterText} + colSpan={colSpan} + /> + )} <DataTableRow dataTest="line-list-data-table-head-row"> {headers.map((header) => ( <HeaderCell diff --git a/src/components/plugin-wrapper/line-list-plugin.tsx b/src/components/plugin-wrapper/line-list-plugin.tsx index 43b2eb6c3..294bf5431 100644 --- a/src/components/plugin-wrapper/line-list-plugin.tsx +++ b/src/components/plugin-wrapper/line-list-plugin.tsx @@ -22,6 +22,7 @@ type LineListPluginProps = { onColumnHeaderClick?: ColumnHeaderClickFn onDataSorted?: (sorting: InternalSorting) => void onResponseReceived: () => void + filterText?: string } export const LineListPlugin: FC<LineListPluginProps> = ({ @@ -33,6 +34,7 @@ export const LineListPlugin: FC<LineListPluginProps> = ({ onColumnHeaderClick, onDataSorted, onResponseReceived, + filterText, }) => { const [fetchAnalyticsData, { data, isFetching }] = useLineListAnalyticsData() @@ -125,6 +127,7 @@ export const LineListPlugin: FC<LineListPluginProps> = ({ return ( <LineList + filterText={filterText} analyticsData={data} onDataSort={onDataSort} onPaginate={onPaginate} diff --git a/src/components/plugin-wrapper/pivot-table-plugin.tsx b/src/components/plugin-wrapper/pivot-table-plugin.tsx index 96c66e341..cb15a9ed5 100644 --- a/src/components/plugin-wrapper/pivot-table-plugin.tsx +++ b/src/components/plugin-wrapper/pivot-table-plugin.tsx @@ -38,6 +38,7 @@ type PivotTablePluginProps = { isInDashboard: boolean isInModal: boolean onResponseReceived: () => void + filterText?: string } export const PivotTablePlugin: FC<PivotTablePluginProps> = ({ @@ -45,6 +46,7 @@ export const PivotTablePlugin: FC<PivotTablePluginProps> = ({ visualization, relativePeriodDate, onResponseReceived, + filterText, // eslint-disable-next-line @typescript-eslint/no-unused-vars isInDashboard, // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -86,5 +88,11 @@ export const PivotTablePlugin: FC<PivotTablePluginProps> = ({ return null } - return <PivotTable visualization={eventVisualization} data={data} /> + return ( + <PivotTable + visualization={eventVisualization} + data={data} + filterText={filterText} + /> + ) } diff --git a/src/components/plugin-wrapper/plugin-wrapper.tsx b/src/components/plugin-wrapper/plugin-wrapper.tsx index 05af982b2..0656baf34 100644 --- a/src/components/plugin-wrapper/plugin-wrapper.tsx +++ b/src/components/plugin-wrapper/plugin-wrapper.tsx @@ -5,6 +5,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 { getVisualizationFilterText } from '@modules/visualization/filter-text' import { isVisualizationEmpty } from '@modules/visualization/guards' import { getVisualizationTitle } from '@modules/visualization/title' import type { @@ -46,6 +47,9 @@ const getBaseRequestIdentity = ( type PluginWrapperProps = { displayProperty: CurrentUser['settings']['displayProperty'] + /* Only used to format custom start/end dates in the filter line. The + * dashboard plugin has no access to the user's settings and omits it. */ + locale?: string visualization: CurrentVisualization | EmptyVisualization filters?: PluginFilters isInDashboard?: boolean @@ -59,6 +63,7 @@ type PluginWrapperProps = { export const PluginWrapper: FC<PluginWrapperProps> = ({ displayProperty, + locale, visualization, filters, isInDashboard = false, @@ -102,6 +107,20 @@ export const PluginWrapper: FC<PluginWrapperProps> = ({ return { ...visualization, title, hideTitle: !title } }, [visualization, metadataStore]) + /* Both renderers take the same string: the line list draws its own row, + * the pivot table engine renders it in place of its own derivation. */ + const filterText = useMemo( + () => + isVisualizationEmpty(visualization) + ? undefined + : getVisualizationFilterText({ + visualization, + metadataStore, + locale, + }), + [visualization, metadataStore, locale] + ) + const [hasAnalyticsData, setHasAnalyticsData] = useState(false) const onResponseReceived = useCallback(() => { @@ -163,6 +182,7 @@ export const PluginWrapper: FC<PluginWrapperProps> = ({ <LineListPlugin displayProperty={displayProperty} visualization={visualizationWithTitle} + filterText={filterText} relativePeriodDate={relativePeriodDate} isInDashboard={isInDashboard} isInModal={isInModal} @@ -175,6 +195,7 @@ export const PluginWrapper: FC<PluginWrapperProps> = ({ <PivotTablePlugin displayProperty={displayProperty} visualization={visualizationWithTitle} + filterText={filterText} relativePeriodDate={relativePeriodDate} isInDashboard={isInDashboard} isInModal={isInModal} diff --git a/src/modules/visualization/__tests__/filter-text.spec.ts b/src/modules/visualization/__tests__/filter-text.spec.ts new file mode 100644 index 000000000..185655fab --- /dev/null +++ b/src/modules/visualization/__tests__/filter-text.spec.ts @@ -0,0 +1,234 @@ +import { MetadataStore } from '@modules/metadata/store' +import { getVisualizationFilterText } from '@modules/visualization/filter-text' +import type { CurrentVisualization, MetadataInputMap } from '@types' +import { describe, expect, it } from 'vitest' + +const PROGRAM_ID = 'prg1' +const STAGE_ID = 'stg1' + +const buildStore = (items: MetadataInputMap = {}) => { + const store = new MetadataStore({}) + store.addMetadata({ + [PROGRAM_ID]: { + id: PROGRAM_ID, + name: 'Programme', + programType: 'WITHOUT_REGISTRATION', + }, + [STAGE_ID]: { + id: STAGE_ID, + name: 'Stage one', + repeatable: false, + hideDueDate: false, + program: { id: PROGRAM_ID }, + }, + ...items, + }) + return store +} + +const buildVis = ( + filters: CurrentVisualization['filters'], + overrides: Partial<CurrentVisualization> = {} +): CurrentVisualization => + ({ + type: 'LINE_LIST', + outputType: 'EVENT', + columns: [], + rows: [], + filters, + ...overrides, + }) as CurrentVisualization + +describe('getVisualizationFilterText', () => { + it('returns an empty string when there are no filters', () => { + expect( + getVisualizationFilterText({ + visualization: buildVis([]), + metadataStore: buildStore(), + }) + ).toBe('') + }) + + it('names an item-based filter and its selected items', () => { + const store = buildStore({ + [`${STAGE_ID}.ou`]: { + id: `${STAGE_ID}.ou`, + dimensionId: 'ou', + name: 'Org unit', + dimensionType: 'ORGANISATION_UNIT', + programId: PROGRAM_ID, + programStageId: STAGE_ID, + }, + ImspTQPwCqd: { id: 'ImspTQPwCqd', name: 'Sierra Leone' }, + O6uvpzGd5pu: { id: 'O6uvpzGd5pu', name: 'Bo' }, + }) + const vis = buildVis([ + { + dimension: 'ou', + programStage: { id: STAGE_ID }, + program: { id: PROGRAM_ID }, + items: [{ id: 'ImspTQPwCqd' }, { id: 'O6uvpzGd5pu' }], + }, + ] as CurrentVisualization['filters']) + + expect( + getVisualizationFilterText({ + visualization: vis, + metadataStore: store, + }) + ).toBe('Org unit: Sierra Leone, Bo') + }) + + it('collapses org unit levels into a single entry', () => { + const store = buildStore({ + [`${STAGE_ID}.ou`]: { + id: `${STAGE_ID}.ou`, + dimensionId: 'ou', + name: 'Org unit', + dimensionType: 'ORGANISATION_UNIT', + programId: PROGRAM_ID, + programStageId: STAGE_ID, + }, + ImspTQPwCqd: { id: 'ImspTQPwCqd', name: 'Sierra Leone' }, + LEVEL_ID: { id: 'LEVEL_ID', name: 'District' }, + }) + const vis = buildVis([ + { + dimension: 'ou', + programStage: { id: STAGE_ID }, + program: { id: PROGRAM_ID }, + items: [{ id: 'LEVEL-LEVEL_ID' }, { id: 'ImspTQPwCqd' }], + }, + ] as CurrentVisualization['filters']) + + expect( + getVisualizationFilterText({ + visualization: vis, + metadataStore: store, + }) + ).toBe('Org unit: Sierra Leone, Levels: District') + }) + + it('renders an operator condition as text', () => { + const store = buildStore({ + [`${STAGE_ID}.de1`]: { + id: `${STAGE_ID}.de1`, + dimensionId: 'de1', + name: 'Age', + dimensionType: 'DATA_ELEMENT', + valueType: 'INTEGER', + programId: PROGRAM_ID, + programStageId: STAGE_ID, + }, + }) + const vis = buildVis([ + { + dimension: 'de1', + programStage: { id: STAGE_ID }, + program: { id: PROGRAM_ID }, + dimensionType: 'DATA_ELEMENT', + valueType: 'INTEGER', + filter: 'GT:20', + items: [], + }, + ] as CurrentVisualization['filters']) + + const text = getVisualizationFilterText({ + visualization: vis, + metadataStore: store, + }) + + expect(text).toContain('Age:') + expect(text).toContain('20') + }) + + it('joins several filters with a dash', () => { + const store = buildStore({ + [`${STAGE_ID}.ou`]: { + id: `${STAGE_ID}.ou`, + dimensionId: 'ou', + name: 'Org unit', + dimensionType: 'ORGANISATION_UNIT', + programId: PROGRAM_ID, + programStageId: STAGE_ID, + }, + [`${STAGE_ID}.de1`]: { + id: `${STAGE_ID}.de1`, + dimensionId: 'de1', + name: 'Gender', + dimensionType: 'DATA_ELEMENT', + programId: PROGRAM_ID, + programStageId: STAGE_ID, + }, + ImspTQPwCqd: { id: 'ImspTQPwCqd', name: 'Sierra Leone' }, + Mnp3oXrpAbK: { id: 'Mnp3oXrpAbK', name: 'Female' }, + }) + const vis = buildVis([ + { + dimension: 'ou', + programStage: { id: STAGE_ID }, + program: { id: PROGRAM_ID }, + items: [{ id: 'ImspTQPwCqd' }], + }, + { + dimension: 'de1', + programStage: { id: STAGE_ID }, + program: { id: PROGRAM_ID }, + items: [{ id: 'Mnp3oXrpAbK' }], + }, + ] as CurrentVisualization['filters']) + + expect( + getVisualizationFilterText({ + visualization: vis, + metadataStore: store, + }) + ).toBe('Org unit: Sierra Leone - Gender: Female') + }) + + it('skips a filter with neither items nor conditions', () => { + const store = buildStore({ + [`${STAGE_ID}.ou`]: { + id: `${STAGE_ID}.ou`, + dimensionId: 'ou', + name: 'Org unit', + dimensionType: 'ORGANISATION_UNIT', + programId: PROGRAM_ID, + programStageId: STAGE_ID, + }, + }) + const vis = buildVis([ + { + dimension: 'ou', + programStage: { id: STAGE_ID }, + program: { id: PROGRAM_ID }, + items: [], + }, + ] as CurrentVisualization['filters']) + + expect( + getVisualizationFilterText({ + visualization: vis, + metadataStore: store, + }) + ).toBe('') + }) + + it('skips a dimension with no metadata rather than showing its id', () => { + const vis = buildVis([ + { + dimension: 'unknown', + programStage: { id: STAGE_ID }, + program: { id: PROGRAM_ID }, + items: [{ id: 'x' }], + }, + ] as CurrentVisualization['filters']) + + expect( + getVisualizationFilterText({ + visualization: vis, + metadataStore: buildStore(), + }) + ).toBe('') + }) +}) diff --git a/src/modules/visualization/filter-text.ts b/src/modules/visualization/filter-text.ts new file mode 100644 index 000000000..77f53ce45 --- /dev/null +++ b/src/modules/visualization/filter-text.ts @@ -0,0 +1,178 @@ +import { + getConditionsMetadataIds, + getConditionsTexts, + type Conditions, +} from '@modules/condition-texts' +import { getCompoundDimensionId } from '@modules/dimension/ids' +import { + getItemDisplayNames, + getItemMetadataIds, +} from '@modules/dimension/item-names' +import { toLayoutDimension } from '@modules/dimension/layout-dimension' +import { + buildSuffixContext, + type SuffixContext, +} from '@modules/dimension/suffix' +import { combineAllDimensionsFromVisualization } from '@modules/dimension/translation' +import { getStartEndDateFormatter } from '@modules/utils/dates' +import type { + CurrentVisualization, + DimensionRecord, + MetadataStore, +} from '@types' + +/* Matches the separator the pivot table engine has always used for its own + * filter line. */ +const FRAGMENT_SEPARATOR = ' - ' + +const getCompoundId = ( + dimension: DimensionRecord, + visualization: CurrentVisualization +): string => + getCompoundDimensionId( + dimension, + visualization.outputType, + visualization.trackedEntityType?.id + ) + +/* Suffixes disambiguate a dimension against the rest of the layout, so the + * context is built from every axis rather than the filters alone. */ +const getSuffixContext = ( + visualization: CurrentVisualization, + metadataStore: MetadataStore +): SuffixContext => { + const programIds = new Set<string>() + const programStageIds = new Set<string>() + + for (const dimension of combineAllDimensionsFromVisualization( + visualization + )) { + const item = metadataStore.getDimensionMetadataItem( + getCompoundId(dimension, visualization) + ) + if (item?.programId) { + programIds.add(item.programId) + } + if (item?.programStageId) { + programStageIds.add(item.programStageId) + } + } + + return buildSuffixContext({ + programs: Object.values( + metadataStore.getMetadataItems(Array.from(programIds)) + ), + programStages: Object.values( + metadataStore.getMetadataItems(Array.from(programStageIds)) + ), + }) +} + +const getFilterValueTexts = ({ + dimension, + conditions, + layoutDimension, + metadataStore, + locale, + digitGroupSeparator, +}: { + dimension: DimensionRecord + conditions: Conditions + layoutDimension: ReturnType<typeof toLayoutDimension> + metadataStore: MetadataStore + locale?: string + digitGroupSeparator: CurrentVisualization['digitGroupSeparator'] +}): string[] => { + if (conditions.condition || conditions.legendSet) { + return getConditionsTexts({ + conditions, + dimension: layoutDimension, + formatValueOptions: { locale, digitGroupSeparator }, + metadataItems: metadataStore.getMetadataItems( + getConditionsMetadataIds({ + conditions, + dimension: layoutDimension, + }) + ), + }) + } + + const itemIds = (dimension.items ?? []) + .map((item) => item.id) + .filter((id): id is string => Boolean(id)) + if (!itemIds.length) { + return [] + } + + return getItemDisplayNames({ + itemIds, + metadataItems: metadataStore.getMetadataItems( + getItemMetadataIds(itemIds) + ), + formatStartEndDate: getStartEndDateFormatter(locale), + }) +} + +/** + * The text of the filter line shown beneath the title and subtitle. Both + * visualisation types render it from this one function so they cannot drift: + * the line list draws its own row, the pivot table takes it as `filterText`. + * + * `locale` is only used to format custom start/end dates. It is undefined in + * the dashboard plugin, which has no access to the user's settings, and then + * falls back to the runtime default. + */ +export const getVisualizationFilterText = ({ + visualization, + metadataStore, + locale, +}: { + visualization: CurrentVisualization + metadataStore: MetadataStore + locale?: string +}): string => { + const filters = visualization.filters ?? [] + if (!filters.length) { + return '' + } + + const suffixContext = getSuffixContext(visualization, metadataStore) + const fragments: string[] = [] + + for (const dimension of filters) { + const compoundId = getCompoundId(dimension, visualization) + const metadataItem = metadataStore.getDimensionMetadataItem(compoundId) + if (!metadataItem) { + continue + } + + const layoutDimension = toLayoutDimension( + compoundId, + metadataItem, + suffixContext + ) + const valueTexts = getFilterValueTexts({ + dimension, + conditions: { + condition: dimension.filter, + legendSet: dimension.legendSet?.id, + }, + layoutDimension, + metadataStore, + locale, + digitGroupSeparator: visualization.digitGroupSeparator, + }) + + if (!valueTexts.length) { + continue + } + + const label = layoutDimension.suffix + ? `${layoutDimension.name} · ${layoutDimension.suffix}` + : layoutDimension.name + + fragments.push(`${label}: ${valueTexts.join(', ')}`) + } + + return fragments.join(FRAGMENT_SEPARATOR) +} diff --git a/src/types/analytics/pivot-table.ts b/src/types/analytics/pivot-table.ts index b2baf42df..822d9d57f 100644 --- a/src/types/analytics/pivot-table.ts +++ b/src/types/analytics/pivot-table.ts @@ -5,6 +5,9 @@ import type { FC } from 'react' type PivotTableProps = { data: PivotTableAnalyticsData visualization: CurrentVisualization + /* Rendered as the filter line in place of the text the engine would + * derive from `visualization.filters`. */ + filterText?: string legendSets?: LegendSet[] renderCounter?: number onToggleContextualMenu?: () => void From 95c2c21343cccb6cb4ad9aa2a8d37221b0b011e8 Mon Sep 17 00:00:00 2001 From: Hendrik de Graaf <hendrik@dhis2.org> Date: Thu, 24 Sep 2026 14:49:17 +0200 Subject: [PATCH 11/33] test: cover the pivot table filter text prop --- i18n/en.pot | 16 ++-- .../__tests__/pivot-table-plugin.spec.tsx | 75 +++++++++++++++++++ 2 files changed, 83 insertions(+), 8 deletions(-) create mode 100644 src/components/plugin-wrapper/__tests__/pivot-table-plugin.spec.tsx diff --git a/i18n/en.pot b/i18n/en.pot index 39552fc94..13386bc16 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -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-10-01T13:13:57.484Z\n" -"PO-Revision-Date: 2026-10-01T13:13:57.484Z\n" +"POT-Creation-Date: 2026-10-01T13:14:42.687Z\n" +"PO-Revision-Date: 2026-10-01T13:14:42.687Z\n" msgid "" "Some dimensions were not added because they cannot be used in a " @@ -232,12 +232,6 @@ msgstr "Program stage: {{- stageName}}" msgid "Grouping: {{- groupingName}}" msgstr "Grouping: {{- groupingName}}" -msgid "Levels" -msgstr "Levels" - -msgid "Groups" -msgstr "Groups" - msgid "Using: {{- itemName}} ({{- aggregationType}})" msgstr "Using: {{- itemName}} ({{- aggregationType}})" @@ -1029,6 +1023,12 @@ msgstr "Enrollment status" msgid "Registration org. unit" msgstr "Registration org. unit" +msgid "Levels" +msgstr "Levels" + +msgid "Groups" +msgstr "Groups" + msgid "Enrollment date" msgstr "Enrollment date" diff --git a/src/components/plugin-wrapper/__tests__/pivot-table-plugin.spec.tsx b/src/components/plugin-wrapper/__tests__/pivot-table-plugin.spec.tsx new file mode 100644 index 000000000..0677fc2f4 --- /dev/null +++ b/src/components/plugin-wrapper/__tests__/pivot-table-plugin.spec.tsx @@ -0,0 +1,75 @@ +import { MockMetadataProvider } from '@components/app-wrapper/metadata-provider/metadata-provider' +import { PivotTablePlugin } from '@components/plugin-wrapper/pivot-table-plugin' +import { render, screen } from '@testing-library/react' +import type { CurrentVisualization } from '@types' +import { describe, expect, it, vi } from 'vitest' + +/* The engine renders the filter row itself, so the only thing this app + * controls is the prop. Stubbing the table keeps the assertion on that. */ +vi.mock('@dhis2/analytics', async (importOriginal) => ({ + ...(await importOriginal<object>()), + PivotTable: ({ filterText }: { filterText?: string }) => ( + <div data-test="pivot-table" data-filter-text={filterText} /> + ), +})) + +vi.mock( + '@components/plugin-wrapper/hooks/use-pivot-table-analytics-data', + () => ({ + usePivotTableAnalyticsData: () => [ + vi.fn(), + { + isFetching: false, + data: { headers: [], rows: [], metaData: {} }, + }, + ], + }) +) + +const visualization = { + type: 'PIVOT_TABLE', + outputType: 'EVENT', + columns: [], + rows: [], + filters: [], +} as unknown as CurrentVisualization + +describe('PivotTablePlugin', () => { + it('hands the filter text to the pivot table', () => { + render( + <MockMetadataProvider> + <PivotTablePlugin + displayProperty="name" + visualization={visualization} + isInDashboard={false} + isInModal={false} + onResponseReceived={vi.fn()} + filterText="Gender: Female" + /> + </MockMetadataProvider> + ) + + expect(screen.getByTestId('pivot-table')).toHaveAttribute( + 'data-filter-text', + 'Gender: Female' + ) + }) + + it('passes no filter text when there is none', () => { + render( + <MockMetadataProvider> + <PivotTablePlugin + displayProperty="name" + visualization={visualization} + isInDashboard={false} + isInModal={false} + onResponseReceived={vi.fn()} + /> + </MockMetadataProvider> + ) + + expect(screen.getByTestId('pivot-table')).not.toHaveAttribute( + 'data-filter-text' + ) + }) +}) From da6d3960ff432c0737004d478b3cc9eae87409ad Mon Sep 17 00:00:00 2001 From: Hendrik de Graaf <hendrik@dhis2.org> Date: Wed, 30 Sep 2026 13:39:54 +0200 Subject: [PATCH 12/33] fix: fall back to a generic plural when the tracked entity type is unknown Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- i18n/en.pot | 7 +++++-- src/modules/visualization/__tests__/title.spec.ts | 11 +++++++++++ src/modules/visualization/title.ts | 4 ++-- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 13386bc16..419fb937a 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -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-10-01T13:14:42.687Z\n" -"PO-Revision-Date: 2026-10-01T13:14:42.687Z\n" +"POT-Creation-Date: 2026-10-01T13:14:43.842Z\n" +"PO-Revision-Date: 2026-10-01T13:14:43.843Z\n" msgid "" "Some dimensions were not added because they cannot be used in a " @@ -1309,6 +1309,9 @@ msgstr "{{- label}} count" msgid "{{- label}} list" msgstr "{{- label}} list" +msgid "Tracked entities" +msgstr "Tracked entities" + msgid "Enrollments" msgstr "Enrollments" diff --git a/src/modules/visualization/__tests__/title.spec.ts b/src/modules/visualization/__tests__/title.spec.ts index 097a9695e..1650343eb 100644 --- a/src/modules/visualization/__tests__/title.spec.ts +++ b/src/modules/visualization/__tests__/title.spec.ts @@ -168,6 +168,17 @@ describe('getVisualizationTitle', () => { ).toBe('Patient count') }) + it('falls back to a generic plural when the tracked entity type is unknown', () => { + const vis = buildVis({ + outputType: 'TRACKED_ENTITY_INSTANCE', + trackedEntityType: { id: 'missing', name: 'Person' }, + }) + + expect(getVisualizationTitle(vis, buildStore())).toBe( + 'Tracked entities' + ) + }) + it('replaces the output-type title with the custom value on a pivot', () => { const store = buildStore({ de1: { id: 'de1', name: 'MCH Apgar score' }, diff --git a/src/modules/visualization/title.ts b/src/modules/visualization/title.ts index 8fd9e1422..86ad66f90 100644 --- a/src/modules/visualization/title.ts +++ b/src/modules/visualization/title.ts @@ -51,7 +51,7 @@ const getCustomValueTitle = ( const getTrackedEntityTitle = ( visualization: CurrentVisualization, metadataStore: MetadataStore -): string | undefined => { +): string => { const tetId = visualization.trackedEntityType?.id const tet = tetId ? metadataStore.getMetadataItem(tetId) : undefined const pluralLabel = @@ -64,7 +64,7 @@ const getTrackedEntityTitle = ( } return isPopulatedString(tet?.name) ? getCountOrListTitle(tet.name, visualization) - : undefined + : i18n.t('Tracked entities') } const getProgramLabels = ( From 4a392085fa696fc730a53373d1e4036ba960ea28 Mon Sep 17 00:00:00 2001 From: Hendrik de Graaf <hendrik@dhis2.org> Date: Wed, 30 Sep 2026 14:18:25 +0200 Subject: [PATCH 13/33] refactor: read the user locale from the app runtime instead of a prop Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- src/components/canvas/canvas.tsx | 1 - src/components/plugin-wrapper/plugin-wrapper.tsx | 8 ++++---- src/modules/visualization/filter-text.ts | 7 +++---- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/src/components/canvas/canvas.tsx b/src/components/canvas/canvas.tsx index 024d86358..f22c82dfe 100644 --- a/src/components/canvas/canvas.tsx +++ b/src/components/canvas/canvas.tsx @@ -74,7 +74,6 @@ export const Canvas: FC = () => { visualizationLoadError={visualizationLoadError ?? undefined} onRetryLoad={onRetryLoad} displayProperty={currentUser.settings.displayProperty} - locale={currentUser.settings.uiLocale} onColumnHeaderClick={onColumnHeaderClick} onDataSorted={onDataSorted} /> diff --git a/src/components/plugin-wrapper/plugin-wrapper.tsx b/src/components/plugin-wrapper/plugin-wrapper.tsx index 0656baf34..c301dd56a 100644 --- a/src/components/plugin-wrapper/plugin-wrapper.tsx +++ b/src/components/plugin-wrapper/plugin-wrapper.tsx @@ -3,6 +3,7 @@ import { useMetadataStore } from '@components/app-wrapper/metadata-provider/meta import { CanvasError } from '@components/canvas-error/canvas-error' import { CanvasErrorFallback } from '@components/canvas-error/canvas-error-fallback' import type { ColumnHeaderClickFn } from '@components/line-list/types' +import { useCurrentUserInfo } from '@dhis2/app-runtime' import { Center, CircularLoader } from '@dhis2/ui' import { assertNever } from '@modules/utils/guards' import { getVisualizationFilterText } from '@modules/visualization/filter-text' @@ -47,9 +48,6 @@ const getBaseRequestIdentity = ( type PluginWrapperProps = { displayProperty: CurrentUser['settings']['displayProperty'] - /* Only used to format custom start/end dates in the filter line. The - * dashboard plugin has no access to the user's settings and omits it. */ - locale?: string visualization: CurrentVisualization | EmptyVisualization filters?: PluginFilters isInDashboard?: boolean @@ -63,7 +61,6 @@ type PluginWrapperProps = { export const PluginWrapper: FC<PluginWrapperProps> = ({ displayProperty, - locale, visualization, filters, isInDashboard = false, @@ -93,6 +90,9 @@ export const PluginWrapper: FC<PluginWrapperProps> = ({ ) const metadataStore = useMetadataStore() + /* Comes from the app-adapter's /api/me, so it resolves in the plugin + * and the interpretation modal too, unlike the app's cached user. */ + const locale = useCurrentUserInfo()?.settings?.keyUiLocale /* The title is resolved here rather than in each plugin because this is * the one component the canvas, the dashboard plugin and the diff --git a/src/modules/visualization/filter-text.ts b/src/modules/visualization/filter-text.ts index 77f53ce45..46216f9bc 100644 --- a/src/modules/visualization/filter-text.ts +++ b/src/modules/visualization/filter-text.ts @@ -87,7 +87,7 @@ const getFilterValueTexts = ({ return getConditionsTexts({ conditions, dimension: layoutDimension, - formatValueOptions: { locale, digitGroupSeparator }, + formatValueOptions: { digitGroupSeparator }, metadataItems: metadataStore.getMetadataItems( getConditionsMetadataIds({ conditions, @@ -118,9 +118,8 @@ const getFilterValueTexts = ({ * visualisation types render it from this one function so they cannot drift: * the line list draws its own row, the pivot table takes it as `filterText`. * - * `locale` is only used to format custom start/end dates. It is undefined in - * the dashboard plugin, which has no access to the user's settings, and then - * falls back to the runtime default. + * `locale` is only used to format custom start/end dates, and falls back to + * the runtime default when the user's locale has not resolved yet. */ export const getVisualizationFilterText = ({ visualization, From 805fbaa4047c617ba6be34ae51577ac5e612ecd4 Mon Sep 17 00:00:00 2001 From: Hendrik de Graaf <hendrik@dhis2.org> Date: Wed, 30 Sep 2026 14:42:28 +0200 Subject: [PATCH 14/33] refactor: trim the legacy title comment Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- src/modules/visualization/normalize-legacy.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/modules/visualization/normalize-legacy.ts b/src/modules/visualization/normalize-legacy.ts index e761ccadc..9af0634d1 100644 --- a/src/modules/visualization/normalize-legacy.ts +++ b/src/modules/visualization/normalize-legacy.ts @@ -307,10 +307,7 @@ export const normalizeApiSavedVisualization = ( } /* The apps that produced legacy visualizations never generated a title, so - * an empty title there means "no title" rather than "auto generated". - * `hideTitle` cannot distinguish the two on its own: it is a primitive - * boolean in the backend and always comes back as `false`. This app strips - * `legacy` on save, so its own output never takes this branch. */ + * an empty title there means "no title" rather than "auto generated". */ if (normalizedVis.legacy) { normalizedVis.hideTitle = !isPopulatedString(normalizedVis.title) } From 419a33f9754a89a4dbebf1fbe0ed5e744d504ccd Mon Sep 17 00:00:00 2001 From: Hendrik de Graaf <hendrik@dhis2.org> Date: Wed, 30 Sep 2026 14:57:23 +0200 Subject: [PATCH 15/33] refactor: correct the title field comment Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- src/components/options/fields/title.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/components/options/fields/title.tsx b/src/components/options/fields/title.tsx index 856d47e0d..820cf43fb 100644 --- a/src/components/options/fields/title.tsx +++ b/src/components/options/fields/title.tsx @@ -21,10 +21,9 @@ type TitleProps = { label: string } -/* The mode is derived from `title` and `hideTitle` rather than stored, so a - * custom title typed and then toggled away from has nowhere persistent to - * live. Keeping it here means it survives toggling while the modal is open - * and is discarded when the modal unmounts, which is what the spec asks for. */ +/* Auto is stored as an empty title, so a custom title typed and then toggled + * away from can't stay in the options without reading back as Custom. It is + * kept in local state instead, and dropped when the field unmounts. */ export const Title: FC<TitleProps> = ({ label }) => { const [title, setTitle] = useOptionsField('title') const [hideTitle, setHideTitle] = useOptionsField('hideTitle') From 830b89fecb3919162d32b4efebec0958631ed5f6 Mon Sep 17 00:00:00 2001 From: Hendrik de Graaf <hendrik@dhis2.org> Date: Wed, 30 Sep 2026 15:05:53 +0200 Subject: [PATCH 16/33] refactor: name the title field handlers after what they change Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- src/components/options/fields/title.tsx | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/src/components/options/fields/title.tsx b/src/components/options/fields/title.tsx index 820cf43fb..2567d376f 100644 --- a/src/components/options/fields/title.tsx +++ b/src/components/options/fields/title.tsx @@ -31,20 +31,20 @@ export const Title: FC<TitleProps> = ({ label }) => { const [mode, setMode] = useState<TitleMode>(() => getStoredMode(title, hideTitle) ) - const [draft, setDraft] = useState<string>(title ?? '') + const [customTitle, setCustomTitle] = useState<string>(title ?? '') - const applyMode = useCallback( + const onModeChange = useCallback( (nextMode: TitleMode) => { setMode(nextMode) setHideTitle(nextMode === 'NONE') - setTitle(nextMode === 'CUSTOM' ? draft : '') + setTitle(nextMode === 'CUSTOM' ? customTitle : '') }, - [draft, setHideTitle, setTitle] + [customTitle, setHideTitle, setTitle] ) - const applyDraft = useCallback( + const onCustomTitleChange = useCallback( (value: string) => { - setDraft(value) + setCustomTitle(value) setTitle(value) }, [setTitle] @@ -57,7 +57,7 @@ export const Title: FC<TitleProps> = ({ label }) => { name="titleMode" label={i18n.t('Auto generated')} checked={mode === 'AUTO'} - onChange={() => applyMode('AUTO')} + onChange={() => onModeChange('AUTO')} dataTest="title-mode-auto" /> <Radio @@ -65,7 +65,7 @@ export const Title: FC<TitleProps> = ({ label }) => { name="titleMode" label={i18n.t('None')} checked={mode === 'NONE'} - onChange={() => applyMode('NONE')} + onChange={() => onModeChange('NONE')} dataTest="title-mode-none" /> <Radio @@ -73,7 +73,7 @@ export const Title: FC<TitleProps> = ({ label }) => { name="titleMode" label={i18n.t('Custom')} checked={mode === 'CUSTOM'} - onChange={() => applyMode('CUSTOM')} + onChange={() => onModeChange('CUSTOM')} dataTest="title-mode-custom" /> {mode === 'CUSTOM' && ( @@ -83,8 +83,10 @@ export const Title: FC<TitleProps> = ({ label }) => { name="title" inputWidth="280px" placeholder={i18n.t('Add a title')} - value={draft} - onChange={({ value }) => applyDraft(value ?? '')} + value={customTitle} + onChange={({ value }) => + onCustomTitleChange(value ?? '') + } dataTest="title-input" /> </div> From 09367f58d406f289135df3c0ef4664f6c3960ce8 Mon Sep 17 00:00:00 2001 From: Hendrik de Graaf <hendrik@dhis2.org> Date: Wed, 30 Sep 2026 15:19:36 +0200 Subject: [PATCH 17/33] refactor: pass hideTitle through unchanged when resolving the title Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../plugin-wrapper/plugin-wrapper.tsx | 24 +++++++++++-------- src/modules/visualization/title.ts | 2 +- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/components/plugin-wrapper/plugin-wrapper.tsx b/src/components/plugin-wrapper/plugin-wrapper.tsx index c301dd56a..d13358d9b 100644 --- a/src/components/plugin-wrapper/plugin-wrapper.tsx +++ b/src/components/plugin-wrapper/plugin-wrapper.tsx @@ -96,16 +96,20 @@ export const PluginWrapper: FC<PluginWrapperProps> = ({ /* The title is resolved here rather than in each plugin because this is * the one component the canvas, the dashboard plugin and the - * interpretation modal all render through. `hideTitle` is set alongside - * it so the pivot table engine, which gates the title on that flag, - * agrees with the line list. */ - const visualizationWithTitle = useMemo(() => { - if (isVisualizationEmpty(visualization)) { - return visualization - } - const title = getVisualizationTitle(visualization, metadataStore) - return { ...visualization, title, hideTitle: !title } - }, [visualization, metadataStore]) + * interpretation modal all render through. */ + const visualizationWithTitle = useMemo( + () => + isVisualizationEmpty(visualization) + ? visualization + : { + ...visualization, + title: getVisualizationTitle( + visualization, + metadataStore + ), + }, + [visualization, metadataStore] + ) /* Both renderers take the same string: the line list draws its own row, * the pivot table engine renders it in place of its own derivation. */ diff --git a/src/modules/visualization/title.ts b/src/modules/visualization/title.ts index 86ad66f90..61ab9f969 100644 --- a/src/modules/visualization/title.ts +++ b/src/modules/visualization/title.ts @@ -80,7 +80,7 @@ const getProgramLabels = ( export const getAutoTitle = ( visualization: CurrentVisualization, metadataStore: MetadataStore -): string | undefined => { +): string => { const customValueTitle = getCustomValueTitle(visualization, metadataStore) if (customValueTitle) { return customValueTitle From af150d1170a9acced1f249e3e0cfc317a4512530 Mon Sep 17 00:00:00 2001 From: Hendrik de Graaf <hendrik@dhis2.org> Date: Wed, 30 Sep 2026 15:23:09 +0200 Subject: [PATCH 18/33] refactor: drop redundant comments in PluginWrapper Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- src/components/plugin-wrapper/plugin-wrapper.tsx | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/components/plugin-wrapper/plugin-wrapper.tsx b/src/components/plugin-wrapper/plugin-wrapper.tsx index d13358d9b..b19d31e5c 100644 --- a/src/components/plugin-wrapper/plugin-wrapper.tsx +++ b/src/components/plugin-wrapper/plugin-wrapper.tsx @@ -90,13 +90,8 @@ export const PluginWrapper: FC<PluginWrapperProps> = ({ ) const metadataStore = useMetadataStore() - /* Comes from the app-adapter's /api/me, so it resolves in the plugin - * and the interpretation modal too, unlike the app's cached user. */ const locale = useCurrentUserInfo()?.settings?.keyUiLocale - /* The title is resolved here rather than in each plugin because this is - * the one component the canvas, the dashboard plugin and the - * interpretation modal all render through. */ const visualizationWithTitle = useMemo( () => isVisualizationEmpty(visualization) @@ -111,8 +106,6 @@ export const PluginWrapper: FC<PluginWrapperProps> = ({ [visualization, metadataStore] ) - /* Both renderers take the same string: the line list draws its own row, - * the pivot table engine renders it in place of its own derivation. */ const filterText = useMemo( () => isVisualizationEmpty(visualization) From 2361b3dcf9d9e32fa6a819f41e2f8383e389418e Mon Sep 17 00:00:00 2001 From: Hendrik de Graaf <hendrik@dhis2.org> Date: Wed, 30 Sep 2026 15:40:52 +0200 Subject: [PATCH 19/33] refactor: move the condition text helpers into the conditions module The hook spec now runs against the real condition logic instead of mocking each helper, since calls inside one module can't be intercepted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../__tests__/use-conditions-texts.spec.ts | 108 ++--------------- src/hooks/use-conditions-texts.ts | 2 +- src/modules/condition-texts.ts | 114 ------------------ src/modules/conditions.ts | 89 +++++++++++++- src/modules/visualization/filter-text.ts | 2 +- 5 files changed, 99 insertions(+), 216 deletions(-) delete mode 100644 src/modules/condition-texts.ts diff --git a/src/hooks/__tests__/use-conditions-texts.spec.ts b/src/hooks/__tests__/use-conditions-texts.spec.ts index 3d6f86a46..26558c4cd 100644 --- a/src/hooks/__tests__/use-conditions-texts.spec.ts +++ b/src/hooks/__tests__/use-conditions-texts.spec.ts @@ -4,7 +4,7 @@ import { useAddMetadata } from '@hooks' import { act, renderHook } from '@testing-library/react' import { createElement } from 'react' import type { ReactNode } from 'react' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { useConditionsTexts } from '../use-conditions-texts' const mockRootOrgUnits = [ @@ -21,44 +21,6 @@ vi.mock('@hooks', async () => ({ useRootOrgUnits: () => mockRootOrgUnits, })) -const { - mockParseConditionsStringToArray, - mockShouldUseLegendSetConditions, - mockShouldUseOrgUnitConditions, - mockShouldUseOptionSetConditions, - mockShouldUseBooleanConditions, - mockGetLegendSetConditionMetadataIds, - mockGetOptionSetIdAndSelectedOptionCodes, - mockGetBooleanConditionTexts, - mockGetOrgUnitConditionMetadataIds, - mockGetOperatorConditionTexts, -} = vi.hoisted(() => ({ - mockParseConditionsStringToArray: vi.fn(), - mockShouldUseLegendSetConditions: vi.fn(), - mockShouldUseOrgUnitConditions: vi.fn(), - mockShouldUseOptionSetConditions: vi.fn(), - mockShouldUseBooleanConditions: vi.fn(), - mockGetLegendSetConditionMetadataIds: vi.fn(), - mockGetOptionSetIdAndSelectedOptionCodes: vi.fn(), - mockGetBooleanConditionTexts: vi.fn(), - mockGetOrgUnitConditionMetadataIds: vi.fn(), - mockGetOperatorConditionTexts: vi.fn(), -})) - -vi.mock('@modules/conditions', () => ({ - parseConditionsStringToArray: mockParseConditionsStringToArray, - shouldUseLegendSetConditions: mockShouldUseLegendSetConditions, - shouldUseOrgUnitConditions: mockShouldUseOrgUnitConditions, - shouldUseOptionSetConditions: mockShouldUseOptionSetConditions, - shouldUseBooleanConditions: mockShouldUseBooleanConditions, - getLegendSetConditionMetadataIds: mockGetLegendSetConditionMetadataIds, - getOptionSetIdAndSelectedOptionCodes: - mockGetOptionSetIdAndSelectedOptionCodes, - getBooleanConditionTexts: mockGetBooleanConditionTexts, - getOrgUnitConditionMetadataIds: mockGetOrgUnitConditionMetadataIds, - getOperatorConditionTexts: mockGetOperatorConditionTexts, -})) - type WrapperProps = { children: ReactNode } const DefaultWrapper = ({ children }: WrapperProps) => createElement(MockMetadataProvider, undefined, children) @@ -70,44 +32,17 @@ const baseDimension: LayoutDimension = { name: 'Test dimension', } -const setupDefaultMocks = () => { - mockParseConditionsStringToArray.mockImplementation((value) => { - if (Array.isArray(value)) { - return value - } - if (typeof value === 'string' && value.length > 0) { - return value.split(',') - } - return [] - }) - mockShouldUseLegendSetConditions.mockReturnValue(false) - mockShouldUseOrgUnitConditions.mockReturnValue(false) - mockShouldUseOptionSetConditions.mockReturnValue(false) - mockShouldUseBooleanConditions.mockReturnValue(false) - mockGetLegendSetConditionMetadataIds.mockReturnValue([]) - mockGetOptionSetIdAndSelectedOptionCodes.mockReturnValue({ - optionSetId: '', - selectedOptionCodes: [], - }) - mockGetBooleanConditionTexts.mockReturnValue([]) - mockGetOrgUnitConditionMetadataIds.mockReturnValue([]) - mockGetOperatorConditionTexts.mockReturnValue([]) -} - -beforeEach(() => { - setupDefaultMocks() -}) - describe('useConditionsTexts metadata updates', () => { it('replaces legend set ids with metadata names as they arrive', () => { - mockShouldUseLegendSetConditions.mockReturnValue(true) const legendIds = ['LEGEND_A', 'LEGEND_B'] - mockGetLegendSetConditionMetadataIds.mockReturnValue(legendIds) const { result } = renderHook( () => { const texts = useConditionsTexts({ - conditions: { condition: 'gt:10' }, + conditions: { + condition: 'IN:LEGEND_A;LEGEND_B', + legendSet: 'LEGEND_SET', + }, dimension: baseDimension, formatValueOptions: {}, }) @@ -139,18 +74,13 @@ describe('useConditionsTexts metadata updates', () => { }) it('updates option set condition texts once the option set metadata becomes available', () => { - mockShouldUseOptionSetConditions.mockReturnValue(true) const optionSetId = 'OS_123' const selectedOptionCodes = ['A', 'B'] - mockGetOptionSetIdAndSelectedOptionCodes.mockReturnValue({ - optionSetId, - selectedOptionCodes, - }) const { result } = renderHook( () => { const texts = useConditionsTexts({ - conditions: { condition: 'in:A;B' }, + conditions: { condition: 'IN:A;B' }, dimension: { ...baseDimension, optionSet: optionSetId }, formatValueOptions: {}, }) @@ -187,20 +117,12 @@ describe('useConditionsTexts metadata updates', () => { }) it('prefers progressively richer organisation unit metadata as it arrives', () => { - mockShouldUseOrgUnitConditions.mockReturnValue(true) const prefixedOrgUnitIds = ['LEVEL-OU_A', 'LEVEL-OU_B'] - const unprefixedOrgUnitIds = ['OU_A', 'OU_B'] - mockGetOrgUnitConditionMetadataIds.mockImplementation( - (_, includeUnprefixed = false) => - includeUnprefixed - ? [...prefixedOrgUnitIds, ...unprefixedOrgUnitIds] - : prefixedOrgUnitIds - ) const { result } = renderHook( () => { const texts = useConditionsTexts({ - conditions: { condition: 'in:LEVEL-OU_A;LEVEL-OU_B' }, + conditions: { condition: 'IN:LEVEL-OU_A;LEVEL-OU_B' }, dimension: { ...baseDimension, valueType: 'ORGANISATION_UNIT', @@ -246,19 +168,12 @@ describe('useConditionsTexts metadata updates', () => { }) it('mixes available org unit metadata on a per-id basis', () => { - mockShouldUseOrgUnitConditions.mockReturnValue(true) const prefixedOrgUnitIds = ['LEVEL-OU_A', 'LEVEL-OU_B'] - mockGetOrgUnitConditionMetadataIds.mockImplementation( - (_, includeUnprefixed = false) => - includeUnprefixed - ? [...prefixedOrgUnitIds, 'OU_B'] - : prefixedOrgUnitIds - ) const { result } = renderHook( () => { const texts = useConditionsTexts({ - conditions: { condition: 'in:LEVEL-OU_A;LEVEL-OU_B' }, + conditions: { condition: 'IN:LEVEL-OU_A;LEVEL-OU_B' }, dimension: { ...baseDimension, valueType: 'ORGANISATION_UNIT', @@ -288,18 +203,13 @@ describe('useConditionsTexts metadata updates', () => { }) it('only returns option texts for selected option codes', () => { - mockShouldUseOptionSetConditions.mockReturnValue(true) const optionSetId = 'OS_EXTRA' const selectedOptionCodes = ['A', 'B'] - mockGetOptionSetIdAndSelectedOptionCodes.mockReturnValue({ - optionSetId, - selectedOptionCodes, - }) const { result } = renderHook( () => { const texts = useConditionsTexts({ - conditions: { condition: 'in:A;B' }, + conditions: { condition: 'IN:A;B' }, dimension: { ...baseDimension, optionSet: optionSetId }, formatValueOptions: {}, }) diff --git a/src/hooks/use-conditions-texts.ts b/src/hooks/use-conditions-texts.ts index 78d3ca650..7f7cb8c02 100644 --- a/src/hooks/use-conditions-texts.ts +++ b/src/hooks/use-conditions-texts.ts @@ -3,7 +3,7 @@ import { useMetadataItems } from '@hooks' import { getConditionsMetadataIds, getConditionsTexts, -} from '@modules/condition-texts' +} from '@modules/conditions' import type { SavedVisualization } from '@types' import { useMemo } from 'react' diff --git a/src/modules/condition-texts.ts b/src/modules/condition-texts.ts deleted file mode 100644 index 0b9eaa80a..000000000 --- a/src/modules/condition-texts.ts +++ /dev/null @@ -1,114 +0,0 @@ -import type { LayoutDimension } from '@components/layout-panel/axis/chip' -import { ouIdHelper } from '@dhis2/analytics' -import { - getBooleanConditionTexts, - getLegendSetConditionMetadataIds, - getOperatorConditionTexts, - getOptionSetIdAndSelectedOptionCodes, - getOrgUnitConditionMetadataIds, - parseConditionsStringToArray, - shouldUseBooleanConditions, - shouldUseLegendSetConditions, - shouldUseOptionSetConditions, - shouldUseOrgUnitConditions, -} from '@modules/conditions' -import { isOptionSetMetadataItem } from '@modules/metadata/item-guards' -import type { MetadataItem, SavedVisualization } from '@types' - -export type Conditions = { - condition?: string | string[] - legendSet?: string -} - -export type ConditionsFormatValueOptions = { - locale?: string - digitGroupSeparator?: SavedVisualization['digitGroupSeparator'] - baseUrl?: string -} - -type ConditionsMetadataItems = Record<string, MetadataItem | undefined> - -/* Split from getConditionsTexts so a React caller can feed the IDs to a - * subscribing hook, while a plain caller reads them straight from the store. */ -export const getConditionsMetadataIds = ({ - conditions, - dimension, -}: { - conditions: Conditions - dimension: LayoutDimension -}): string[] => { - const conditionsList = parseConditionsStringToArray( - conditions?.condition ?? '' - ) - - if (shouldUseLegendSetConditions(conditions)) { - return getLegendSetConditionMetadataIds(conditions, conditionsList) - } - if (shouldUseOrgUnitConditions(conditions, dimension, conditionsList)) { - return getOrgUnitConditionMetadataIds(conditionsList, true) - } - if (shouldUseOptionSetConditions(conditions, dimension, conditionsList)) { - return [ - getOptionSetIdAndSelectedOptionCodes(dimension, conditionsList) - .optionSetId, - ] - } - return [] -} - -export const getConditionsTexts = ({ - conditions, - dimension, - formatValueOptions, - metadataItems, -}: { - conditions: Conditions - dimension: LayoutDimension - formatValueOptions: ConditionsFormatValueOptions - metadataItems: ConditionsMetadataItems -}): string[] => { - const conditionsList = parseConditionsStringToArray( - conditions?.condition ?? '' - ) - - if (shouldUseLegendSetConditions(conditions)) { - return getLegendSetConditionMetadataIds(conditions, conditionsList).map( - (id) => metadataItems[id]?.name ?? id - ) - } - - if (shouldUseOrgUnitConditions(conditions, dimension, conditionsList)) { - /* Passed `false` so the prefixed ID is returned: the prefixed entry is - * preferred for naming, with the unprefixed one as the fallback. */ - return getOrgUnitConditionMetadataIds(conditionsList, false).map( - (id) => - metadataItems[id]?.name ?? - metadataItems[ouIdHelper.removePrefix(id)]?.name ?? - id - ) - } - - if (shouldUseOptionSetConditions(conditions, dimension, conditionsList)) { - const { optionSetId, selectedOptionCodes } = - getOptionSetIdAndSelectedOptionCodes(dimension, conditionsList) - const optionSetMetadata = metadataItems[optionSetId] - - if (!isOptionSetMetadataItem(optionSetMetadata)) { - return selectedOptionCodes - } - const selectedOptionCodesLookup = new Set(selectedOptionCodes) - return optionSetMetadata.options - .filter((option) => selectedOptionCodesLookup.has(option.code)) - .map((option) => option.name) - } - - if (shouldUseBooleanConditions(conditions, dimension, conditionsList)) { - return getBooleanConditionTexts(conditionsList) - } - - return getOperatorConditionTexts( - dimension, - conditionsList, - formatValueOptions - ) -} diff --git a/src/modules/conditions.ts b/src/modules/conditions.ts index be1f13d1f..b14756fe9 100644 --- a/src/modules/conditions.ts +++ b/src/modules/conditions.ts @@ -3,14 +3,16 @@ import { formatValue, ouIdHelper } from '@dhis2/analytics' import i18n from '@dhis2/d2-i18n' import { getCompoundDimensionId } from '@modules/dimension/ids' import { combineAllDimensionsFromVisualization } from '@modules/dimension/translation' +import { isOptionSetMetadataItem } from '@modules/metadata/item-guards' import type { CurrentVisualization, + MetadataItem, OutputType, SavedVisualization, ValueType, } from '@types' -type Conditions = { +export type Conditions = { condition?: string | string[] legendSet?: string } @@ -463,3 +465,88 @@ export const getConditionsFromVisualization = ( return result } + +/* Split from getConditionsTexts so a React caller can feed the IDs to a + * subscribing hook, while a plain caller reads them straight from the store. */ +export const getConditionsMetadataIds = ({ + conditions, + dimension, +}: { + conditions: Conditions + dimension: LayoutDimension +}): string[] => { + const conditionsList = parseConditionsStringToArray( + conditions?.condition ?? '' + ) + + if (shouldUseLegendSetConditions(conditions)) { + return getLegendSetConditionMetadataIds(conditions, conditionsList) + } + if (shouldUseOrgUnitConditions(conditions, dimension, conditionsList)) { + return getOrgUnitConditionMetadataIds(conditionsList, true) + } + if (shouldUseOptionSetConditions(conditions, dimension, conditionsList)) { + return [ + getOptionSetIdAndSelectedOptionCodes(dimension, conditionsList) + .optionSetId, + ] + } + return [] +} + +export const getConditionsTexts = ({ + conditions, + dimension, + formatValueOptions, + metadataItems, +}: { + conditions: Conditions + dimension: LayoutDimension + formatValueOptions: FormatValueOptions + metadataItems: Record<string, MetadataItem | undefined> +}): string[] => { + const conditionsList = parseConditionsStringToArray( + conditions?.condition ?? '' + ) + + if (shouldUseLegendSetConditions(conditions)) { + return getLegendSetConditionMetadataIds(conditions, conditionsList).map( + (id) => metadataItems[id]?.name ?? id + ) + } + + if (shouldUseOrgUnitConditions(conditions, dimension, conditionsList)) { + /* Passed `false` so the prefixed ID is returned: the prefixed entry is + * preferred for naming, with the unprefixed one as the fallback. */ + return getOrgUnitConditionMetadataIds(conditionsList, false).map( + (id) => + metadataItems[id]?.name ?? + metadataItems[ouIdHelper.removePrefix(id)]?.name ?? + id + ) + } + + if (shouldUseOptionSetConditions(conditions, dimension, conditionsList)) { + const { optionSetId, selectedOptionCodes } = + getOptionSetIdAndSelectedOptionCodes(dimension, conditionsList) + const optionSetMetadata = metadataItems[optionSetId] + + if (!isOptionSetMetadataItem(optionSetMetadata)) { + return selectedOptionCodes + } + const selectedOptionCodesLookup = new Set(selectedOptionCodes) + return optionSetMetadata.options + .filter((option) => selectedOptionCodesLookup.has(option.code)) + .map((option) => option.name) + } + + if (shouldUseBooleanConditions(conditions, dimension, conditionsList)) { + return getBooleanConditionTexts(conditionsList) + } + + return getOperatorConditionTexts( + dimension, + conditionsList, + formatValueOptions + ) +} diff --git a/src/modules/visualization/filter-text.ts b/src/modules/visualization/filter-text.ts index 46216f9bc..e85a047d4 100644 --- a/src/modules/visualization/filter-text.ts +++ b/src/modules/visualization/filter-text.ts @@ -2,7 +2,7 @@ import { getConditionsMetadataIds, getConditionsTexts, type Conditions, -} from '@modules/condition-texts' +} from '@modules/conditions' import { getCompoundDimensionId } from '@modules/dimension/ids' import { getItemDisplayNames, From 46ca018dba44d9ea529b6a8f4f903db525cae14f Mon Sep 17 00:00:00 2001 From: Hendrik de Graaf <hendrik@dhis2.org> Date: Wed, 30 Sep 2026 15:51:56 +0200 Subject: [PATCH 20/33] refactor: reuse the conditions types in useConditionsTexts Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- src/hooks/use-conditions-texts.ts | 14 ++------------ src/modules/conditions.ts | 2 +- 2 files changed, 3 insertions(+), 13 deletions(-) diff --git a/src/hooks/use-conditions-texts.ts b/src/hooks/use-conditions-texts.ts index 7f7cb8c02..eef138977 100644 --- a/src/hooks/use-conditions-texts.ts +++ b/src/hooks/use-conditions-texts.ts @@ -3,21 +3,11 @@ import { useMetadataItems } from '@hooks' import { getConditionsMetadataIds, getConditionsTexts, + type Conditions, + type FormatValueOptions, } from '@modules/conditions' -import type { SavedVisualization } from '@types' import { useMemo } from 'react' -type Conditions = { - condition?: string | string[] - legendSet?: string -} - -type FormatValueOptions = { - locale?: string - digitGroupSeparator?: SavedVisualization['digitGroupSeparator'] - baseUrl?: string -} - type UseConditionsTextsParams = { conditions: Conditions dimension: LayoutDimension diff --git a/src/modules/conditions.ts b/src/modules/conditions.ts index b14756fe9..02045f3dd 100644 --- a/src/modules/conditions.ts +++ b/src/modules/conditions.ts @@ -16,7 +16,7 @@ export type Conditions = { condition?: string | string[] legendSet?: string } -type FormatValueOptions = { +export type FormatValueOptions = { locale?: string digitGroupSeparator?: SavedVisualization['digitGroupSeparator'] baseUrl?: string From a9f72983f8190fec9ff0e44f650c304289c08774 Mon Sep 17 00:00:00 2001 From: Hendrik de Graaf <hendrik@dhis2.org> Date: Wed, 30 Sep 2026 16:06:51 +0200 Subject: [PATCH 21/33] refactor: inline the compound id helper in filter-text Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- src/modules/visualization/filter-text.ts | 25 +++++++++++------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/src/modules/visualization/filter-text.ts b/src/modules/visualization/filter-text.ts index e85a047d4..a6f270040 100644 --- a/src/modules/visualization/filter-text.ts +++ b/src/modules/visualization/filter-text.ts @@ -21,20 +21,9 @@ import type { MetadataStore, } from '@types' -/* Matches the separator the pivot table engine has always used for its own - * filter line. */ +// Aligned with the separator the pivot table engine produces const FRAGMENT_SEPARATOR = ' - ' -const getCompoundId = ( - dimension: DimensionRecord, - visualization: CurrentVisualization -): string => - getCompoundDimensionId( - dimension, - visualization.outputType, - visualization.trackedEntityType?.id - ) - /* Suffixes disambiguate a dimension against the rest of the layout, so the * context is built from every axis rather than the filters alone. */ const getSuffixContext = ( @@ -48,7 +37,11 @@ const getSuffixContext = ( visualization )) { const item = metadataStore.getDimensionMetadataItem( - getCompoundId(dimension, visualization) + getCompoundDimensionId( + dimension, + visualization.outputType, + visualization.trackedEntityType?.id + ) ) if (item?.programId) { programIds.add(item.programId) @@ -139,7 +132,11 @@ export const getVisualizationFilterText = ({ const fragments: string[] = [] for (const dimension of filters) { - const compoundId = getCompoundId(dimension, visualization) + const compoundId = getCompoundDimensionId( + dimension, + visualization.outputType, + visualization.trackedEntityType?.id + ) const metadataItem = metadataStore.getDimensionMetadataItem(compoundId) if (!metadataItem) { continue From 7102d3296d229b7418cafb4ebfab4e03c7784ed9 Mon Sep 17 00:00:00 2001 From: Hendrik de Graaf <hendrik@dhis2.org> Date: Wed, 30 Sep 2026 16:25:11 +0200 Subject: [PATCH 22/33] refactor: read the title's program from programDimensions Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../visualization/__tests__/title.spec.ts | 128 ++++++++---------- src/modules/visualization/title.ts | 31 +---- 2 files changed, 61 insertions(+), 98 deletions(-) diff --git a/src/modules/visualization/__tests__/title.spec.ts b/src/modules/visualization/__tests__/title.spec.ts index 1650343eb..9c866d991 100644 --- a/src/modules/visualization/__tests__/title.spec.ts +++ b/src/modules/visualization/__tests__/title.spec.ts @@ -8,26 +8,27 @@ const TET_ID = 'tet1' const buildStore = (items: MetadataInputMap = {}) => { const store = new MetadataStore({}) - store.addMetadata({ - [PROGRAM_ID]: { - id: PROGRAM_ID, - name: 'Programme', - programType: 'WITHOUT_REGISTRATION', - }, - ...items, - }) + store.addMetadata(items) return store } +const buildProgram = (labels: Record<string, string> = {}) => ({ + id: PROGRAM_ID, + name: 'Programme', + programType: 'WITHOUT_REGISTRATION', + ...labels, +}) + const buildVis = ( overrides: Partial<CurrentVisualization> = {} ): CurrentVisualization => ({ type: 'LINE_LIST', outputType: 'EVENT', - columns: [{ dimension: 'ou', program: { id: PROGRAM_ID } }], + columns: [], rows: [], filters: [], + programDimensions: [buildProgram()], ...overrides, }) as CurrentVisualization @@ -61,72 +62,84 @@ describe('getVisualizationTitle', () => { }) it('prefers the plural event label for both visualization types', () => { - const store = buildStore({ - [PROGRAM_ID]: { - id: PROGRAM_ID, - name: 'Programme', - programType: 'WITHOUT_REGISTRATION', + const store = buildStore() + const programDimensions = [ + buildProgram({ displayEventLabel: 'visit', displayEventsLabel: 'Visits', - }, - }) + }), + ] as CurrentVisualization['programDimensions'] - expect(getVisualizationTitle(buildVis(), store)).toBe('Visits') expect( - getVisualizationTitle(buildVis({ type: 'PIVOT_TABLE' }), store) + getVisualizationTitle(buildVis({ programDimensions }), store) + ).toBe('Visits') + expect( + getVisualizationTitle( + buildVis({ type: 'PIVOT_TABLE', programDimensions }), + store + ) ).toBe('Visits') }) it('falls back to the singular event label with per-type wording', () => { - const store = buildStore({ - [PROGRAM_ID]: { - id: PROGRAM_ID, - name: 'Programme', - programType: 'WITHOUT_REGISTRATION', + const store = buildStore() + const programDimensions = [ + buildProgram({ displayEventLabel: 'Visit', - }, - }) + }), + ] as CurrentVisualization['programDimensions'] - expect(getVisualizationTitle(buildVis(), store)).toBe('Visit list') expect( - getVisualizationTitle(buildVis({ type: 'PIVOT_TABLE' }), store) + getVisualizationTitle(buildVis({ programDimensions }), store) + ).toBe('Visit list') + expect( + getVisualizationTitle( + buildVis({ type: 'PIVOT_TABLE', programDimensions }), + store + ) ).toBe('Visit count') }) it('uses the enrollment labels for enrollment output', () => { - const store = buildStore({ - [PROGRAM_ID]: { - id: PROGRAM_ID, - name: 'Programme', - programType: 'WITH_REGISTRATION', + const store = buildStore() + const programDimensions = [ + buildProgram({ displayEnrollmentLabel: 'Pregnancy', - }, - }) + }), + ] as CurrentVisualization['programDimensions'] expect( - getVisualizationTitle(buildVis({ outputType: 'ENROLLMENT' }), store) + getVisualizationTitle( + buildVis({ outputType: 'ENROLLMENT', programDimensions }), + store + ) ).toBe('Pregnancy list') expect( getVisualizationTitle( - buildVis({ outputType: 'ENROLLMENT', type: 'PIVOT_TABLE' }), + buildVis({ + outputType: 'ENROLLMENT', + type: 'PIVOT_TABLE', + programDimensions, + }), store ) ).toBe('Pregnancy count') }) it('prefers the plural enrollment label', () => { - const store = buildStore({ - [PROGRAM_ID]: { - id: PROGRAM_ID, - name: 'Programme', - programType: 'WITH_REGISTRATION', + const store = buildStore() + const programDimensions = [ + buildProgram({ displayEnrollmentLabel: 'Pregnancy', displayEnrollmentsLabel: 'Pregnancies', - }, - }) + }), + ] as CurrentVisualization['programDimensions'] expect( - getVisualizationTitle(buildVis({ outputType: 'ENROLLMENT' }), store) + getVisualizationTitle( + buildVis({ outputType: 'ENROLLMENT', programDimensions }), + store + ) ).toBe('Pregnancies') }) @@ -225,32 +238,9 @@ describe('getVisualizationTitle', () => { expect(getVisualizationTitle(vis, store)).toBe('Events') }) - it('falls back to the bare plural for an empty layout', () => { - const vis = buildVis({ columns: [], rows: [], filters: [] }) + it('falls back to the bare plural when there is no program', () => { + const vis = buildVis({ programDimensions: [] }) expect(getVisualizationTitle(vis, buildStore())).toBe('Events') }) - - it('picks the first program in axis order for multi-program layouts', () => { - const store = buildStore({ - [PROGRAM_ID]: { - id: PROGRAM_ID, - name: 'First', - programType: 'WITH_REGISTRATION', - displayEventsLabel: 'Visits', - }, - prg2: { - id: 'prg2', - name: 'Second', - programType: 'WITH_REGISTRATION', - displayEventsLabel: 'Doses', - }, - }) - const vis = buildVis({ - columns: [{ dimension: 'ou', program: { id: PROGRAM_ID } }], - filters: [{ dimension: 'x', program: { id: 'prg2' } }], - } as Partial<CurrentVisualization>) - - expect(getVisualizationTitle(vis, store)).toBe('Visits') - }) }) diff --git a/src/modules/visualization/title.ts b/src/modules/visualization/title.ts index 61ab9f969..2aec00cd9 100644 --- a/src/modules/visualization/title.ts +++ b/src/modules/visualization/title.ts @@ -1,24 +1,7 @@ import { aggregationTypeDisplayNames } from '@constants/aggregation-types' import i18n from '@dhis2/d2-i18n' import { isPopulatedString } from '@modules/utils/guards' -import type { CurrentVisualization, MetadataStore, Program } from '@types' - -const getFirstProgramId = ( - visualization: CurrentVisualization -): string | undefined => { - for (const axis of [ - visualization.columns, - visualization.rows, - visualization.filters, - ]) { - for (const dimension of axis ?? []) { - if (dimension.program?.id) { - return dimension.program.id - } - } - } - return undefined -} +import type { CurrentVisualization, MetadataStore } from '@types' const getCountOrListTitle = ( label: string, @@ -67,16 +50,6 @@ const getTrackedEntityTitle = ( : i18n.t('Tracked entities') } -const getProgramLabels = ( - visualization: CurrentVisualization, - metadataStore: MetadataStore -): Program | undefined => { - const programId = getFirstProgramId(visualization) - return programId - ? metadataStore.getProgramMetadataItem(programId) - : undefined -} - export const getAutoTitle = ( visualization: CurrentVisualization, metadataStore: MetadataStore @@ -90,7 +63,7 @@ export const getAutoTitle = ( return getTrackedEntityTitle(visualization, metadataStore) } - const program = getProgramLabels(visualization, metadataStore) + const program = visualization.programDimensions?.[0] if (visualization.outputType === 'ENROLLMENT') { if (isPopulatedString(program?.displayEnrollmentsLabel)) { From e084202882945a0543c7599c613558522f338c70 Mon Sep 17 00:00:00 2001 From: Hendrik de Graaf <hendrik@dhis2.org> Date: Wed, 30 Sep 2026 16:31:19 +0200 Subject: [PATCH 23/33] refactor: separate the custom value check from its title, throw on a missing name Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../visualization/__tests__/title.spec.ts | 4 +-- src/modules/visualization/title.ts | 36 ++++++++++--------- 2 files changed, 22 insertions(+), 18 deletions(-) diff --git a/src/modules/visualization/__tests__/title.spec.ts b/src/modules/visualization/__tests__/title.spec.ts index 9c866d991..21bc3f15d 100644 --- a/src/modules/visualization/__tests__/title.spec.ts +++ b/src/modules/visualization/__tests__/title.spec.ts @@ -216,14 +216,14 @@ describe('getVisualizationTitle', () => { expect(getVisualizationTitle(vis, store)).toBe('Events') }) - it('ignores a custom value whose item is unknown to the metadata store', () => { + it('throws when the custom value item is unknown to the metadata store', () => { const vis = buildVis({ type: 'PIVOT_TABLE', value: { id: 'missing' }, aggregationType: 'AVERAGE', }) - expect(getVisualizationTitle(vis, buildStore())).toBe('Events') + expect(() => getVisualizationTitle(vis, buildStore())).toThrow() }) it('ignores a custom value on a line list', () => { diff --git a/src/modules/visualization/title.ts b/src/modules/visualization/title.ts index 2aec00cd9..0e4fc2a9b 100644 --- a/src/modules/visualization/title.ts +++ b/src/modules/visualization/title.ts @@ -11,24 +11,29 @@ const getCountOrListTitle = ( ? i18n.t('{{- label}} count', { label }) : i18n.t('{{- label}} list', { label }) +type VisualizationWithCustomValue = CurrentVisualization & { + value: { id: string } + aggregationType: NonNullable<CurrentVisualization['aggregationType']> +} + +const hasCustomValue = ( + visualization: CurrentVisualization +): visualization is VisualizationWithCustomValue => + visualization.type === 'PIVOT_TABLE' && + Boolean(visualization.value?.id) && + Boolean(visualization.aggregationType) + const getCustomValueTitle = ( - visualization: CurrentVisualization, + visualization: VisualizationWithCustomValue, metadataStore: MetadataStore -): string | undefined => { - if ( - visualization.type !== 'PIVOT_TABLE' || - !visualization.value?.id || - !visualization.aggregationType - ) { - return undefined - } - const itemName = metadataStore.getMetadataItem(visualization.value.id)?.name +): string => { + const itemName = metadataStore.getMetadataItemOrThrow( + visualization.value.id + ).name const aggregationName = aggregationTypeDisplayNames[visualization.aggregationType] - return isPopulatedString(itemName) && isPopulatedString(aggregationName) - ? `${itemName} · ${aggregationName}` - : undefined + return `${itemName} · ${aggregationName}` } const getTrackedEntityTitle = ( @@ -54,9 +59,8 @@ export const getAutoTitle = ( visualization: CurrentVisualization, metadataStore: MetadataStore ): string => { - const customValueTitle = getCustomValueTitle(visualization, metadataStore) - if (customValueTitle) { - return customValueTitle + if (hasCustomValue(visualization)) { + return getCustomValueTitle(visualization, metadataStore) } if (visualization.outputType === 'TRACKED_ENTITY_INSTANCE') { From d5f1f0cce76a50df56ff5a64e428acd9de4d7896 Mon Sep 17 00:00:00 2001 From: Hendrik de Graaf <hendrik@dhis2.org> Date: Wed, 30 Sep 2026 16:34:19 +0200 Subject: [PATCH 24/33] fix: throw when the tracked entity type is missing instead of falling back The spec defines no generic fallback for tracked entity titles, and the metadata store guarantees a name for any type it holds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- i18n/en.pot | 7 ++----- .../visualization/__tests__/title.spec.ts | 6 ++---- src/modules/visualization/title.ts | 18 ++++++++++-------- 3 files changed, 14 insertions(+), 17 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 419fb937a..8fb2cd24a 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -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-10-01T13:14:43.842Z\n" -"PO-Revision-Date: 2026-10-01T13:14:43.843Z\n" +"POT-Creation-Date: 2026-10-01T13:14:58.128Z\n" +"PO-Revision-Date: 2026-10-01T13:14:58.129Z\n" msgid "" "Some dimensions were not added because they cannot be used in a " @@ -1309,9 +1309,6 @@ msgstr "{{- label}} count" msgid "{{- label}} list" msgstr "{{- label}} list" -msgid "Tracked entities" -msgstr "Tracked entities" - msgid "Enrollments" msgstr "Enrollments" diff --git a/src/modules/visualization/__tests__/title.spec.ts b/src/modules/visualization/__tests__/title.spec.ts index 21bc3f15d..3b4656360 100644 --- a/src/modules/visualization/__tests__/title.spec.ts +++ b/src/modules/visualization/__tests__/title.spec.ts @@ -181,15 +181,13 @@ describe('getVisualizationTitle', () => { ).toBe('Patient count') }) - it('falls back to a generic plural when the tracked entity type is unknown', () => { + it('throws when the tracked entity type is unknown to the metadata store', () => { const vis = buildVis({ outputType: 'TRACKED_ENTITY_INSTANCE', trackedEntityType: { id: 'missing', name: 'Person' }, }) - expect(getVisualizationTitle(vis, buildStore())).toBe( - 'Tracked entities' - ) + expect(() => getVisualizationTitle(vis, buildStore())).toThrow() }) it('replaces the output-type title with the custom value on a pivot', () => { diff --git a/src/modules/visualization/title.ts b/src/modules/visualization/title.ts index 0e4fc2a9b..11b645177 100644 --- a/src/modules/visualization/title.ts +++ b/src/modules/visualization/title.ts @@ -41,18 +41,20 @@ const getTrackedEntityTitle = ( metadataStore: MetadataStore ): string => { const tetId = visualization.trackedEntityType?.id - const tet = tetId ? metadataStore.getMetadataItem(tetId) : undefined + if (!tetId) { + throw new Error( + 'A tracked entity visualization has no tracked entity type' + ) + } + const tet = metadataStore.getMetadataItemOrThrow(tetId) const pluralLabel = - tet && 'displayTrackedEntityTypesLabel' in tet + 'displayTrackedEntityTypesLabel' in tet ? tet.displayTrackedEntityTypesLabel : undefined - if (isPopulatedString(pluralLabel)) { - return pluralLabel - } - return isPopulatedString(tet?.name) - ? getCountOrListTitle(tet.name, visualization) - : i18n.t('Tracked entities') + return isPopulatedString(pluralLabel) + ? pluralLabel + : getCountOrListTitle(tet.name, visualization) } export const getAutoTitle = ( From aa34ac9ab65b23b6dd73bdcaeab16ece79eb575e Mon Sep 17 00:00:00 2001 From: Hendrik de Graaf <hendrik@dhis2.org> Date: Wed, 30 Sep 2026 16:36:28 +0200 Subject: [PATCH 25/33] refactor: throw on missing dimension metadata in the filter line Also drops the name and dimensionId fallbacks in toLayoutDimension, which the metadata store already guarantees. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- src/modules/dimension/layout-dimension.ts | 4 ++-- .../visualization/__tests__/filter-text.spec.ts | 6 +++--- src/modules/visualization/filter-text.ts | 12 +++++------- 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/src/modules/dimension/layout-dimension.ts b/src/modules/dimension/layout-dimension.ts index 239a199e2..ff8d0b709 100644 --- a/src/modules/dimension/layout-dimension.ts +++ b/src/modules/dimension/layout-dimension.ts @@ -12,8 +12,8 @@ export const toLayoutDimension = ( ): LayoutDimension => { const dimension: LayoutDimension = { id, - name: metadataItem.name || id, - dimensionId: metadataItem.dimensionId ?? id, + name: metadataItem.name, + dimensionId: metadataItem.dimensionId, dimensionType: metadataItem.dimensionType, programStageId: metadataItem.programStageId, programId: metadataItem.programId, diff --git a/src/modules/visualization/__tests__/filter-text.spec.ts b/src/modules/visualization/__tests__/filter-text.spec.ts index 185655fab..50e249a96 100644 --- a/src/modules/visualization/__tests__/filter-text.spec.ts +++ b/src/modules/visualization/__tests__/filter-text.spec.ts @@ -214,7 +214,7 @@ describe('getVisualizationFilterText', () => { ).toBe('') }) - it('skips a dimension with no metadata rather than showing its id', () => { + it('throws for a filter dimension with no metadata', () => { const vis = buildVis([ { dimension: 'unknown', @@ -224,11 +224,11 @@ describe('getVisualizationFilterText', () => { }, ] as CurrentVisualization['filters']) - expect( + expect(() => getVisualizationFilterText({ visualization: vis, metadataStore: buildStore(), }) - ).toBe('') + ).toThrow() }) }) diff --git a/src/modules/visualization/filter-text.ts b/src/modules/visualization/filter-text.ts index a6f270040..4848b9dce 100644 --- a/src/modules/visualization/filter-text.ts +++ b/src/modules/visualization/filter-text.ts @@ -36,17 +36,17 @@ const getSuffixContext = ( for (const dimension of combineAllDimensionsFromVisualization( visualization )) { - const item = metadataStore.getDimensionMetadataItem( + const item = metadataStore.getDimensionMetadataItemOrThrow( getCompoundDimensionId( dimension, visualization.outputType, visualization.trackedEntityType?.id ) ) - if (item?.programId) { + if (item.programId) { programIds.add(item.programId) } - if (item?.programStageId) { + if (item.programStageId) { programStageIds.add(item.programStageId) } } @@ -137,10 +137,8 @@ export const getVisualizationFilterText = ({ visualization.outputType, visualization.trackedEntityType?.id ) - const metadataItem = metadataStore.getDimensionMetadataItem(compoundId) - if (!metadataItem) { - continue - } + const metadataItem = + metadataStore.getDimensionMetadataItemOrThrow(compoundId) const layoutDimension = toLayoutDimension( compoundId, From 8bc6d703dbf50cd476d72fe577d3f8c76a6561fe Mon Sep 17 00:00:00 2001 From: Hendrik de Graaf <hendrik@dhis2.org> Date: Wed, 30 Sep 2026 16:45:13 +0200 Subject: [PATCH 26/33] refactor: drop a redundant comment in conditions Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- src/modules/conditions.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/modules/conditions.ts b/src/modules/conditions.ts index 02045f3dd..873b40e4e 100644 --- a/src/modules/conditions.ts +++ b/src/modules/conditions.ts @@ -466,8 +466,6 @@ export const getConditionsFromVisualization = ( return result } -/* Split from getConditionsTexts so a React caller can feed the IDs to a - * subscribing hook, while a plain caller reads them straight from the store. */ export const getConditionsMetadataIds = ({ conditions, dimension, From 55e9449fbde41aacfc02c10c63a54b07a998bbfc Mon Sep 17 00:00:00 2001 From: Hendrik de Graaf <hendrik@dhis2.org> Date: Wed, 30 Sep 2026 16:52:04 +0200 Subject: [PATCH 27/33] refactor: state what the visualization type listener resets Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- src/store/listeners.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/store/listeners.ts b/src/store/listeners.ts index 573d33659..e70525ed8 100644 --- a/src/store/listeners.ts +++ b/src/store/listeners.ts @@ -50,9 +50,7 @@ export const registerAppListeners = () => { effect: (action, { dispatch }) => { setLastUsedVisualizationTypeToLocalStorage(action.payload) - /* The generated title differs per visualization type ("Visit - * count" vs "Visit list"), so a custom title written for one type - * rarely suits the other. Output type changes keep the title. */ + // Changing visualization type resets the title configuration to Auto generated dispatch(setVisUiConfigOption({ key: 'title', value: '' })) dispatch(setVisUiConfigOption({ key: 'hideTitle', value: false })) }, From b052de5222c7e2ad77b9f7868f2ab8cac065b7fc Mon Sep 17 00:00:00 2001 From: Hendrik de Graaf <hendrik@dhis2.org> Date: Wed, 30 Sep 2026 16:54:59 +0200 Subject: [PATCH 28/33] chore: depend on the released @dhis2/analytics 29.8.0 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- package.json | 2 +- pnpm-lock.yaml | 11 +++++------ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index de02cc283..2050bc6f7 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "vitest-canvas-mock": "^1.2.0" }, "dependencies": { - "@dhis2/analytics": "git+https://github.com/d2-ci/analytics.git#ca872daa11f1d7ed8d8e545cea9841d80ed91ed0", + "@dhis2/analytics": "^29.8.0", "@dhis2/app-runtime": "^3.17.4", "@dhis2/ui": "^10.18.0", "@dnd-kit/core": "^6.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eac826cc1..3d8df90c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: '@dhis2/analytics': - specifier: git+https://github.com/d2-ci/analytics.git#ca872daa11f1d7ed8d8e545cea9841d80ed91ed0 - version: https://codeload.github.com/d2-ci/analytics/tar.gz/ca872daa11f1d7ed8d8e545cea9841d80ed91ed0(@dhis2/app-runtime@3.17.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@dhis2/d2-i18n@1.2.0)(@dhis2/ui@10.18.0(@dhis2/app-runtime@3.17.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@dhis2/d2-i18n@1.2.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-jsx@4.0.1(@babel/core@7.29.7)(react@18.3.1)))(prop-types@15.8.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-jsx@4.0.1(@babel/core@7.29.7)(react@18.3.1)) + specifier: ^29.8.0 + version: 29.8.1(@dhis2/app-runtime@3.17.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@dhis2/d2-i18n@1.2.0)(@dhis2/ui@10.18.0(@dhis2/app-runtime@3.17.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@dhis2/d2-i18n@1.2.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-jsx@4.0.1(@babel/core@7.29.7)(react@18.3.1)))(prop-types@15.8.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-jsx@4.0.1(@babel/core@7.29.7)(react@18.3.1)) '@dhis2/app-runtime': specifier: ^3.17.4 version: 3.17.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -1435,9 +1435,8 @@ packages: react-dom: ^16.13 || ^18 styled-jsx: ^4 - '@dhis2/analytics@https://codeload.github.com/d2-ci/analytics/tar.gz/ca872daa11f1d7ed8d8e545cea9841d80ed91ed0': - resolution: {gitHosted: true, integrity: sha512-RkRaSNiXpfWYCnYdw9fDtPBkOuaNh2Lqs1E+/hzOmOWdS+EuWj2iySCf5vPi7QPafdf4elKL45RG8hmFqx92fA==, tarball: https://codeload.github.com/d2-ci/analytics/tar.gz/ca872daa11f1d7ed8d8e545cea9841d80ed91ed0} - version: 29.7.0 + '@dhis2/analytics@29.8.1': + resolution: {integrity: sha512-tPuT7LcUJ+PowDafx2+6edmk9ooPPc4ZeoRc3czZbBzVZ1AjCvlXc3Vst1TeDXy0diZRujoYl5TvJW1d/MENqQ==} peerDependencies: '@dhis2/app-runtime': ^3 '@dhis2/d2-i18n': ^1.1 @@ -9771,7 +9770,7 @@ snapshots: react-dom: 18.3.1(react@18.3.1) styled-jsx: 4.0.1(@babel/core@7.29.7)(react@18.3.1) - '@dhis2/analytics@https://codeload.github.com/d2-ci/analytics/tar.gz/ca872daa11f1d7ed8d8e545cea9841d80ed91ed0(@dhis2/app-runtime@3.17.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@dhis2/d2-i18n@1.2.0)(@dhis2/ui@10.18.0(@dhis2/app-runtime@3.17.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@dhis2/d2-i18n@1.2.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-jsx@4.0.1(@babel/core@7.29.7)(react@18.3.1)))(prop-types@15.8.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-jsx@4.0.1(@babel/core@7.29.7)(react@18.3.1))': + '@dhis2/analytics@29.8.1(@dhis2/app-runtime@3.17.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@dhis2/d2-i18n@1.2.0)(@dhis2/ui@10.18.0(@dhis2/app-runtime@3.17.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@dhis2/d2-i18n@1.2.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-jsx@4.0.1(@babel/core@7.29.7)(react@18.3.1)))(prop-types@15.8.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-jsx@4.0.1(@babel/core@7.29.7)(react@18.3.1))': dependencies: '@dhis2/app-runtime': 3.17.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@dhis2/d2-i18n': 1.2.0 From ed474fb31dbc1026d82f28cd29c4fefd873ab304 Mon Sep 17 00:00:00 2001 From: Hendrik de Graaf <hendrik@dhis2.org> Date: Wed, 30 Sep 2026 16:57:42 +0200 Subject: [PATCH 29/33] chore: drop the pnpm store test exclude Only a git dependency's install left test files in the store. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- vitest.config.mts | 3 --- 1 file changed, 3 deletions(-) diff --git a/vitest.config.mts b/vitest.config.mts index c03332ab6..a378c2797 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -18,9 +18,6 @@ export default defineConfig({ ...configDefaults.exclude, '**/.d2/**', '**/.claude/**', - /* Installing a git dependency leaves the extracted package, tests - * and all, in the project-local pnpm store. */ - '**/.pnpm-store/**', // Third-party sources fetched for reference by `npx opensrc` '**/opensrc/**', ], From 4656034a96ed75cf98ea9dfabb11db70b7559dca Mon Sep 17 00:00:00 2001 From: Hendrik de Graaf <hendrik@dhis2.org> Date: Wed, 30 Sep 2026 17:19:38 +0200 Subject: [PATCH 30/33] refactor: share how a dimension's selection is described Moves the items-or-conditions choice out of the chip tooltip into a pure getDimensionValueTexts, and adds a pure getSuffixContext and getDimensionLabel. The tooltip and the line list headers now use them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../axis/__tests__/grouping-chip.spec.tsx | 15 +- .../use-tooltip-content-data.spec.ts | 363 +++--------------- src/components/layout-panel/axis/chip.tsx | 4 +- .../layout-panel/axis/tooltip-content.tsx | 33 +- .../axis/use-tooltip-content-data.ts | 59 +-- .../use-transformed-line-list-data.ts | 16 +- .../use-dimension-value-texts.spec.ts | 41 ++ src/hooks/index.ts | 1 + src/hooks/use-dimension-value-texts.ts | 50 +++ .../dimension/__tests__/item-names.spec.ts | 76 ++++ .../dimension/__tests__/value-texts.spec.ts | 82 ++++ src/modules/dimension/dimension-type.ts | 14 + src/modules/dimension/suffix.ts | 24 +- src/modules/dimension/value-texts.ts | 49 +++ 14 files changed, 434 insertions(+), 393 deletions(-) create mode 100644 src/hooks/__tests__/use-dimension-value-texts.spec.ts create mode 100644 src/hooks/use-dimension-value-texts.ts create mode 100644 src/modules/dimension/__tests__/item-names.spec.ts create mode 100644 src/modules/dimension/__tests__/value-texts.spec.ts create mode 100644 src/modules/dimension/value-texts.ts diff --git a/src/components/layout-panel/axis/__tests__/grouping-chip.spec.tsx b/src/components/layout-panel/axis/__tests__/grouping-chip.spec.tsx index e3aca146c..0415dc246 100644 --- a/src/components/layout-panel/axis/__tests__/grouping-chip.spec.tsx +++ b/src/components/layout-panel/axis/__tests__/grouping-chip.spec.tsx @@ -49,10 +49,13 @@ describe('TooltipContent grouping', () => { await renderWithAppWrapper( <TooltipContent dimension={dimension} - conditionsTexts={['0 - 10', '10 - 20']} + itemIds={[]} + conditions={{ legendSet: 'LEGEND_SET', condition: 'IN:L1;L2' }} + digitGroupSeparator="SPACE" groupingName="Age 10y interval" axisId="columns" - /> + />, + { metadata: { L1: '0 - 10', L2: '10 - 20' } } ) expect( @@ -66,7 +69,9 @@ describe('TooltipContent grouping', () => { await renderWithAppWrapper( <TooltipContent dimension={dimension} - conditionsTexts={[]} + itemIds={[]} + conditions={{ legendSet: 'LEGEND_SET' }} + digitGroupSeparator="SPACE" groupingName="Age 10y interval" axisId="columns" /> @@ -84,7 +89,9 @@ describe('TooltipContent grouping', () => { await renderWithAppWrapper( <TooltipContent dimension={dimension} - conditionsTexts={[]} + itemIds={[]} + conditions={{}} + digitGroupSeparator="SPACE" axisId="columns" /> ) diff --git a/src/components/layout-panel/axis/__tests__/use-tooltip-content-data.spec.ts b/src/components/layout-panel/axis/__tests__/use-tooltip-content-data.spec.ts index 80a917a3a..6b8c8e719 100644 --- a/src/components/layout-panel/axis/__tests__/use-tooltip-content-data.spec.ts +++ b/src/components/layout-panel/axis/__tests__/use-tooltip-content-data.spec.ts @@ -1,10 +1,8 @@ -import { useAddMetadata } from '@hooks' import { visUiConfigSlice, type VisUiConfigState, } from '@store/vis-ui-config-slice' import { renderHookWithAppWrapper } from '@test-utils/app-wrapper' -import { act } from '@testing-library/react' import { describe, expect, it } from 'vitest' import type { LayoutDimension } from '../chip' import { useTooltipContentData } from '../use-tooltip-content-data' @@ -34,325 +32,66 @@ const createVisUiConfigState = ( }) describe('useTooltipContentData', () => { - describe('basic functionality', () => { - it('returns empty data for dimension with no items', async () => { - const { result } = await renderHookWithAppWrapper( - () => useTooltipContentData(baseDimension), - { - partialStore: { - reducer: { - visUiConfig: visUiConfigSlice.reducer, - }, - preloadedState: { - visUiConfig: createVisUiConfigState(), - }, + it('returns empty names for a dimension without program context', async () => { + const { result } = await renderHookWithAppWrapper( + () => useTooltipContentData(baseDimension), + { + partialStore: { + reducer: { + visUiConfig: visUiConfigSlice.reducer, }, - } - ) - - expect(result.current).toEqual({ - programName: '', - stageName: '', - itemDisplayNames: [], - }) - }) - - it('returns data element names when metadata is available', async () => { - const { result } = await renderHookWithAppWrapper( - () => ({ - tooltipData: useTooltipContentData(baseDimension), - addMetadata: useAddMetadata(), - }), - { - partialStore: { - reducer: { - visUiConfig: visUiConfigSlice.reducer, - }, - preloadedState: { - visUiConfig: createVisUiConfigState({ - itemsByDimension: { - dx: ['DE1', 'DE2'], - }, - }), - }, + preloadedState: { + visUiConfig: createVisUiConfigState(), }, - } - ) - - // Initially shows IDs since metadata is not loaded - expect(result.current.tooltipData.itemDisplayNames).toEqual([ - 'DE1', - 'DE2', - ]) - - // Add metadata - act(() => { - result.current.addMetadata({ - uid: 'DE1', - name: 'Data Element 1', - }) - result.current.addMetadata({ - uid: 'DE2', - name: 'Data Element 2', - }) - }) - - // Now shows names - expect(result.current.tooltipData.itemDisplayNames).toEqual([ - 'Data Element 1', - 'Data Element 2', - ]) - }) - - it('returns program and stage names when available', async () => { - const programDimension: LayoutDimension = { - id: 'programUid.stageUid.dimensionUid', - dimensionId: 'dimensionUid', - dimensionType: 'DATA_ELEMENT', - programId: 'programUid', - programStageId: 'stageUid', - name: 'Program', + }, } + ) - const { result } = await renderHookWithAppWrapper( - () => ({ - tooltipData: useTooltipContentData(programDimension), - addMetadata: useAddMetadata(), - }), - { - partialStore: { - reducer: { - visUiConfig: visUiConfigSlice.reducer, - }, - preloadedState: { - visUiConfig: createVisUiConfigState(), - }, - }, - metadata: { - programUid: { - id: 'programUid', - name: 'Test Program', - programType: 'WITHOUT_REGISTRATION', - programStages: [ - { - id: 'stageUid', - name: 'Test Stage', - repeatable: true, - }, - ], - }, - }, - } - ) - - expect(result.current.tooltipData.programName).toBe('Test Program') - expect(result.current.tooltipData.stageName).toBe('Test Stage') - }) - - it('handles organisation unit levels', async () => { - const { result } = await renderHookWithAppWrapper( - () => ({ - tooltipData: useTooltipContentData(baseDimension), - addMetadata: useAddMetadata(), - }), - { - partialStore: { - reducer: { - visUiConfig: visUiConfigSlice.reducer, - }, - preloadedState: { - visUiConfig: createVisUiConfigState({ - itemsByDimension: { - dx: ['LEVEL-1', 'LEVEL-2'], - }, - }), - }, - }, - } - ) - - // Initially shows level IDs - expect(result.current.tooltipData.itemDisplayNames).toEqual([ - 'Levels: 1, 2', - ]) - - // Add level metadata - act(() => { - result.current.addMetadata({ - uid: '1', - name: 'National', - }) - result.current.addMetadata({ - uid: '2', - name: 'District', - }) - }) - - // Now shows level names - expect(result.current.tooltipData.itemDisplayNames).toEqual([ - 'Levels: National, District', - ]) - }) - - it('handles organisation unit groups', async () => { - const { result } = await renderHookWithAppWrapper( - () => ({ - tooltipData: useTooltipContentData(baseDimension), - addMetadata: useAddMetadata(), - }), - { - partialStore: { - reducer: { - visUiConfig: visUiConfigSlice.reducer, - }, - preloadedState: { - visUiConfig: createVisUiConfigState({ - itemsByDimension: { - dx: ['OU_GROUP-group1', 'OU_GROUP-group2'], - }, - }), - }, - }, - } - ) - - // Initially shows group IDs - expect(result.current.tooltipData.itemDisplayNames).toEqual([ - 'Groups: group1, group2', - ]) - - // Add group metadata - act(() => { - result.current.addMetadata({ - uid: 'group1', - name: 'Group One', - }) - result.current.addMetadata({ - uid: 'group2', - name: 'Group Two', - }) - }) - - // Now shows group names - expect(result.current.tooltipData.itemDisplayNames).toEqual([ - 'Groups: Group One, Group Two', - ]) + expect(result.current).toEqual({ + programName: '', + stageName: '', }) + }) - it('handles date ranges', async () => { - const { result } = await renderHookWithAppWrapper( - () => useTooltipContentData(baseDimension), - { - partialStore: { - reducer: { - visUiConfig: visUiConfigSlice.reducer, - }, - preloadedState: { - visUiConfig: createVisUiConfigState({ - itemsByDimension: { - dx: ['2023-01-01_2023-12-31'], - }, - }), - }, + it('returns program and stage names when available', async () => { + const programDimension: LayoutDimension = { + id: 'programUid.stageUid.dimensionUid', + dimensionId: 'dimensionUid', + dimensionType: 'DATA_ELEMENT', + programId: 'programUid', + programStageId: 'stageUid', + name: 'Program', + } + + const { result } = await renderHookWithAppWrapper( + () => useTooltipContentData(programDimension), + { + partialStore: { + reducer: { + visUiConfig: visUiConfigSlice.reducer, }, - } - ) - - // Date ranges should be formatted - expect(result.current.itemDisplayNames).toEqual([ - 'January 1, 2023 - December 31, 2023', - ]) - }) - - it('combines different item types', async () => { - const { result } = await renderHookWithAppWrapper( - () => ({ - tooltipData: useTooltipContentData(baseDimension), - addMetadata: useAddMetadata(), - }), - { - partialStore: { - reducer: { - visUiConfig: visUiConfigSlice.reducer, - }, - preloadedState: { - visUiConfig: createVisUiConfigState({ - itemsByDimension: { - dx: ['DE1', 'LEVEL-1', 'OU_GROUP-group1'], - }, - }), - }, + preloadedState: { + visUiConfig: createVisUiConfigState(), }, - } - ) - - // Initially shows IDs and formatted level/group - expect(result.current.tooltipData.itemDisplayNames).toEqual([ - 'DE1', - 'Levels: 1', - 'Groups: group1', - ]) - - // Add metadata - act(() => { - result.current.addMetadata({ - uid: 'DE1', - name: 'Data Element 1', - }) - result.current.addMetadata({ - uid: '1', - name: 'National', - }) - result.current.addMetadata({ - uid: 'group1', - name: 'Group One', - }) - }) - - // Now shows all names - expect(result.current.tooltipData.itemDisplayNames).toEqual([ - 'Data Element 1', - 'Levels: National', - 'Groups: Group One', - ]) - }) - }) - - describe('reactive metadata updates', () => { - it('updates when metadata is added dynamically', async () => { - const { result } = await renderHookWithAppWrapper( - () => ({ - tooltipData: useTooltipContentData(baseDimension), - addMetadata: useAddMetadata(), - }), - { - partialStore: { - reducer: { - visUiConfig: visUiConfigSlice.reducer, - }, - preloadedState: { - visUiConfig: createVisUiConfigState({ - itemsByDimension: { - dx: ['DE1'], - }, - }), - }, + }, + metadata: { + programUid: { + id: 'programUid', + name: 'Test Program', + programType: 'WITHOUT_REGISTRATION', + programStages: [ + { + id: 'stageUid', + name: 'Test Stage', + repeatable: true, + }, + ], }, - } - ) - - expect(result.current.tooltipData.itemDisplayNames).toEqual(['DE1']) - - act(() => { - result.current.addMetadata({ - uid: 'DE1', - name: 'Updated Data Element', - }) - }) + }, + } + ) - expect(result.current.tooltipData.itemDisplayNames).toEqual([ - 'Updated Data Element', - ]) - }) + expect(result.current.programName).toBe('Test Program') + expect(result.current.stageName).toBe('Test Stage') }) }) diff --git a/src/components/layout-panel/axis/chip.tsx b/src/components/layout-panel/axis/chip.tsx index 8418335f1..4ff54ed9b 100644 --- a/src/components/layout-panel/axis/chip.tsx +++ b/src/components/layout-panel/axis/chip.tsx @@ -129,7 +129,9 @@ export const Chip: FC<ChipProps> = ({ dimension, axisId }) => { content={ <TooltipContent dimension={dimension} - conditionsTexts={conditionsTexts} + itemIds={items} + conditions={conditions} + digitGroupSeparator={digitGroupSeparator} groupingName={legendSet?.name} axisId={axisId} /> diff --git a/src/components/layout-panel/axis/tooltip-content.tsx b/src/components/layout-panel/axis/tooltip-content.tsx index 42a0ab92b..358cde67e 100644 --- a/src/components/layout-panel/axis/tooltip-content.tsx +++ b/src/components/layout-panel/axis/tooltip-content.tsx @@ -1,5 +1,7 @@ import i18n from '@dhis2/d2-i18n' -import type { DimensionType } from '@types' +import { useDimensionValueTexts } from '@hooks' +import type { Conditions } from '@modules/conditions' +import type { DimensionType, SavedVisualization } from '@types' import { type FC } from 'react' import type { LayoutDimension } from './chip' import styles from './styles/tooltip.module.css' @@ -7,15 +9,6 @@ import { useTooltipContentData } from './use-tooltip-content-data' const MAX_LIST_LENGTH = 5 -const ITEM_BASED_DIMENSION_TYPES: ReadonlySet<DimensionType> = new Set([ - 'CATEGORY', - 'CATEGORY_OPTION_GROUP_SET', - 'ORGANISATION_UNIT_GROUP_SET', - 'STATUS', - 'PERIOD', - 'ORGANISATION_UNIT', -]) - const NO_FALLBACK_DIMENSION_TYPES: ReadonlySet<DimensionType> = new Set([ 'PERIOD', 'ORGANISATION_UNIT', @@ -23,7 +16,9 @@ const NO_FALLBACK_DIMENSION_TYPES: ReadonlySet<DimensionType> = new Set([ type TooltipContentProps = { dimension: LayoutDimension - conditionsTexts: string[] + itemIds: string[] + conditions: Conditions + digitGroupSeparator: SavedVisualization['digitGroupSeparator'] groupingName?: string axisId: string } @@ -60,17 +55,21 @@ const ItemsList: FC<ItemsListProps> = ({ itemDisplayNames, dimensionId }) => { export const TooltipContent: FC<TooltipContentProps> = ({ dimension, - conditionsTexts, + itemIds, + conditions, + digitGroupSeparator, groupingName, axisId, }) => { - const { programName, stageName, itemDisplayNames } = - useTooltipContentData(dimension) + const { programName, stageName } = useTooltipContentData(dimension) + const itemsList = useDimensionValueTexts({ + dimension, + itemIds, + conditions, + formatValueOptions: { digitGroupSeparator }, + }) const dimensionType = dimension.dimensionType - const isItemBased = - !!dimensionType && ITEM_BASED_DIMENSION_TYPES.has(dimensionType) - const itemsList = isItemBased ? itemDisplayNames : conditionsTexts const showStage = dimensionType === 'DATA_ELEMENT' const emptyShowsNoneSelected = axisId === 'filters' || diff --git a/src/components/layout-panel/axis/use-tooltip-content-data.ts b/src/components/layout-panel/axis/use-tooltip-content-data.ts index 18a542909..5ed1c2be9 100644 --- a/src/components/layout-panel/axis/use-tooltip-content-data.ts +++ b/src/components/layout-panel/axis/use-tooltip-content-data.ts @@ -1,41 +1,21 @@ -import { useMetadataItems, useAppSelector } from '@hooks' -import { - getItemDisplayNames, - getItemMetadataIds, -} from '@modules/dimension/item-names' +import { useMetadataItems } from '@hooks' import { isProgramMetadataItem } from '@modules/metadata/item-guards' -import { useLocalizedStartEndDateFormatter } from '@modules/utils/dates' -import { getVisUiConfigItemsByDimension } from '@store/vis-ui-config-slice' import { useMemo } from 'react' import type { LayoutDimension } from './chip' export const useTooltipContentData = (dimension: LayoutDimension) => { - const itemIds = useAppSelector((state) => - getVisUiConfigItemsByDimension(state, dimension.id) - ) - const formatStartEndDate = useLocalizedStartEndDateFormatter() const { programId, programStageId } = dimension - // Collect all metadata IDs that will be needed - const metadataIds = useMemo(() => { - const ids = new Set<string>(getItemMetadataIds(itemIds)) - - if (programId) { - ids.add(programId) - } - if (programStageId) { - ids.add(programStageId) - } - - return Array.from(ids) - }, [programId, programStageId, itemIds]) - - // Get reactive metadata + const metadataIds = useMemo( + () => + [programId, programStageId].filter((id): id is string => + Boolean(id) + ), + [programId, programStageId] + ) const metadataItems = useMetadataItems(metadataIds) - // Compute the final data using reactive metadata - const { programName, stageName, itemDisplayNames } = useMemo(() => { - // Program and stage names + return useMemo(() => { const programMetadata = programId ? metadataItems[programId] : null const programStageMetadata = programStageId ? metadataItems[programStageId] @@ -52,25 +32,10 @@ export const useTooltipContentData = (dimension: LayoutDimension) => { ) : null const programStage = programStageMetadata ?? programStageFromProgram - const programName = programMetadata?.name ?? '' - const stageName = programStage?.name ?? '' - - const itemDisplayNames = getItemDisplayNames({ - itemIds, - metadataItems, - formatStartEndDate, - }) return { - programName, - stageName, - itemDisplayNames, + programName: programMetadata?.name ?? '', + stageName: programStage?.name ?? '', } - }, [programId, programStageId, itemIds, formatStartEndDate, metadataItems]) - - return { - programName, - stageName, - itemDisplayNames, - } + }, [programId, programStageId, metadataItems]) } diff --git a/src/components/line-list/use-transformed-line-list-data.ts b/src/components/line-list/use-transformed-line-list-data.ts index ab8a614f4..d4e2de856 100644 --- a/src/components/line-list/use-transformed-line-list-data.ts +++ b/src/components/line-list/use-transformed-line-list-data.ts @@ -15,10 +15,10 @@ import { headersMap } from '@modules/analytics-request' import { formatBooleanValue, isBooleanValue } from '@modules/conditions' import { extractPlainDimensionId } from '@modules/dimension/ids' import { - buildSuffixContext, + getDimensionLabel, getDimensionSuffix, + getSuffixContext, } from '@modules/dimension/suffix' -import { resolveLayoutContext } from '@modules/layout' import { getStatusName, isStatus } from '@modules/status' import { isValueTypeNumeric } from '@modules/value-type' import type { @@ -141,7 +141,7 @@ export const getHeaderDisplayText = ( return '' } - const label = dimensionSuffix ? `${column} · ${dimensionSuffix}` : column + const label = getDimensionLabel(column, dimensionSuffix) if (isStageOffsetInteger(stageOffset)) { let repetitionSuffix @@ -205,17 +205,11 @@ export const transformHeaders = ({ const metadata = { ...analyticsData.metaDataItems, ...storeMetadata } /* Only the headers with dimension metadata in the store; other headers - * (e.g. value columns) have none and resolveLayoutContext would throw. */ - const { programIds, programStageIds } = resolveLayoutContext( + * (e.g. value columns) have none and getSuffixContext would throw. */ + const suffixContext = getSuffixContext( Object.keys(storeMetadata), metadataStore ) - const suffixContext = buildSuffixContext({ - programs: Object.values(metadataStore.getMetadataItems(programIds)), - programStages: Object.values( - metadataStore.getMetadataItems(programStageIds) - ), - }) return analyticsData.headers.map((header): TransformedLineListHeader => ({ ...header, diff --git a/src/hooks/__tests__/use-dimension-value-texts.spec.ts b/src/hooks/__tests__/use-dimension-value-texts.spec.ts new file mode 100644 index 000000000..508f33dfa --- /dev/null +++ b/src/hooks/__tests__/use-dimension-value-texts.spec.ts @@ -0,0 +1,41 @@ +import type { LayoutDimension } from '@components/layout-panel/axis/chip' +import { useAddMetadata, useDimensionValueTexts } from '@hooks' +import { renderHookWithAppWrapper } from '@test-utils/app-wrapper' +import { act } from '@testing-library/react' +import { describe, expect, it } from 'vitest' + +const orgUnit: LayoutDimension = { + id: 'stg.ou', + dimensionId: 'ou', + dimensionType: 'ORGANISATION_UNIT', + name: 'Org unit', +} +const itemIds = ['OU1', 'LEVEL-lvl1'] +const conditions = {} +const formatValueOptions = {} + +describe('useDimensionValueTexts', () => { + it('swaps in names as their metadata arrives', async () => { + const { result } = await renderHookWithAppWrapper(() => ({ + texts: useDimensionValueTexts({ + dimension: orgUnit, + itemIds, + conditions, + formatValueOptions, + }), + addMetadata: useAddMetadata(), + })) + + expect(result.current.texts).toEqual(['OU1', 'Levels: lvl1']) + + act(() => { + result.current.addMetadata({ uid: 'OU1', name: 'Sierra Leone' }) + result.current.addMetadata({ uid: 'lvl1', name: 'National' }) + }) + + expect(result.current.texts).toEqual([ + 'Sierra Leone', + 'Levels: National', + ]) + }) +}) diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 19e1bac19..b26a7cdc0 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -36,6 +36,7 @@ export { export * from './use-rtk-query' export * from './use-rtk-lazy-query' export * from './use-conditions-texts' +export * from './use-dimension-value-texts' export * from './use-options-field' export * from './use-stable-callback' export * from './use-layout-context' diff --git a/src/hooks/use-dimension-value-texts.ts b/src/hooks/use-dimension-value-texts.ts new file mode 100644 index 000000000..c9ced7a64 --- /dev/null +++ b/src/hooks/use-dimension-value-texts.ts @@ -0,0 +1,50 @@ +import type { LayoutDimension } from '@components/layout-panel/axis/chip' +import { useMetadataItems } from '@hooks' +import type { Conditions, FormatValueOptions } from '@modules/conditions' +import { + getDimensionValueMetadataIds, + getDimensionValueTexts, +} from '@modules/dimension/value-texts' +import { useLocalizedStartEndDateFormatter } from '@modules/utils/dates' +import { useMemo } from 'react' + +type UseDimensionValueTextsParams = { + dimension: LayoutDimension + itemIds: string[] + conditions: Conditions + formatValueOptions: FormatValueOptions +} + +export const useDimensionValueTexts = ({ + dimension, + itemIds, + conditions, + formatValueOptions, +}: UseDimensionValueTextsParams): string[] => { + const formatStartEndDate = useLocalizedStartEndDateFormatter() + const metadataIds = useMemo( + () => getDimensionValueMetadataIds({ dimension, itemIds, conditions }), + [dimension, itemIds, conditions] + ) + const metadataItems = useMetadataItems(metadataIds) + + return useMemo( + () => + getDimensionValueTexts({ + dimension, + itemIds, + conditions, + metadataItems, + formatValueOptions, + formatStartEndDate, + }), + [ + dimension, + itemIds, + conditions, + metadataItems, + formatValueOptions, + formatStartEndDate, + ] + ) +} diff --git a/src/modules/dimension/__tests__/item-names.spec.ts b/src/modules/dimension/__tests__/item-names.spec.ts new file mode 100644 index 000000000..4caac719d --- /dev/null +++ b/src/modules/dimension/__tests__/item-names.spec.ts @@ -0,0 +1,76 @@ +import { + getItemDisplayNames, + getItemMetadataIds, +} from '@modules/dimension/item-names' +import { getStartEndDateFormatter } from '@modules/utils/dates' +import type { MetadataItem } from '@types' +import { describe, expect, it } from 'vitest' + +const formatStartEndDate = getStartEndDateFormatter('en') + +const named = (names: Record<string, string>) => + Object.fromEntries( + Object.entries(names).map(([id, name]) => [ + id, + { id, name } as MetadataItem, + ]) + ) + +describe('getItemMetadataIds', () => { + it('strips level and group prefixes so the names can be looked up', () => { + expect( + getItemMetadataIds(['OU1', 'LEVEL-lvl1', 'OU_GROUP-grp1']) + ).toEqual(['OU1', 'lvl1', 'grp1']) + }) +}) + +describe('getItemDisplayNames', () => { + it('names items from the metadata', () => { + expect( + getItemDisplayNames({ + itemIds: ['OU1', 'OU2'], + metadataItems: named({ OU1: 'Sierra Leone', OU2: 'Bo' }), + formatStartEndDate, + }) + ).toEqual(['Sierra Leone', 'Bo']) + }) + + it('shows the id of an item whose name has not loaded yet', () => { + expect( + getItemDisplayNames({ + itemIds: ['OU1'], + metadataItems: {}, + formatStartEndDate, + }) + ).toEqual(['OU1']) + }) + + it('collapses levels and groups into one entry each, after the items', () => { + expect( + getItemDisplayNames({ + itemIds: ['LEVEL-lvl1', 'OU1', 'OU_GROUP-grp1', 'LEVEL-lvl2'], + metadataItems: named({ + OU1: 'Sierra Leone', + lvl1: 'National', + lvl2: 'District', + grp1: 'Group One', + }), + formatStartEndDate, + }) + ).toEqual([ + 'Sierra Leone', + 'Levels: National, District', + 'Groups: Group One', + ]) + }) + + it('formats a custom start and end date', () => { + expect( + getItemDisplayNames({ + itemIds: ['2023-01-01_2023-12-31'], + metadataItems: {}, + formatStartEndDate, + }) + ).toEqual(['January 1, 2023 - December 31, 2023']) + }) +}) diff --git a/src/modules/dimension/__tests__/value-texts.spec.ts b/src/modules/dimension/__tests__/value-texts.spec.ts new file mode 100644 index 000000000..8c192ca84 --- /dev/null +++ b/src/modules/dimension/__tests__/value-texts.spec.ts @@ -0,0 +1,82 @@ +import type { LayoutDimension } from '@components/layout-panel/axis/chip' +import { + getDimensionValueMetadataIds, + getDimensionValueTexts, +} from '@modules/dimension/value-texts' +import { getStartEndDateFormatter } from '@modules/utils/dates' +import type { MetadataItem } from '@types' +import { describe, expect, it } from 'vitest' + +const orgUnit: LayoutDimension = { + id: 'stg.ou', + dimensionId: 'ou', + dimensionType: 'ORGANISATION_UNIT', + name: 'Org unit', +} + +const age: LayoutDimension = { + id: 'stg.age', + dimensionId: 'age', + dimensionType: 'DATA_ELEMENT', + valueType: 'INTEGER', + name: 'Age', +} + +const textsFor = ( + dimension: LayoutDimension, + values: { itemIds?: string[]; condition?: string } +) => + getDimensionValueTexts({ + dimension, + itemIds: values.itemIds ?? [], + conditions: { condition: values.condition }, + metadataItems: { + OU1: { id: 'OU1', name: 'Sierra Leone' } as MetadataItem, + }, + formatValueOptions: {}, + formatStartEndDate: getStartEndDateFormatter('en'), + }) + +describe('getDimensionValueTexts', () => { + it('names the selected items of an item-based dimension', () => { + expect(textsFor(orgUnit, { itemIds: ['OU1'] })).toEqual([ + 'Sierra Leone', + ]) + }) + + it('ignores conditions on an item-based dimension', () => { + expect(textsFor(orgUnit, { condition: 'GT:20' })).toEqual([]) + }) + + it('describes the conditions of a value-based dimension', () => { + const [text] = textsFor(age, { condition: 'GT:20' }) + + expect(text).toContain('20') + }) + + it('ignores items on a value-based dimension', () => { + expect(textsFor(age, { itemIds: ['OU1'] })).toEqual([]) + }) +}) + +describe('getDimensionValueMetadataIds', () => { + it('asks for the item names of an item-based dimension', () => { + expect( + getDimensionValueMetadataIds({ + dimension: orgUnit, + itemIds: ['OU1', 'LEVEL-lvl1'], + conditions: {}, + }) + ).toEqual(['OU1', 'lvl1']) + }) + + it('asks for the legend names of a grouped value-based dimension', () => { + expect( + getDimensionValueMetadataIds({ + dimension: age, + itemIds: [], + conditions: { legendSet: 'LS', condition: 'IN:L1;L2' }, + }) + ).toEqual(['L1', 'L2']) + }) +}) diff --git a/src/modules/dimension/dimension-type.ts b/src/modules/dimension/dimension-type.ts index 8f8b800a7..64f401d71 100644 --- a/src/modules/dimension/dimension-type.ts +++ b/src/modules/dimension/dimension-type.ts @@ -20,3 +20,17 @@ export const getUiDimensionType = ( return dimensionType } } + +const ITEM_BASED_DIMENSION_TYPES: ReadonlySet<DimensionType> = new Set([ + 'CATEGORY', + 'CATEGORY_OPTION_GROUP_SET', + 'ORGANISATION_UNIT_GROUP_SET', + 'STATUS', + 'PERIOD', + 'ORGANISATION_UNIT', +]) + +/* Item-based dimensions are narrowed by selecting items; the rest by value + * conditions. */ +export const isItemBasedDimensionType = (dimensionType: DimensionType) => + ITEM_BASED_DIMENSION_TYPES.has(dimensionType) diff --git a/src/modules/dimension/suffix.ts b/src/modules/dimension/suffix.ts index 1d61891cd..49a9055ae 100644 --- a/src/modules/dimension/suffix.ts +++ b/src/modules/dimension/suffix.ts @@ -1,4 +1,5 @@ -import type { DimensionMetadataItem, MetadataItem } from '@types' +import { resolveLayoutContext } from '@modules/layout' +import type { DimensionMetadataItem, MetadataItem, MetadataStore } from '@types' export type SuffixContext = { programCount: number @@ -87,3 +88,24 @@ export const getDimensionSuffix = ( } return undefined } + +/* Pure counterpart of the layout panel's suffix hook, for callers that read the + * metadata store directly. Throws if a dimension has no metadata. */ +export const getSuffixContext = ( + dimensionIds: string[], + metadataStore: MetadataStore +): SuffixContext => { + const { programIds, programStageIds } = resolveLayoutContext( + dimensionIds, + metadataStore + ) + return buildSuffixContext({ + programs: Object.values(metadataStore.getMetadataItems(programIds)), + programStages: Object.values( + metadataStore.getMetadataItems(programStageIds) + ), + }) +} + +export const getDimensionLabel = (name: string, suffix?: string): string => + suffix ? `${name} · ${suffix}` : name diff --git a/src/modules/dimension/value-texts.ts b/src/modules/dimension/value-texts.ts new file mode 100644 index 000000000..c046f79a3 --- /dev/null +++ b/src/modules/dimension/value-texts.ts @@ -0,0 +1,49 @@ +import type { LayoutDimension } from '@components/layout-panel/axis/chip' +import { + getConditionsMetadataIds, + getConditionsTexts, + type Conditions, + type FormatValueOptions, +} from '@modules/conditions' +import { isItemBasedDimensionType } from '@modules/dimension/dimension-type' +import { + getItemDisplayNames, + getItemMetadataIds, +} from '@modules/dimension/item-names' +import type { MetadataItem } from '@types' + +type DimensionValues = { + dimension: LayoutDimension + itemIds: string[] + conditions: Conditions +} + +export const getDimensionValueMetadataIds = ({ + dimension, + itemIds, + conditions, +}: DimensionValues): string[] => + isItemBasedDimensionType(dimension.dimensionType) + ? getItemMetadataIds(itemIds) + : getConditionsMetadataIds({ conditions, dimension }) + +export const getDimensionValueTexts = ({ + dimension, + itemIds, + conditions, + metadataItems, + formatValueOptions, + formatStartEndDate, +}: DimensionValues & { + metadataItems: Record<string, MetadataItem | undefined> + formatValueOptions: FormatValueOptions + formatStartEndDate: (startEndDate: string) => string +}): string[] => + isItemBasedDimensionType(dimension.dimensionType) + ? getItemDisplayNames({ itemIds, metadataItems, formatStartEndDate }) + : getConditionsTexts({ + conditions, + dimension, + formatValueOptions, + metadataItems, + }) From 49ef96194cf7cc7ee81eff991807bd9a8b6062ab Mon Sep 17 00:00:00 2001 From: Hendrik de Graaf <hendrik@dhis2.org> Date: Wed, 30 Sep 2026 17:19:39 +0200 Subject: [PATCH 31/33] fix: build the filter line from the same input as the layout panel Deriving layout, items and conditions with getVisualizationUiConfig applies the enrollment org unit rename, so enrollment org unit filters are named. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../__tests__/filter-text.spec.ts | 103 +++++++++- src/modules/visualization/filter-text.ts | 188 ++++-------------- 2 files changed, 138 insertions(+), 153 deletions(-) diff --git a/src/modules/visualization/__tests__/filter-text.spec.ts b/src/modules/visualization/__tests__/filter-text.spec.ts index 50e249a96..de60fe52d 100644 --- a/src/modules/visualization/__tests__/filter-text.spec.ts +++ b/src/modules/visualization/__tests__/filter-text.spec.ts @@ -152,16 +152,16 @@ describe('getVisualizationFilterText', () => { programId: PROGRAM_ID, programStageId: STAGE_ID, }, - [`${STAGE_ID}.de1`]: { - id: `${STAGE_ID}.de1`, - dimensionId: 'de1', - name: 'Gender', - dimensionType: 'DATA_ELEMENT', + [`${STAGE_ID}.eventDate`]: { + id: `${STAGE_ID}.eventDate`, + dimensionId: 'eventDate', + name: 'Report date', + dimensionType: 'PERIOD', programId: PROGRAM_ID, programStageId: STAGE_ID, }, ImspTQPwCqd: { id: 'ImspTQPwCqd', name: 'Sierra Leone' }, - Mnp3oXrpAbK: { id: 'Mnp3oXrpAbK', name: 'Female' }, + THIS_YEAR: { id: 'THIS_YEAR', name: 'This year' }, }) const vis = buildVis([ { @@ -171,10 +171,10 @@ describe('getVisualizationFilterText', () => { items: [{ id: 'ImspTQPwCqd' }], }, { - dimension: 'de1', + dimension: 'eventDate', programStage: { id: STAGE_ID }, program: { id: PROGRAM_ID }, - items: [{ id: 'Mnp3oXrpAbK' }], + items: [{ id: 'THIS_YEAR' }], }, ] as CurrentVisualization['filters']) @@ -183,7 +183,92 @@ describe('getVisualizationFilterText', () => { visualization: vis, metadataStore: store, }) - ).toBe('Org unit: Sierra Leone - Gender: Female') + ).toBe('Org unit: Sierra Leone - Report date: This year') + }) + + it('names an enrollment org unit filter', () => { + const store = buildStore({ + [`${PROGRAM_ID}.enrollmentOu`]: { + id: `${PROGRAM_ID}.enrollmentOu`, + dimensionId: 'enrollmentOu', + name: 'Enrollment org unit', + dimensionType: 'ORGANISATION_UNIT', + programId: PROGRAM_ID, + }, + ImspTQPwCqd: { id: 'ImspTQPwCqd', name: 'Sierra Leone' }, + }) + const vis = buildVis( + [ + { + dimension: 'ou', + program: { id: PROGRAM_ID }, + items: [{ id: 'ImspTQPwCqd' }], + }, + ] as CurrentVisualization['filters'], + { outputType: 'ENROLLMENT' } + ) + + expect( + getVisualizationFilterText({ + visualization: vis, + metadataStore: store, + }) + ).toBe('Enrollment org unit: Sierra Leone') + }) + + it('adds the stage name when the layout spans several stages', () => { + const store = buildStore({ + stg2: { + id: 'stg2', + name: 'Stage two', + repeatable: false, + hideDueDate: false, + program: { id: PROGRAM_ID }, + }, + [`${STAGE_ID}.ou`]: { + id: `${STAGE_ID}.ou`, + dimensionId: 'ou', + name: 'Org unit', + dimensionType: 'ORGANISATION_UNIT', + programId: PROGRAM_ID, + programStageId: STAGE_ID, + }, + 'stg2.eventDate': { + id: 'stg2.eventDate', + dimensionId: 'eventDate', + name: 'Report date', + dimensionType: 'PERIOD', + programId: PROGRAM_ID, + programStageId: 'stg2', + }, + ImspTQPwCqd: { id: 'ImspTQPwCqd', name: 'Sierra Leone' }, + }) + const vis = buildVis( + [ + { + dimension: 'ou', + programStage: { id: STAGE_ID }, + program: { id: PROGRAM_ID }, + items: [{ id: 'ImspTQPwCqd' }], + }, + ] as CurrentVisualization['filters'], + { + columns: [ + { + dimension: 'eventDate', + programStage: { id: 'stg2' }, + program: { id: PROGRAM_ID }, + }, + ], + } as Partial<CurrentVisualization> + ) + + expect( + getVisualizationFilterText({ + visualization: vis, + metadataStore: store, + }) + ).toBe('Org unit · Stage one: Sierra Leone') }) it('skips a filter with neither items nor conditions', () => { diff --git a/src/modules/visualization/filter-text.ts b/src/modules/visualization/filter-text.ts index 4848b9dce..d504bdc2c 100644 --- a/src/modules/visualization/filter-text.ts +++ b/src/modules/visualization/filter-text.ts @@ -1,115 +1,19 @@ -import { - getConditionsMetadataIds, - getConditionsTexts, - type Conditions, -} from '@modules/conditions' -import { getCompoundDimensionId } from '@modules/dimension/ids' -import { - getItemDisplayNames, - getItemMetadataIds, -} from '@modules/dimension/item-names' import { toLayoutDimension } from '@modules/dimension/layout-dimension' +import { getDimensionLabel, getSuffixContext } from '@modules/dimension/suffix' import { - buildSuffixContext, - type SuffixContext, -} from '@modules/dimension/suffix' -import { combineAllDimensionsFromVisualization } from '@modules/dimension/translation' + getDimensionValueMetadataIds, + getDimensionValueTexts, +} from '@modules/dimension/value-texts' import { getStartEndDateFormatter } from '@modules/utils/dates' -import type { - CurrentVisualization, - DimensionRecord, - MetadataStore, -} from '@types' +import { getVisualizationUiConfig } from '@modules/visualization/ui-config' +import type { CurrentVisualization, MetadataStore } from '@types' // Aligned with the separator the pivot table engine produces const FRAGMENT_SEPARATOR = ' - ' -/* Suffixes disambiguate a dimension against the rest of the layout, so the - * context is built from every axis rather than the filters alone. */ -const getSuffixContext = ( - visualization: CurrentVisualization, - metadataStore: MetadataStore -): SuffixContext => { - const programIds = new Set<string>() - const programStageIds = new Set<string>() - - for (const dimension of combineAllDimensionsFromVisualization( - visualization - )) { - const item = metadataStore.getDimensionMetadataItemOrThrow( - getCompoundDimensionId( - dimension, - visualization.outputType, - visualization.trackedEntityType?.id - ) - ) - if (item.programId) { - programIds.add(item.programId) - } - if (item.programStageId) { - programStageIds.add(item.programStageId) - } - } - - return buildSuffixContext({ - programs: Object.values( - metadataStore.getMetadataItems(Array.from(programIds)) - ), - programStages: Object.values( - metadataStore.getMetadataItems(Array.from(programStageIds)) - ), - }) -} - -const getFilterValueTexts = ({ - dimension, - conditions, - layoutDimension, - metadataStore, - locale, - digitGroupSeparator, -}: { - dimension: DimensionRecord - conditions: Conditions - layoutDimension: ReturnType<typeof toLayoutDimension> - metadataStore: MetadataStore - locale?: string - digitGroupSeparator: CurrentVisualization['digitGroupSeparator'] -}): string[] => { - if (conditions.condition || conditions.legendSet) { - return getConditionsTexts({ - conditions, - dimension: layoutDimension, - formatValueOptions: { digitGroupSeparator }, - metadataItems: metadataStore.getMetadataItems( - getConditionsMetadataIds({ - conditions, - dimension: layoutDimension, - }) - ), - }) - } - - const itemIds = (dimension.items ?? []) - .map((item) => item.id) - .filter((id): id is string => Boolean(id)) - if (!itemIds.length) { - return [] - } - - return getItemDisplayNames({ - itemIds, - metadataItems: metadataStore.getMetadataItems( - getItemMetadataIds(itemIds) - ), - formatStartEndDate: getStartEndDateFormatter(locale), - }) -} - /** - * The text of the filter line shown beneath the title and subtitle. Both - * visualisation types render it from this one function so they cannot drift: - * the line list draws its own row, the pivot table takes it as `filterText`. + * The text of the filter line shown beneath the title and subtitle, for both + * the line list and the pivot table. * * `locale` is only used to format custom start/end dates, and falls back to * the runtime default when the user's locale has not resolved yet. @@ -123,50 +27,46 @@ export const getVisualizationFilterText = ({ metadataStore: MetadataStore locale?: string }): string => { - const filters = visualization.filters ?? [] - if (!filters.length) { + const { layout, itemsByDimension, conditionsByDimension } = + getVisualizationUiConfig(visualization) + if (!layout.filters.length) { return '' } - const suffixContext = getSuffixContext(visualization, metadataStore) - const fragments: string[] = [] + const suffixContext = getSuffixContext( + [...layout.columns, ...layout.rows, ...layout.filters], + metadataStore + ) + const formatStartEndDate = getStartEndDateFormatter(locale) - for (const dimension of filters) { - const compoundId = getCompoundDimensionId( - dimension, - visualization.outputType, - visualization.trackedEntityType?.id - ) - const metadataItem = - metadataStore.getDimensionMetadataItemOrThrow(compoundId) + return layout.filters + .flatMap((dimensionId) => { + const dimension = toLayoutDimension( + dimensionId, + metadataStore.getDimensionMetadataItemOrThrow(dimensionId), + suffixContext + ) + const values = { + dimension, + itemIds: itemsByDimension[dimensionId] ?? [], + conditions: conditionsByDimension[dimensionId] ?? {}, + } + const valueTexts = getDimensionValueTexts({ + ...values, + metadataItems: metadataStore.getMetadataItems( + getDimensionValueMetadataIds(values) + ), + formatValueOptions: { + digitGroupSeparator: visualization.digitGroupSeparator, + }, + formatStartEndDate, + }) - const layoutDimension = toLayoutDimension( - compoundId, - metadataItem, - suffixContext - ) - const valueTexts = getFilterValueTexts({ - dimension, - conditions: { - condition: dimension.filter, - legendSet: dimension.legendSet?.id, - }, - layoutDimension, - metadataStore, - locale, - digitGroupSeparator: visualization.digitGroupSeparator, + return valueTexts.length + ? [ + `${getDimensionLabel(dimension.name, dimension.suffix)}: ${valueTexts.join(', ')}`, + ] + : [] }) - - if (!valueTexts.length) { - continue - } - - const label = layoutDimension.suffix - ? `${layoutDimension.name} · ${layoutDimension.suffix}` - : layoutDimension.name - - fragments.push(`${label}: ${valueTexts.join(', ')}`) - } - - return fragments.join(FRAGMENT_SEPARATOR) + .join(FRAGMENT_SEPARATOR) } From 62a38df7310536e14e62aac33f9842b6c88d4fc1 Mon Sep 17 00:00:00 2001 From: Hendrik de Graaf <hendrik@dhis2.org> Date: Thu, 1 Oct 2026 13:43:24 +0200 Subject: [PATCH 32/33] feat: keep hideSubtitle in step with the subtitle Legacy visualizations infer it from the subtitle on load, alongside hideTitle, and this app now saves it as hidden whenever the subtitle is empty, since there is no generated subtitle. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../__tests__/unapplied-changes.spec.ts | 1 + .../__tests__/visualization.spec.ts | 39 +++++++++++++++++++ src/modules/visualization/current-vis.ts | 3 ++ src/modules/visualization/normalize-legacy.ts | 6 ++- 4 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/modules/visualization/__tests__/unapplied-changes.spec.ts b/src/modules/visualization/__tests__/unapplied-changes.spec.ts index 1908fbeaf..84b96215d 100644 --- a/src/modules/visualization/__tests__/unapplied-changes.spec.ts +++ b/src/modules/visualization/__tests__/unapplied-changes.spec.ts @@ -39,6 +39,7 @@ const baseCurrentVis = { columns: [], rows: [], filters: [], + hideSubtitle: true, } as unknown as CurrentVisualization /* Compose the two functions exactly as useHasUnappliedChanges does, so these diff --git a/src/modules/visualization/__tests__/visualization.spec.ts b/src/modules/visualization/__tests__/visualization.spec.ts index 49293d25c..6a7deb3f3 100644 --- a/src/modules/visualization/__tests__/visualization.spec.ts +++ b/src/modules/visualization/__tests__/visualization.spec.ts @@ -686,6 +686,30 @@ describe('normalizeApiSavedVisualization', () => { expect(result.hideTitle).toBe(true) }) + it('hides the subtitle of a legacy visualization that has none', () => { + const result = normalizeApiSavedVisualization( + buildApiVis({ legacy: true, hideSubtitle: false }) + ) + + expect(result.hideSubtitle).toBe(true) + }) + + it('shows the subtitle of a legacy visualization that has one', () => { + const result = normalizeApiSavedVisualization( + buildApiVis({ legacy: true, hideSubtitle: true, subtitle: 'Kept' }) + ) + + expect(result.hideSubtitle).toBe(false) + }) + + it('honours the stored hideSubtitle on a non-legacy visualization', () => { + const result = normalizeApiSavedVisualization( + buildApiVis({ hideSubtitle: false }) + ) + + expect(result.hideSubtitle).toBe(false) + }) + it('honours the stored hideTitle on a non-legacy visualization', () => { const result = normalizeApiSavedVisualization( buildApiVis({ hideTitle: false }) @@ -809,6 +833,7 @@ describe('getVisualizationState treats default-valued options as unchanged', () const apiDefaultOptions: Partial<EventVisualizationOptions> = { ...DEFAULT_OPTIONS, digitGroupSeparator: 'SPACE', + hideSubtitle: true, } const buildApiVis = ( @@ -878,6 +903,20 @@ describe('getVisualizationState treats default-valued options as unchanged', () expect(getVisualizationState(savedVis, currentVis)).toBe('SAVED') }) + it('hides an empty subtitle when rebuilding', () => { + const savedVis = normalizeApiSavedVisualization(buildApiVis()) + + expect(rebuildCurrentVis(savedVis).hideSubtitle).toBe(true) + }) + + it('shows a populated subtitle when rebuilding', () => { + const savedVis = normalizeApiSavedVisualization( + buildApiVis({ ...apiDefaultOptions, subtitle: 'Shown' }) + ) + + expect(rebuildCurrentVis(savedVis).hideSubtitle).toBe(false) + }) + it('is DIRTY when a real option changes', () => { const savedVis = normalizeApiSavedVisualization(buildApiVis()) diff --git a/src/modules/visualization/current-vis.ts b/src/modules/visualization/current-vis.ts index 5b40af09a..7d3f0c4cb 100644 --- a/src/modules/visualization/current-vis.ts +++ b/src/modules/visualization/current-vis.ts @@ -5,6 +5,7 @@ import { resolveTeiFields, } from '@modules/layout' import { getEnabledOptions } from '@modules/options' +import { isPopulatedString } from '@modules/utils/guards' import type { CurrentVisState } from '@store/current-vis-slice' import type { VisUiConfigState } from '@store/vis-ui-config-slice' import type { @@ -132,6 +133,8 @@ export const buildCurrentVisFromVisUiConfig = ({ filters: buildAxis(visUiConfig.layout.filters, visUiConfig, metadataStore), programDimensions: collectProgramDimensions(visUiConfig, metadataStore), ...getEnabledOptions(visUiConfig.options), + // There is no generated subtitle, so an empty one is a hidden one + hideSubtitle: !isPopulatedString(visUiConfig.options.subtitle), ...resolveTeiFields(visUiConfig, metadataStore), ...resolveCustomValueFields( previousCurrentVis, diff --git a/src/modules/visualization/normalize-legacy.ts b/src/modules/visualization/normalize-legacy.ts index 9af0634d1..0530a18c9 100644 --- a/src/modules/visualization/normalize-legacy.ts +++ b/src/modules/visualization/normalize-legacy.ts @@ -306,10 +306,12 @@ export const normalizeApiSavedVisualization = ( normalizedVis.topLimit = topLimit } - /* The apps that produced legacy visualizations never generated a title, so - * an empty title there means "no title" rather than "auto generated". */ + /* Legacy visualizations don't set the hide flags reliably, so they are + * inferred from the text. The apps that produced them never generated a + * title, so an empty title means "no title" rather than "auto generated". */ if (normalizedVis.legacy) { normalizedVis.hideTitle = !isPopulatedString(normalizedVis.title) + normalizedVis.hideSubtitle = !isPopulatedString(normalizedVis.subtitle) } return normalizedVis From 333fa46e28981d8e1ea9f669b9202cfad6ebf228 Mon Sep 17 00:00:00 2001 From: Hendrik de Graaf <hendrik@dhis2.org> Date: Thu, 1 Oct 2026 16:14:51 +0200 Subject: [PATCH 33/33] fix: mark fire-and-forget promises explicitly Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- src/components/plugin-wrapper/line-list-plugin.tsx | 4 ++-- src/components/plugin-wrapper/pivot-table-plugin.tsx | 2 +- src/store/listeners.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/plugin-wrapper/line-list-plugin.tsx b/src/components/plugin-wrapper/line-list-plugin.tsx index 294bf5431..ddc6afa63 100644 --- a/src/components/plugin-wrapper/line-list-plugin.tsx +++ b/src/components/plugin-wrapper/line-list-plugin.tsx @@ -62,7 +62,7 @@ export const LineListPlugin: FC<LineListPluginProps> = ({ const onPaginate = useCallback<PaginateFn>( ({ page, pageSize }) => { - fetchAnalyticsData({ + void fetchAnalyticsData({ visualization: transformVisualizationForAnalyticsRequest( eventVisualization @@ -103,7 +103,7 @@ export const LineListPlugin: FC<LineListPluginProps> = ({ ) useEffect(() => { - fetchAnalyticsData({ + void fetchAnalyticsData({ visualization: transformVisualizationForAnalyticsRequest(eventVisualization), relativePeriodDate, diff --git a/src/components/plugin-wrapper/pivot-table-plugin.tsx b/src/components/plugin-wrapper/pivot-table-plugin.tsx index cb15a9ed5..802f48d94 100644 --- a/src/components/plugin-wrapper/pivot-table-plugin.tsx +++ b/src/components/plugin-wrapper/pivot-table-plugin.tsx @@ -66,7 +66,7 @@ export const PivotTablePlugin: FC<PivotTablePluginProps> = ({ // TODO: implement onDataSorted and any other function/callback that cannot rely on the Redux store useEffect(() => { - fetchAnalyticsData({ + void fetchAnalyticsData({ visualization: transformVisualizationForAnalyticsRequest(visualization), relativePeriodDate, diff --git a/src/store/listeners.ts b/src/store/listeners.ts index e70525ed8..0ec2bc7c8 100644 --- a/src/store/listeners.ts +++ b/src/store/listeners.ts @@ -18,7 +18,7 @@ import { export const registerAppListeners = () => { startAppListening({ actionCreator: setNavigationState, - effect: async (action, { dispatch, getOriginalState }) => { + effect: (action, { dispatch, getOriginalState }) => { const originalState = getOriginalState() const originalVisualizationId = originalState.navigation.visualizationId