From e78b485bbc041d2154d0737f5057ac0c01b16415 Mon Sep 17 00:00:00 2001 From: Edoardo Sabadelli Date: Wed, 2 Sep 2026 15:44:48 +0200 Subject: [PATCH 01/12] feat: implement row granularity label for LL --- i18n/en.pot | 7 +- .../layout-panel/bottom-bar/bottom-bar.tsx | 4 + .../__tests__/row-granularity-label.spec.tsx | 122 ++++++++++++++++++ .../row-granularity-label/icon-table-rows.tsx | 19 +++ .../row-granularity-label.tsx | 21 +++ .../styles/row-granularity-label.module.css | 16 +++ src/hooks/index.ts | 1 + src/hooks/use-output-type-label.ts | 28 ++++ 8 files changed, 216 insertions(+), 2 deletions(-) create mode 100644 src/components/layout-panel/bottom-bar/row-granularity-label/__tests__/row-granularity-label.spec.tsx create mode 100644 src/components/layout-panel/bottom-bar/row-granularity-label/icon-table-rows.tsx create mode 100644 src/components/layout-panel/bottom-bar/row-granularity-label/row-granularity-label.tsx create mode 100644 src/components/layout-panel/bottom-bar/row-granularity-label/styles/row-granularity-label.module.css create mode 100644 src/hooks/use-output-type-label.ts diff --git a/i18n/en.pot b/i18n/en.pot index 4bfea020..b37d7db8 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-08-28T12:57:22.711Z\n" -"PO-Revision-Date: 2026-08-28T12:57:22.717Z\n" +"POT-Creation-Date: 2026-09-02T13:36:59.215Z\n" +"PO-Revision-Date: 2026-09-02T13:36:59.215Z\n" msgid "" "Some dimensions were not added because they cannot be used in a " @@ -343,6 +343,9 @@ msgstr "Not valid with program indicators" msgid "Nothing selected. Add items to the layout to get started." msgstr "Nothing selected. Add items to the layout to get started." +msgid "One row for each {{- outputTypeLabel}}" +msgstr "One row for each {{- outputTypeLabel}}" + msgid "Configure custom value" msgstr "Configure custom value" diff --git a/src/components/layout-panel/bottom-bar/bottom-bar.tsx b/src/components/layout-panel/bottom-bar/bottom-bar.tsx index 6c08d842..5f658121 100644 --- a/src/components/layout-panel/bottom-bar/bottom-bar.tsx +++ b/src/components/layout-panel/bottom-bar/bottom-bar.tsx @@ -8,6 +8,7 @@ import { CustomValueButton } from './action-buttons/custom-value-button' import { EnrollmentButton } from './action-buttons/enrollment-button' import { EventButton } from './action-buttons/event-button' import { TrackedEntityInstanceButton } from './action-buttons/tracked-entity-instance-button' +import { RowGranularityLabel } from './row-granularity-label/row-granularity-label' import classes from './styles/bottom-bar.module.css' export const BottomBar: FC = () => { @@ -35,6 +36,9 @@ export const BottomBar: FC = () => { + {visualizationType === 'LINE_LIST' && ( + + )} )} diff --git a/src/components/layout-panel/bottom-bar/row-granularity-label/__tests__/row-granularity-label.spec.tsx b/src/components/layout-panel/bottom-bar/row-granularity-label/__tests__/row-granularity-label.spec.tsx new file mode 100644 index 00000000..3dfab42d --- /dev/null +++ b/src/components/layout-panel/bottom-bar/row-granularity-label/__tests__/row-granularity-label.spec.tsx @@ -0,0 +1,122 @@ +import { initialState as visUiConfigInitialState } from '@store/vis-ui-config-slice' +import { renderWithAppWrapper } from '@test-utils/app-wrapper' +import { screen } from '@testing-library/react' +import type { OutputType, RootState } from '@types' +import { describe, it, expect } from 'vitest' +import { RowGranularityLabel } from '../row-granularity-label' + +const trackerStage = { + id: 's1', + name: 'Stage 1', + repeatable: false, + hideDueDate: false, + program: { id: 'p1' }, +} + +const trackerProgram = { + id: 'p1', + name: 'Program 1', + programType: 'WITH_REGISTRATION', + programStages: [trackerStage], + trackedEntityType: { id: 'tet1', name: 'Person' }, + displayEventLabel: 'Visit', + displayEnrollmentLabel: 'Registration', +} + +const eventProgram = { + id: 'p2', + name: 'Program 2', + programType: 'WITHOUT_REGISTRATION', + programStages: [{ ...trackerStage, id: 's2', program: { id: 'p2' } }], +} + +const metadata = { + p1: trackerProgram, + s1: trackerStage, + tet1: { id: 'tet1', name: 'Person' }, + p2: eventProgram, + 's2.de1': { + id: 's2.de1', + name: 'DE 1', + dimensionType: 'DATA_ELEMENT', + valueType: 'NUMBER', + programId: 'p2', + programStageId: 's2', + }, + 'tet1.enrollmentOu': { + id: 'tet1.enrollmentOu', + name: 'Registration org. unit', + dimensionType: 'ORGANISATION_UNIT', + trackedEntityTypeId: 'tet1', + }, + 's1.de1': { + id: 's1.de1', + name: 'DE 1', + dimensionType: 'DATA_ELEMENT', + valueType: 'NUMBER', + programId: 'p1', + programStageId: 's1', + }, +} + +const buildMockOptions = (outputType: OutputType, columns: string[]) => ({ + metadata, + partialStore: { + preloadedState: { + visUiConfig: { + ...visUiConfigInitialState, + outputType, + layout: { ...visUiConfigInitialState.layout, columns }, + }, + } as Partial, + }, +}) + +describe('RowGranularityLabel', () => { + it('names the tracked entity type for TRACKED_ENTITY_INSTANCE output', async () => { + await renderWithAppWrapper( + , + buildMockOptions('TRACKED_ENTITY_INSTANCE', ['tet1.enrollmentOu']) + ) + + expect(screen.getByText('One row for each Person')).toBeInTheDocument() + }) + + it("uses the program's enrollment label for ENROLLMENT output", async () => { + await renderWithAppWrapper( + , + buildMockOptions('ENROLLMENT', ['s1.de1']) + ) + + expect( + screen.getByText('One row for each Registration') + ).toBeInTheDocument() + }) + + it("uses the program's event label for EVENT output", async () => { + await renderWithAppWrapper( + , + buildMockOptions('EVENT', ['s1.de1']) + ) + + expect(screen.getByText('One row for each Visit')).toBeInTheDocument() + }) + + it('falls back to a generic noun for a program without custom labels', async () => { + await renderWithAppWrapper( + , + buildMockOptions('EVENT', ['s2.de1']) + ) + + expect(screen.getByText('One row for each Event')).toBeInTheDocument() + }) + + it('renders no button, so it cannot be clicked', async () => { + await renderWithAppWrapper( + , + buildMockOptions('EVENT', ['s1.de1']) + ) + + expect(screen.queryByRole('button')).not.toBeInTheDocument() + }) +}) diff --git a/src/components/layout-panel/bottom-bar/row-granularity-label/icon-table-rows.tsx b/src/components/layout-panel/bottom-bar/row-granularity-label/icon-table-rows.tsx new file mode 100644 index 00000000..0c1ff962 --- /dev/null +++ b/src/components/layout-panel/bottom-bar/row-granularity-label/icon-table-rows.tsx @@ -0,0 +1,19 @@ +import type { FC } from 'react' + +export const IconTableRows: FC = () => ( + +) diff --git a/src/components/layout-panel/bottom-bar/row-granularity-label/row-granularity-label.tsx b/src/components/layout-panel/bottom-bar/row-granularity-label/row-granularity-label.tsx new file mode 100644 index 00000000..b4bbbe52 --- /dev/null +++ b/src/components/layout-panel/bottom-bar/row-granularity-label/row-granularity-label.tsx @@ -0,0 +1,21 @@ +import i18n from '@dhis2/d2-i18n' +import { useAppSelector, useOutputTypeLabel } from '@hooks' +import { getVisUiConfigOutputType } from '@store/vis-ui-config-slice' +import { type FC } from 'react' +import { IconTableRows } from './icon-table-rows' +import classes from './styles/row-granularity-label.module.css' + +export const RowGranularityLabel: FC = () => { + const outputType = useAppSelector(getVisUiConfigOutputType) + const outputTypeLabel = useOutputTypeLabel(outputType) + + return ( + + + {i18n.t('One row for each {{- outputTypeLabel}}', { + outputTypeLabel, + nsSeparator: '^^', + })} + + ) +} diff --git a/src/components/layout-panel/bottom-bar/row-granularity-label/styles/row-granularity-label.module.css b/src/components/layout-panel/bottom-bar/row-granularity-label/styles/row-granularity-label.module.css new file mode 100644 index 00000000..29afec05 --- /dev/null +++ b/src/components/layout-panel/bottom-bar/row-granularity-label/styles/row-granularity-label.module.css @@ -0,0 +1,16 @@ +.label { + display: flex; + align-items: center; + gap: var(--spacers-dp4); + padding-block: 6px; + padding-inline: var(--spacers-dp8) 8px; + border-inline-start: 1px solid var(--colors-grey300); + color: var(--colors-grey700); + font-size: 13px; + line-height: 1; + user-select: none; +} + +.label svg { + color: var(--colors-grey500); +} diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 19e1bac1..8dddfda6 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -39,6 +39,7 @@ export * from './use-conditions-texts' export * from './use-options-field' export * from './use-stable-callback' export * from './use-layout-context' +export * from './use-output-type-label' export * from './use-cross-tet-mismatch' export * from './use-dimension-layout-blocked-message' export * from './intl' diff --git a/src/hooks/use-output-type-label.ts b/src/hooks/use-output-type-label.ts new file mode 100644 index 00000000..81aaa084 --- /dev/null +++ b/src/hooks/use-output-type-label.ts @@ -0,0 +1,28 @@ +import { useProgramMetadataItem } from '@components/app-wrapper/metadata-provider/metadata-provider' +import i18n from '@dhis2/d2-i18n' +import { useLayoutContext, useMetadataItem } from '@hooks' +import { isDataSourceProgramWithRegistration } from '@modules/data-source' +import type { OutputType } from '@types' + +/* The noun an output type's records are called, as configured on the program + * (or the tracked entity type's own name), falling back to a generic term. */ +export const useOutputTypeLabel = (outputType: OutputType): string => { + const { programIds, tetId } = useLayoutContext() + const programMetadata = useProgramMetadataItem(programIds[0]) + const tetMetadata = useMetadataItem(tetId) + + switch (outputType) { + case 'EVENT': + return isDataSourceProgramWithRegistration(programMetadata) && + programMetadata.displayEventLabel + ? programMetadata.displayEventLabel + : i18n.t('Event') + case 'ENROLLMENT': + return isDataSourceProgramWithRegistration(programMetadata) && + programMetadata.displayEnrollmentLabel + ? programMetadata.displayEnrollmentLabel + : i18n.t('Enrollment') + case 'TRACKED_ENTITY_INSTANCE': + return tetMetadata?.name ?? i18n.t('tracked entity') + } +} From 86b999603130d8819cef2ef903249f6621d42b25 Mon Sep 17 00:00:00 2001 From: Edoardo Sabadelli Date: Wed, 2 Sep 2026 15:48:51 +0200 Subject: [PATCH 02/12] refactor: use hook for update button labels --- i18n/en.pot | 22 +++++++++---------- .../action-buttons/enrollment-button.tsx | 18 ++------------- .../action-buttons/event-button.tsx | 18 ++------------- .../tracked-entity-instance-button.tsx | 10 ++------- 4 files changed, 17 insertions(+), 51 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index b37d7db8..0569623f 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-02T13:36:59.215Z\n" -"PO-Revision-Date: 2026-09-02T13:36:59.215Z\n" +"POT-Creation-Date: 2026-09-02T13:48:53.069Z\n" +"PO-Revision-Date: 2026-09-02T13:48:53.069Z\n" msgid "" "Some dimensions were not added because they cannot be used in a " @@ -253,9 +253,6 @@ msgstr "Switch to custom value table" msgid "Update custom value table" msgstr "Update custom value table" -msgid "Enrollment" -msgstr "Enrollment" - msgid "Create {{- enrollmentLabel}} list" msgstr "Create {{- enrollmentLabel}} list" @@ -274,9 +271,6 @@ msgstr "Update {{- enrollmentLabel}} list" msgid "Update {{- enrollmentLabel}} table" msgstr "Update {{- enrollmentLabel}} table" -msgid "Event" -msgstr "Event" - msgid "Create {{- eventLabel}} list" msgstr "Create {{- eventLabel}} list" @@ -295,9 +289,6 @@ msgstr "Update {{- eventLabel}} list" msgid "Update {{- eventLabel}} table" msgstr "Update {{- eventLabel}} table" -msgid "tracked entity" -msgstr "tracked entity" - msgid "Create {{- trackedEntityTypeName}} list" msgstr "Create {{- trackedEntityTypeName}} list" @@ -648,6 +639,9 @@ msgstr "Program indicators" msgid "{{- name}} registration" msgstr "{{- name}} registration" +msgid "Event" +msgstr "Event" + msgid "Choose a data source" msgstr "Choose a data source" @@ -908,6 +902,12 @@ msgstr "Max value (sum in org unit hierarchy)" msgid "Custom" msgstr "Custom" +msgid "Enrollment" +msgstr "Enrollment" + +msgid "tracked entity" +msgstr "tracked entity" + msgid "equal to (=)" msgstr "equal to (=)" diff --git a/src/components/layout-panel/bottom-bar/action-buttons/enrollment-button.tsx b/src/components/layout-panel/bottom-bar/action-buttons/enrollment-button.tsx index 45d3cb44..1fb8fbc9 100644 --- a/src/components/layout-panel/bottom-bar/action-buttons/enrollment-button.tsx +++ b/src/components/layout-panel/bottom-bar/action-buttons/enrollment-button.tsx @@ -1,7 +1,5 @@ -import { useProgramMetadataItem } from '@components/app-wrapper/metadata-provider/metadata-provider' import i18n from '@dhis2/d2-i18n' -import { useAppSelector, useLayoutContext } from '@hooks' -import { isDataSourceProgramWithRegistration } from '@modules/data-source' +import { useAppSelector, useOutputTypeLabel } from '@hooks' import { getVisUiConfigVisualizationType } from '@store/vis-ui-config-slice' import { useMemo, type FC } from 'react' import { BaseButtonWithConditionalTooltip } from './base-button' @@ -10,19 +8,7 @@ import { useActionButton } from './use-action-button' export const EnrollmentButton: FC = () => { const visualizationType = useAppSelector(getVisUiConfigVisualizationType) const { action, tooltipConfig } = useActionButton('ENROLLMENT') - const { programIds } = useLayoutContext() - const programMetadata = useProgramMetadataItem(programIds[0]) - - const enrollmentLabel = useMemo(() => { - if ( - isDataSourceProgramWithRegistration(programMetadata) && - programMetadata.displayEnrollmentLabel - ) { - return programMetadata.displayEnrollmentLabel - } - - return i18n.t('Enrollment') - }, [programMetadata]) + const enrollmentLabel = useOutputTypeLabel('ENROLLMENT') const buttonLabelLookup = useMemo( () => ({ diff --git a/src/components/layout-panel/bottom-bar/action-buttons/event-button.tsx b/src/components/layout-panel/bottom-bar/action-buttons/event-button.tsx index 66cfea78..786429c5 100644 --- a/src/components/layout-panel/bottom-bar/action-buttons/event-button.tsx +++ b/src/components/layout-panel/bottom-bar/action-buttons/event-button.tsx @@ -1,7 +1,5 @@ -import { useProgramMetadataItem } from '@components/app-wrapper/metadata-provider/metadata-provider' import i18n from '@dhis2/d2-i18n' -import { useAppSelector, useLayoutContext } from '@hooks' -import { isDataSourceProgramWithRegistration } from '@modules/data-source' +import { useAppSelector, useOutputTypeLabel } from '@hooks' import { getVisUiConfigVisualizationType } from '@store/vis-ui-config-slice' import { useMemo, type FC } from 'react' import { BaseButtonWithConditionalTooltip } from './base-button' @@ -11,19 +9,7 @@ export const EventButton: FC = () => { const visualizationType = useAppSelector(getVisUiConfigVisualizationType) const { action, tooltipConfig } = useActionButton('EVENT', 'EVENT') - const { programIds } = useLayoutContext() - const programMetadata = useProgramMetadataItem(programIds[0]) - - const eventLabel = useMemo(() => { - if ( - isDataSourceProgramWithRegistration(programMetadata) && - programMetadata.displayEventLabel - ) { - return programMetadata.displayEventLabel - } - - return i18n.t('Event') - }, [programMetadata]) + const eventLabel = useOutputTypeLabel('EVENT') const buttonLabelLookup = useMemo( () => ({ diff --git a/src/components/layout-panel/bottom-bar/action-buttons/tracked-entity-instance-button.tsx b/src/components/layout-panel/bottom-bar/action-buttons/tracked-entity-instance-button.tsx index 1a14be3e..9545cf7e 100644 --- a/src/components/layout-panel/bottom-bar/action-buttons/tracked-entity-instance-button.tsx +++ b/src/components/layout-panel/bottom-bar/action-buttons/tracked-entity-instance-button.tsx @@ -1,18 +1,12 @@ import i18n from '@dhis2/d2-i18n' -import { useLayoutContext, useMetadataItem } from '@hooks' +import { useOutputTypeLabel } from '@hooks' import { useMemo, type FC } from 'react' import { BaseButtonWithConditionalTooltip } from './base-button' import { useActionButton } from './use-action-button' export const TrackedEntityInstanceButton: FC = () => { const { action, tooltipConfig } = useActionButton('TRACKED_ENTITY_INSTANCE') - const { tetId } = useLayoutContext() - const tetMetadata = useMetadataItem(tetId) - - const trackedEntityTypeName = useMemo( - () => tetMetadata?.name ?? i18n.t('tracked entity'), - [tetMetadata] - ) + const trackedEntityTypeName = useOutputTypeLabel('TRACKED_ENTITY_INSTANCE') const buttonLabelLookup = useMemo( () => ({ From 9431711c60c8f2e461241c1d9760b9e63a92585d Mon Sep 17 00:00:00 2001 From: Edoardo Sabadelli Date: Wed, 2 Sep 2026 16:14:49 +0200 Subject: [PATCH 03/12] refactor: replace CustomValueButton with CellValueButton --- i18n/en.pot | 28 +-- .../__tests__/layout-panel.cy.tsx | 7 +- .../__tests__/use-action-button.spec.tsx | 190 +----------------- .../action-buttons/custom-value-button.tsx | 183 ----------------- .../action-buttons/event-button.tsx | 2 +- .../action-buttons/use-action-button.ts | 22 +- .../layout-panel/bottom-bar/bottom-bar.tsx | 4 +- .../__tests__/cell-value-button.spec.tsx | 180 +++++++++++++++++ .../cell-value-button/cell-value-button.tsx | 94 +++++++++ .../styles/cell-value-button.module.css | 59 ++++++ .../icon-table-rows.tsx | 0 .../row-granularity-label.tsx | 2 +- 12 files changed, 358 insertions(+), 413 deletions(-) delete mode 100644 src/components/layout-panel/bottom-bar/action-buttons/custom-value-button.tsx create mode 100644 src/components/layout-panel/bottom-bar/cell-value-button/__tests__/cell-value-button.spec.tsx create mode 100644 src/components/layout-panel/bottom-bar/cell-value-button/cell-value-button.tsx create mode 100644 src/components/layout-panel/bottom-bar/cell-value-button/styles/cell-value-button.module.css rename src/components/layout-panel/bottom-bar/{row-granularity-label => }/icon-table-rows.tsx (100%) diff --git a/i18n/en.pot b/i18n/en.pot index 0569623f..0af0a900 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-02T13:48:53.069Z\n" -"PO-Revision-Date: 2026-09-02T13:48:53.069Z\n" +"POT-Creation-Date: 2026-09-02T14:14:51.291Z\n" +"PO-Revision-Date: 2026-09-02T14:14:51.291Z\n" msgid "" "Some dimensions were not added because they cannot be used in a " @@ -235,24 +235,6 @@ msgstr "Levels" msgid "Groups" msgstr "Groups" -msgid "Using: {{- itemName}} ({{- aggregationType}})" -msgstr "Using: {{- itemName}} ({{- aggregationType}})" - -msgid "Custom value is from a different stage than dimensions in the layout" -msgstr "Custom value is from a different stage than dimensions in the layout" - -msgid "Update custom value" -msgstr "Update custom value" - -msgid "Create custom value table" -msgstr "Create custom value table" - -msgid "Switch to custom value table" -msgstr "Switch to custom value table" - -msgid "Update custom value table" -msgstr "Update custom value table" - msgid "Create {{- enrollmentLabel}} list" msgstr "Create {{- enrollmentLabel}} list" @@ -334,6 +316,12 @@ msgstr "Not valid with program indicators" msgid "Nothing selected. Add items to the layout to get started." msgstr "Nothing selected. Add items to the layout to get started." +msgid "Cells show {{- valueName}}" +msgstr "Cells show {{- valueName}}" + +msgid "Cells show {{- outputTypeLabel}} count" +msgstr "Cells show {{- outputTypeLabel}} count" + msgid "One row for each {{- outputTypeLabel}}" msgstr "One row for each {{- outputTypeLabel}}" diff --git a/src/components/layout-panel/__tests__/layout-panel.cy.tsx b/src/components/layout-panel/__tests__/layout-panel.cy.tsx index 19fa0e0e..e38a54a8 100644 --- a/src/components/layout-panel/__tests__/layout-panel.cy.tsx +++ b/src/components/layout-panel/__tests__/layout-panel.cy.tsx @@ -443,7 +443,7 @@ describe('', () => { }) }) - it('renders the PIVOT_TABLE update buttons in the order enrollment, event, custom value', () => { + it('renders the PIVOT_TABLE update buttons in the order enrollment, event, followed by the cell value button', () => { const layoutPanelMockOptions = createMockOptions({ dimensionSelection: { ...mockOptions.partialStore?.preloadedState.dimensionSelection, @@ -468,8 +468,11 @@ describe('', () => { expect(order).to.deep.equal([ 'update-button-enrollment', 'update-button-event', - 'update-button-custom-value', ]) }) + + cy.getByDataTest('update-buttons') + .findByDataTest('cell-value-button') + .should('exist') }) }) diff --git a/src/components/layout-panel/bottom-bar/action-buttons/__tests__/use-action-button.spec.tsx b/src/components/layout-panel/bottom-bar/action-buttons/__tests__/use-action-button.spec.tsx index 95b62f38..d1a6d593 100644 --- a/src/components/layout-panel/bottom-bar/action-buttons/__tests__/use-action-button.spec.tsx +++ b/src/components/layout-panel/bottom-bar/action-buttons/__tests__/use-action-button.spec.tsx @@ -985,162 +985,10 @@ describe('useActionButton for Tracked entity instance button', () => { }) }) -describe('useActionButton for Custom value button', () => { - it('returns correct result for: PT, currentVis with outputType !== EVENT', async () => { - const { result } = await renderHookWithAppWrapper( - () => useActionButton('EVENT', 'CUSTOM_VALUE'), - createStoreWithPreloadedState({ - currentVis: { - outputType: 'ENROLLMENT', - type: 'PIVOT_TABLE', - }, - dimensionSelection: { - dataSourceId: metadata.p2.id, - }, - visUiConfig: { - layout: { - columns: [metadata['p2.p2s1.d1'].id], - }, - outputType: 'ENROLLMENT', - visualizationType: 'PIVOT_TABLE', - }, - }) - ) - - const output = result.current - - expect(output.action).toEqual('switch') - expect(output.tooltipConfig).toEqual(undefined) - }) - - it('returns correct result for: PT, currentVis with outputType === EVENT', async () => { - const { result } = await renderHookWithAppWrapper( - () => useActionButton('EVENT', 'CUSTOM_VALUE'), - createStoreWithPreloadedState({ - currentVis: { - outputType: 'EVENT', - type: 'PIVOT_TABLE', - }, - dimensionSelection: { - dataSourceId: metadata.p2.id, - }, - visUiConfig: { - layout: { - columns: [metadata['p2.p2s1.d1'].id], - }, - outputType: 'EVENT', - visualizationType: 'PIVOT_TABLE', - }, - }) - ) - - const output = result.current - - expect(output.action).toEqual('switch') - expect(output.tooltipConfig).toEqual(undefined) - }) - - it('returns correct result for: PT, empty layout', async () => { - const { result } = await renderHookWithAppWrapper( - () => useActionButton('EVENT', 'CUSTOM_VALUE'), - createStoreWithPreloadedState({ - dimensionSelection: { - dataSourceId: metadata.p1.id, - }, - visUiConfig: { - visualizationType: 'PIVOT_TABLE', - }, - }) - ) - - const output = result.current - - expect(output.action).toEqual('create') - expect(output.tooltipConfig).toEqual({ - content: - 'Nothing selected. Add items to the layout to get started.', - openDelay: 1000, - }) - }) - - it('returns correct result for: PT, no program', async () => { - const { result } = await renderHookWithAppWrapper( - () => useActionButton('EVENT', 'CUSTOM_VALUE'), - createStoreWithPreloadedState({ - visUiConfig: { - layout: { - columns: [metadata['tei1.enrollmentOu'].id], - }, - visualizationType: 'PIVOT_TABLE', - }, - }) - ) - - const output = result.current - - expect(output.action).toEqual('create') - expect(output.tooltipConfig).toEqual({ - content: 'Not valid without a program', - }) - }) - - it('returns correct result for: PT, multiple programs', async () => { - const { result } = await renderHookWithAppWrapper( - () => useActionButton('EVENT', 'CUSTOM_VALUE'), - createStoreWithPreloadedState({ - dimensionSelection: { - dataSourceId: metadata.p2.id, - }, - visUiConfig: { - layout: { - columns: [ - metadata['p1.p1s1.d1'].id, - metadata['p2.p2s1.d1'].id, - ], - }, - visualizationType: 'PIVOT_TABLE', - }, - }) - ) - - const output = result.current - - expect(output.action).toEqual('create') - expect(output.tooltipConfig).toEqual({ - content: 'Not valid with multiple programs', - }) - }) - - it('returns correct result for: PT, multiple program stages', async () => { - const { result } = await renderHookWithAppWrapper( - () => useActionButton('EVENT', 'CUSTOM_VALUE'), - createStoreWithPreloadedState({ - dimensionSelection: { - dataSourceId: metadata.p1.id, - }, - visUiConfig: { - layout: { - columns: [ - metadata['p1.p1s1.d1'].id, - metadata['p1.p1s2.d1'].id, - ], - }, - visualizationType: 'PIVOT_TABLE', - }, - }) - ) - - const output = result.current - - expect(output.action).toEqual('create') - expect(output.tooltipConfig).toEqual({ - content: 'Not valid with multiple program stages', - }) - }) - +describe('useActionButton for Event button with a custom value set', () => { it('returns "update" for: PT, EVENT output, custom value active (currentVis has a value)', async () => { const { result } = await renderHookWithAppWrapper( - () => useActionButton('EVENT', 'CUSTOM_VALUE'), + () => useActionButton('EVENT'), createStoreWithPreloadedState({ currentVis: { outputType: 'EVENT', @@ -1162,15 +1010,13 @@ describe('useActionButton for Custom value button', () => { expect(result.current.action).toEqual('update') }) -}) -describe('useActionButton for Event button in PIVOT_TABLE custom value mode', () => { - it('returns "switch" when a custom value is active (currentVis has a value)', async () => { + it('returns "switch" for: PT, ENROLLMENT output, custom value active', async () => { const { result } = await renderHookWithAppWrapper( - () => useActionButton('EVENT', 'EVENT'), + () => useActionButton('EVENT'), createStoreWithPreloadedState({ currentVis: { - outputType: 'EVENT', + outputType: 'ENROLLMENT', type: 'PIVOT_TABLE', value: { id: metadata['p2.p2s1.d1'].id }, }, @@ -1181,7 +1027,7 @@ describe('useActionButton for Event button in PIVOT_TABLE custom value mode', () layout: { columns: [metadata['p2.p2s1.d1'].id], }, - outputType: 'EVENT', + outputType: 'ENROLLMENT', visualizationType: 'PIVOT_TABLE', }, }) @@ -1189,28 +1035,4 @@ describe('useActionButton for Event button in PIVOT_TABLE custom value mode', () expect(result.current.action).toEqual('switch') }) - - it('returns "update" when no custom value is active (currentVis has no value)', async () => { - const { result } = await renderHookWithAppWrapper( - () => useActionButton('EVENT', 'EVENT'), - createStoreWithPreloadedState({ - currentVis: { - outputType: 'EVENT', - type: 'PIVOT_TABLE', - }, - dimensionSelection: { - dataSourceId: metadata.p2.id, - }, - visUiConfig: { - layout: { - columns: [metadata['p2.p2s1.d1'].id], - }, - outputType: 'EVENT', - visualizationType: 'PIVOT_TABLE', - }, - }) - ) - - expect(result.current.action).toEqual('update') - }) }) diff --git a/src/components/layout-panel/bottom-bar/action-buttons/custom-value-button.tsx b/src/components/layout-panel/bottom-bar/action-buttons/custom-value-button.tsx deleted file mode 100644 index 1927277d..00000000 --- a/src/components/layout-panel/bottom-bar/action-buttons/custom-value-button.tsx +++ /dev/null @@ -1,183 +0,0 @@ -import { - CustomValueModal, - getStageIdFromDimensionId, -} from '@components/layout-panel/custom-value-modal' -import { aggregationTypeDisplayNames } from '@constants/aggregation-types' -import i18n from '@dhis2/d2-i18n' -import { IconSettings16, Tooltip } from '@dhis2/ui' -import { - useAppDispatch, - useAppSelector, - useLayoutContext, - useMetadataItem, -} from '@hooks' -import { tUpdateCurrentVisFromVisUiConfig } from '@store/thunks' -import { - getVisUiConfigCustomValue, - setVisUiConfigOutputType, -} from '@store/vis-ui-config-slice' -import cx from 'classnames' -import { - useCallback, - useMemo, - useState, - type FC, - type ReactElement, -} from 'react' -import classes from './styles/action-buttons.module.css' -import { UpdateSyncIcon } from './update-sync-icon' -import { useActionButton } from './use-action-button' -import { useUpdateAnimation } from './use-update-animation' - -const DEFAULT_TOOLTIP_OPEN_DELAY = 500 - -type WithTooltipProps = { - content?: string - openDelay?: number - children: ReactElement -} - -const WithTooltip: FC = ({ - content, - openDelay = DEFAULT_TOOLTIP_OPEN_DELAY, - children, -}) => { - if (!content) { - return children - } - return ( - - {(tooltipProps: object) => ( - - {children} - - )} - - ) -} - -export const CustomValueButton: FC = () => { - const dispatch = useAppDispatch() - const customValue = useAppSelector(getVisUiConfigCustomValue) - const { programStageIds } = useLayoutContext() - const customValueMetadata = useMetadataItem(customValue?.id) - const { action, tooltipConfig: actionTooltipConfig } = useActionButton( - 'EVENT', - 'CUSTOM_VALUE' - ) - const [isModalOpen, setIsModalOpen] = useState(false) - const { isAnimating } = useUpdateAnimation('EVENT') - const isButtonReady = Boolean( - customValue?.id && customValue?.aggregationType - ) - const layoutStageId = programStageIds[0] ?? null - const customValueStageId = getStageIdFromDimensionId(customValue?.id) - const hasStageMismatch = Boolean( - layoutStageId && - customValueStageId && - customValueStageId !== layoutStageId - ) - const isFullyDisabled = Boolean(actionTooltipConfig) - const isUpdateDisabled = isFullyDisabled || hasStageMismatch - const wrapperTooltipContent = (() => { - if (actionTooltipConfig) { - return actionTooltipConfig.content - } - if (customValue && !hasStageMismatch) { - return i18n.t('Using: {{- itemName}} ({{- aggregationType}})', { - itemName: customValueMetadata?.name, - aggregationType: - aggregationTypeDisplayNames[customValue.aggregationType], - nsSeparator: '^^', - }) - } - return undefined - })() - const updateButtonTooltipContent = - !isFullyDisabled && hasStageMismatch - ? i18n.t( - 'Custom value is from a different stage than dimensions in the layout' - ) - : undefined - const configureButtonTooltipContent = - !isFullyDisabled && hasStageMismatch - ? i18n.t('Update custom value') - : undefined - const label = useMemo(() => { - switch (action) { - case 'create': - return i18n.t('Create custom value table') - case 'switch': - return i18n.t('Switch to custom value table') - case 'update': - return i18n.t('Update custom value table') - } - }, [action]) - - const onUpdateClick = useCallback(() => { - if (customValue) { - dispatch(setVisUiConfigOutputType('EVENT')) - dispatch(tUpdateCurrentVisFromVisUiConfig(true)) - } else { - setIsModalOpen(true) - } - }, [customValue, dispatch]) - const onConfigureClick = useCallback(() => { - setIsModalOpen((curr) => !curr) - }, []) - const onModalClose = useCallback(() => setIsModalOpen(false), []) - - return ( - <> - -
- - - - - {isButtonReady && ( - - - - )} -
-
- {isModalOpen && } - - ) -} diff --git a/src/components/layout-panel/bottom-bar/action-buttons/event-button.tsx b/src/components/layout-panel/bottom-bar/action-buttons/event-button.tsx index 786429c5..95eeea31 100644 --- a/src/components/layout-panel/bottom-bar/action-buttons/event-button.tsx +++ b/src/components/layout-panel/bottom-bar/action-buttons/event-button.tsx @@ -8,7 +8,7 @@ import { useActionButton } from './use-action-button' export const EventButton: FC = () => { const visualizationType = useAppSelector(getVisUiConfigVisualizationType) - const { action, tooltipConfig } = useActionButton('EVENT', 'EVENT') + const { action, tooltipConfig } = useActionButton('EVENT') const eventLabel = useOutputTypeLabel('EVENT') const buttonLabelLookup = useMemo( diff --git a/src/components/layout-panel/bottom-bar/action-buttons/use-action-button.ts b/src/components/layout-panel/bottom-bar/action-buttons/use-action-button.ts index 7ee22dd1..e1deb569 100644 --- a/src/components/layout-panel/bottom-bar/action-buttons/use-action-button.ts +++ b/src/components/layout-panel/bottom-bar/action-buttons/use-action-button.ts @@ -15,10 +15,6 @@ import type { OutputType, Program } from '@types' import { useMemo } from 'react' import type { ButtonAction } from './base-button' -/* The two table kinds that share the EVENT output type: a plain event table - * and a custom value table. Used to label the EVENT/custom-value buttons. */ -export type EventOutputTypeVariant = 'EVENT' | 'CUSTOM_VALUE' - type TooltipConfig = { content: string; openDelay?: number } | undefined const getRegistrationOuTooltipContent = (): TooltipConfig => ({ @@ -184,10 +180,7 @@ const getTrackedEntityInstanceTooltipContent = ({ }) } -export const useActionButton = ( - buttonType: OutputType, - buttonVariant?: EventOutputTypeVariant -) => { +export const useActionButton = (buttonType: OutputType) => { const currentVis = useAppSelector(getCurrentVis) const { tetId, programStageIds, programIds } = useLayoutContext() const layout = useAppSelector(getVisUiConfigLayout) @@ -217,22 +210,11 @@ export const useActionButton = ( if (isVisualizationEmpty(currentVis)) { return 'create' } else if (outputType === buttonType) { - if ( - visualizationType === 'PIVOT_TABLE' && - buttonType === 'EVENT' && - buttonVariant !== undefined - ) { - const hasCustomValue = Boolean(currentVis.value?.id) - const activeVariant: EventOutputTypeVariant = hasCustomValue - ? 'CUSTOM_VALUE' - : 'EVENT' - return activeVariant === buttonVariant ? 'update' : 'switch' - } return 'update' } else { return 'switch' } - }, [buttonType, buttonVariant, currentVis, outputType, visualizationType]) + }, [buttonType, currentVis, outputType]) const hasCategoryInLayout: boolean = useMemo( () => diff --git a/src/components/layout-panel/bottom-bar/bottom-bar.tsx b/src/components/layout-panel/bottom-bar/bottom-bar.tsx index 5f658121..20b459c8 100644 --- a/src/components/layout-panel/bottom-bar/bottom-bar.tsx +++ b/src/components/layout-panel/bottom-bar/bottom-bar.tsx @@ -4,10 +4,10 @@ import { getIsVisualizationLoading } from '@store/loader-slice' import { getVisUiConfigVisualizationType } from '@store/vis-ui-config-slice' import cx from 'classnames' import { type FC } from 'react' -import { CustomValueButton } from './action-buttons/custom-value-button' import { EnrollmentButton } from './action-buttons/enrollment-button' import { EventButton } from './action-buttons/event-button' import { TrackedEntityInstanceButton } from './action-buttons/tracked-entity-instance-button' +import { CellValueButton } from './cell-value-button/cell-value-button' import { RowGranularityLabel } from './row-granularity-label/row-granularity-label' import classes from './styles/bottom-bar.module.css' @@ -29,7 +29,7 @@ export const BottomBar: FC = () => { <> - + ) : ( <> diff --git a/src/components/layout-panel/bottom-bar/cell-value-button/__tests__/cell-value-button.spec.tsx b/src/components/layout-panel/bottom-bar/cell-value-button/__tests__/cell-value-button.spec.tsx new file mode 100644 index 00000000..7d7cdef5 --- /dev/null +++ b/src/components/layout-panel/bottom-bar/cell-value-button/__tests__/cell-value-button.spec.tsx @@ -0,0 +1,180 @@ +import { initialState as visUiConfigInitialState } from '@store/vis-ui-config-slice' +import { renderWithAppWrapper, type MockOptions } from '@test-utils/app-wrapper' +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import type { AggregationType, OutputType, RootState } from '@types' +import { describe, it, expect } from 'vitest' +import { CellValueButton } from '../cell-value-button' + +const stage1 = { + id: 's1', + name: 'Stage 1', + repeatable: false, + hideDueDate: false, + program: { id: 'p1' }, +} +const stage2 = { + id: 's2', + name: 'Stage 2', + repeatable: false, + hideDueDate: false, + program: { id: 'p2' }, +} + +const metadata = { + p1: { + id: 'p1', + name: 'Program 1', + programType: 'WITH_REGISTRATION', + programStages: [stage1], + trackedEntityType: { id: 'tet1', name: 'Person' }, + displayEventLabel: 'Visit', + displayEnrollmentLabel: 'Registration', + }, + p2: { + id: 'p2', + name: 'Program 2', + programType: 'WITHOUT_REGISTRATION', + programStages: [stage2], + }, + s1: stage1, + s2: stage2, + tet1: { id: 'tet1', name: 'Person' }, + 's1.de1': { + id: 's1.de1', + name: 'Weight in kg', + dimensionType: 'DATA_ELEMENT', + valueType: 'NUMBER', + programId: 'p1', + programStageId: 's1', + }, + 's2.de1': { + id: 's2.de1', + name: 'Height in cm', + dimensionType: 'DATA_ELEMENT', + valueType: 'NUMBER', + programId: 'p2', + programStageId: 's2', + }, + 'tet1.enrollmentOu': { + id: 'tet1.enrollmentOu', + name: 'Registration org. unit', + dimensionType: 'ORGANISATION_UNIT', + trackedEntityTypeId: 'tet1', + }, +} + +const buildMockOptions = ({ + columns, + outputType = 'EVENT', + customValue, +}: { + columns: string[] + outputType?: OutputType + customValue?: { id: string; aggregationType: AggregationType } +}): MockOptions => ({ + metadata, + partialStore: { + preloadedState: { + visUiConfig: { + ...visUiConfigInitialState, + visualizationType: 'PIVOT_TABLE', + outputType, + layout: { ...visUiConfigInitialState.layout, columns }, + customValue, + }, + } as Partial, + }, +}) + +describe('CellValueButton', () => { + it('falls back to the output type count when no custom value is set', async () => { + await renderWithAppWrapper( + , + buildMockOptions({ columns: ['s1.de1'] }) + ) + + expect( + screen.getByRole('button', { name: 'Cells show Visit count' }) + ).toBeEnabled() + }) + + it('follows the output type in the count label', async () => { + await renderWithAppWrapper( + , + buildMockOptions({ + columns: ['s1.de1'], + outputType: 'ENROLLMENT', + }) + ) + + expect( + screen.getByRole('button', { + name: 'Cells show Registration count', + }) + ).toBeInTheDocument() + }) + + it('names the custom value when one is set', async () => { + await renderWithAppWrapper( + , + buildMockOptions({ + columns: ['s1.de1'], + customValue: { id: 's1.de1', aggregationType: 'AVERAGE' }, + }) + ) + + expect( + screen.getByRole('button', { name: 'Cells show Weight in kg' }) + ).toBeInTheDocument() + }) + + it('opens the modal on click', async () => { + await renderWithAppWrapper( + , + buildMockOptions({ columns: ['s1.de1'] }) + ) + + await userEvent.click(screen.getByRole('button')) + + await waitFor(() => { + expect( + screen.getByText('Configure custom value') + ).toBeInTheDocument() + }) + }) + + it('is disabled without a program in the layout', async () => { + await renderWithAppWrapper( + , + buildMockOptions({ columns: ['tet1.enrollmentOu'] }) + ) + + expect(screen.getByRole('button')).toBeDisabled() + + await userEvent.hover(screen.getByRole('button')) + + await waitFor(() => { + expect( + screen.getByText('Not valid without a program') + ).toBeInTheDocument() + }) + }) + + it('is disabled with multiple programs in the layout', async () => { + await renderWithAppWrapper( + , + buildMockOptions({ columns: ['s1.de1', 's2.de1'] }) + ) + + expect(screen.getByRole('button')).toBeDisabled() + + await userEvent.hover(screen.getByRole('button')) + + await waitFor(() => { + expect( + screen.getByText('Not valid with multiple programs') + ).toBeInTheDocument() + }) + }) +}) diff --git a/src/components/layout-panel/bottom-bar/cell-value-button/cell-value-button.tsx b/src/components/layout-panel/bottom-bar/cell-value-button/cell-value-button.tsx new file mode 100644 index 00000000..c818a4eb --- /dev/null +++ b/src/components/layout-panel/bottom-bar/cell-value-button/cell-value-button.tsx @@ -0,0 +1,94 @@ +import { IconTableRows } from '@components/layout-panel/bottom-bar/icon-table-rows' +import { CustomValueModal } from '@components/layout-panel/custom-value-modal' +import i18n from '@dhis2/d2-i18n' +import { IconEdit16, Tooltip } from '@dhis2/ui' +import { + useAppSelector, + useLayoutContext, + useMetadataItem, + useOutputTypeLabel, +} from '@hooks' +import { + getVisUiConfigCustomValue, + getVisUiConfigOutputType, +} from '@store/vis-ui-config-slice' +import cx from 'classnames' +import { useCallback, useState, type FC } from 'react' +import classes from './styles/cell-value-button.module.css' + +const TOOLTIP_OPEN_DELAY = 500 + +/* The item list in the modal is fetched for a single program, so a layout + * without exactly one program has no unambiguous set of values to choose from. */ +const useUnavailableReason = (): string | undefined => { + const { programIds } = useLayoutContext() + + if (programIds.length === 0) { + return i18n.t('Not valid without a program') + } + if (programIds.length > 1) { + return i18n.t('Not valid with multiple programs') + } + return undefined +} + +export const CellValueButton: FC = () => { + const outputType = useAppSelector(getVisUiConfigOutputType) + const outputTypeLabel = useOutputTypeLabel(outputType) + const customValue = useAppSelector(getVisUiConfigCustomValue) + const customValueMetadata = useMetadataItem(customValue?.id) + const unavailableReason = useUnavailableReason() + const [isModalOpen, setIsModalOpen] = useState(false) + + const onClick = useCallback(() => setIsModalOpen(true), []) + const onModalClose = useCallback(() => setIsModalOpen(false), []) + + const label = customValueMetadata?.name + ? i18n.t('Cells show {{- valueName}}', { + valueName: customValueMetadata.name, + nsSeparator: '^^', + }) + : i18n.t('Cells show {{- outputTypeLabel}} count', { + outputTypeLabel, + nsSeparator: '^^', + }) + + const button = ( + + ) + + return ( + <> + {unavailableReason ? ( + + {(tooltipProps: object) => ( + + {button} + + )} + + ) : ( + button + )} + {isModalOpen && } + + ) +} diff --git a/src/components/layout-panel/bottom-bar/cell-value-button/styles/cell-value-button.module.css b/src/components/layout-panel/bottom-bar/cell-value-button/styles/cell-value-button.module.css new file mode 100644 index 00000000..1f17704e --- /dev/null +++ b/src/components/layout-panel/bottom-bar/cell-value-button/styles/cell-value-button.module.css @@ -0,0 +1,59 @@ +.button { + display: flex; + align-items: center; + gap: var(--spacers-dp4); + padding-block: 6px; + padding-inline: var(--spacers-dp8); + border: 1px solid transparent; + border-inline-start-color: var(--colors-grey300); + border-radius: 3px; + background-color: var(--colors-white); + color: var(--colors-grey700); + font-family: inherit; + font-size: 13px; + line-height: 1; + cursor: pointer; + user-select: none; +} + +.button:hover { + border-color: var(--colors-grey400); + background-color: var(--colors-grey100); + color: var(--colors-grey900); +} + +.button:active { + background-color: var(--colors-grey300); +} + +.button:focus { + outline: 3px solid var(--theme-focus); + outline-offset: -3px; +} + +.button:focus:not(:focus-visible) { + outline: none; +} + +.button svg { + flex-shrink: 0; + color: var(--colors-grey500); +} + +.button svg:last-child { + margin-inline-start: var(--spacers-dp4); +} + +.button.disabled, +.button.disabled:hover { + border-color: transparent; + border-inline-start-color: var(--colors-grey300); + background-color: var(--colors-white); + color: var(--colors-grey500); + cursor: not-allowed; +} + +.tooltipWrapper { + display: flex; + align-items: center; +} diff --git a/src/components/layout-panel/bottom-bar/row-granularity-label/icon-table-rows.tsx b/src/components/layout-panel/bottom-bar/icon-table-rows.tsx similarity index 100% rename from src/components/layout-panel/bottom-bar/row-granularity-label/icon-table-rows.tsx rename to src/components/layout-panel/bottom-bar/icon-table-rows.tsx diff --git a/src/components/layout-panel/bottom-bar/row-granularity-label/row-granularity-label.tsx b/src/components/layout-panel/bottom-bar/row-granularity-label/row-granularity-label.tsx index b4bbbe52..eeb2b137 100644 --- a/src/components/layout-panel/bottom-bar/row-granularity-label/row-granularity-label.tsx +++ b/src/components/layout-panel/bottom-bar/row-granularity-label/row-granularity-label.tsx @@ -1,8 +1,8 @@ +import { IconTableRows } from '@components/layout-panel/bottom-bar/icon-table-rows' import i18n from '@dhis2/d2-i18n' import { useAppSelector, useOutputTypeLabel } from '@hooks' import { getVisUiConfigOutputType } from '@store/vis-ui-config-slice' import { type FC } from 'react' -import { IconTableRows } from './icon-table-rows' import classes from './styles/row-granularity-label.module.css' export const RowGranularityLabel: FC = () => { From cc6415ddfc366e996ba1b38ff4c54fd429d8741e Mon Sep 17 00:00:00 2001 From: Edoardo Sabadelli Date: Thu, 3 Sep 2026 09:30:45 +0200 Subject: [PATCH 04/12] refactor: move RadioCard to the shared folder --- i18n/en.pot | 69 ++++++++++--------- .../grouping-section.tsx | 2 +- .../show-all-filter-radio.tsx | 2 +- .../radio-card/radio-card.tsx | 0 .../radio-card/styles/radio-card.module.css | 0 5 files changed, 37 insertions(+), 36 deletions(-) rename src/components/{dimension-modal => shared}/radio-card/radio-card.tsx (100%) rename src/components/{dimension-modal => shared}/radio-card/styles/radio-card.module.css (100%) diff --git a/i18n/en.pot b/i18n/en.pot index 0af0a900..60718e9b 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-02T14:14:51.291Z\n" -"PO-Revision-Date: 2026-09-02T14:14:51.291Z\n" +"POT-Creation-Date: 2026-09-03T07:30:46.814Z\n" +"PO-Revision-Date: 2026-09-03T07:30:46.814Z\n" msgid "" "Some dimensions were not added because they cannot be used in a " @@ -325,11 +325,40 @@ msgstr "Cells show {{- outputTypeLabel}} count" msgid "One row for each {{- outputTypeLabel}}" msgstr "One row for each {{- outputTypeLabel}}" -msgid "Configure custom value" -msgstr "Configure custom value" +msgid "Configure table cells" +msgstr "Configure table cells" -msgid "Choose the numeric data item to show in table cells." -msgstr "Choose the numeric data item to show in table cells." +msgid "Cell value" +msgstr "Cell value" + +msgid "Count" +msgstr "Count" + +msgid "" +"Each cell shows a count of the events, enrollments or tracked entities the " +"table is built from." +msgstr "" +"Each cell shows a count of the events, enrollments or tracked entities the " +"table is built from." + +msgid "Custom value" +msgstr "Custom value" + +msgid "" +"Each cell shows a data item's value instead — for example, a total or " +"average. Used for every output type." +msgstr "" +"Each cell shows a data item's value instead — for example, a total or " +"average. Used for every output type." + +msgid "Cancel" +msgstr "Cancel" + +msgid "Select a value before updating" +msgstr "Select a value before updating" + +msgid "Update" +msgstr "Update" msgid "Search data items" msgstr "Search data items" @@ -343,15 +372,9 @@ msgstr "Error loading data" msgid "Failed to load data items" msgstr "Failed to load data items" -msgid "No numeric data items in stage \"{{- stageName}}\"" -msgstr "No numeric data items in stage \"{{- stageName}}\"" - msgid "No numeric data items in this program" msgstr "No numeric data items in this program" -msgid "This stage does not have any numeric data items available." -msgstr "This stage does not have any numeric data items available." - msgid "This program does not have any numeric data items available." msgstr "This program does not have any numeric data items available." @@ -361,25 +384,6 @@ msgstr "No data items match \"{{- searchTerm}}\"" msgid "Aggregation" msgstr "Aggregation" -msgid "Cancel" -msgstr "Cancel" - -msgid "Update" -msgstr "Update" - -msgid "Select a value before updating" -msgstr "Select a value before updating" - -msgid "" -"\"{{- itemName}}\" is from a different stage than the dimensions in the " -"layout. Choose another item." -msgstr "" -"\"{{- itemName}}\" is from a different stage than the dimensions in the " -"layout. Choose another item." - -msgid "Showing data items from \"{{- stageName}}\", the stage used in the layout" -msgstr "Showing data items from \"{{- stageName}}\", the stage used in the layout" - msgid "Collapse layout" msgstr "Collapse layout" @@ -866,9 +870,6 @@ msgstr "First value (average in org unit hierarchy)" msgid "First value in period (first value in org unit hierarchy)" msgstr "First value in period (first value in org unit hierarchy)" -msgid "Count" -msgstr "Count" - msgid "Standard deviation" msgstr "Standard deviation" diff --git a/src/components/dimension-modal/conditions-modal-content/grouping-section.tsx b/src/components/dimension-modal/conditions-modal-content/grouping-section.tsx index d774f5a7..63edd46d 100644 --- a/src/components/dimension-modal/conditions-modal-content/grouping-section.tsx +++ b/src/components/dimension-modal/conditions-modal-content/grouping-section.tsx @@ -1,7 +1,7 @@ import { RadioCard, RadioCardGroup, -} from '@components/dimension-modal/radio-card/radio-card' +} from '@components/shared/radio-card/radio-card' import i18n from '@dhis2/d2-i18n' import { useAppDispatch, useAppSelector } from '@hooks' import { diff --git a/src/components/dimension-modal/show-all-filter-radio/show-all-filter-radio.tsx b/src/components/dimension-modal/show-all-filter-radio/show-all-filter-radio.tsx index 7bed62d0..418823a4 100644 --- a/src/components/dimension-modal/show-all-filter-radio/show-all-filter-radio.tsx +++ b/src/components/dimension-modal/show-all-filter-radio/show-all-filter-radio.tsx @@ -1,7 +1,7 @@ import { RadioCard, RadioCardGroup, -} from '@components/dimension-modal/radio-card/radio-card' +} from '@components/shared/radio-card/radio-card' import i18n from '@dhis2/d2-i18n' import { type FC, type PropsWithChildren } from 'react' diff --git a/src/components/dimension-modal/radio-card/radio-card.tsx b/src/components/shared/radio-card/radio-card.tsx similarity index 100% rename from src/components/dimension-modal/radio-card/radio-card.tsx rename to src/components/shared/radio-card/radio-card.tsx diff --git a/src/components/dimension-modal/radio-card/styles/radio-card.module.css b/src/components/shared/radio-card/styles/radio-card.module.css similarity index 100% rename from src/components/dimension-modal/radio-card/styles/radio-card.module.css rename to src/components/shared/radio-card/styles/radio-card.module.css From f729469e75d6de216bba6f5d2decb483eb4d0eac Mon Sep 17 00:00:00 2001 From: Edoardo Sabadelli Date: Thu, 3 Sep 2026 09:39:34 +0200 Subject: [PATCH 05/12] refactor: rename CustomValueModal to CellValueModal --- i18n/en.pot | 7 +- .../__tests__/cell-value-modal.spec.tsx} | 149 +++--- .../__tests__/custom-value-option.spec.tsx | 0 .../__tests__/use-cell-value-items.spec.ts | 222 +++++++++ .../cell-value-modal/cell-value-modal.tsx | 127 +++++ .../custom-value-item-picker.tsx | 180 +++++++ .../custom-value-option.tsx | 0 .../layout-panel/cell-value-modal/index.ts | 1 + .../styles/cell-value-modal.module.css | 5 + .../custom-value-item-picker.module.css} | 16 +- .../styles/custom-value-option.module.css | 0 .../use-cell-value-items.ts} | 70 +-- .../__tests__/use-custom-value-items.spec.ts | 471 ------------------ .../custom-value-modal/custom-value-modal.tsx | 288 ----------- .../layout-panel/custom-value-modal/index.ts | 2 - .../custom-value-modal/stage-notice.tsx | 43 -- .../styles/stage-notice.module.css | 3 - 17 files changed, 625 insertions(+), 959 deletions(-) rename src/components/layout-panel/{custom-value-modal/__tests__/custom-value-modal.spec.tsx => cell-value-modal/__tests__/cell-value-modal.spec.tsx} (79%) rename src/components/layout-panel/{custom-value-modal => cell-value-modal}/__tests__/custom-value-option.spec.tsx (100%) create mode 100644 src/components/layout-panel/cell-value-modal/__tests__/use-cell-value-items.spec.ts create mode 100644 src/components/layout-panel/cell-value-modal/cell-value-modal.tsx create mode 100644 src/components/layout-panel/cell-value-modal/custom-value-item-picker.tsx rename src/components/layout-panel/{custom-value-modal => cell-value-modal}/custom-value-option.tsx (100%) create mode 100644 src/components/layout-panel/cell-value-modal/index.ts create mode 100644 src/components/layout-panel/cell-value-modal/styles/cell-value-modal.module.css rename src/components/layout-panel/{custom-value-modal/styles/custom-value-modal.module.css => cell-value-modal/styles/custom-value-item-picker.module.css} (76%) rename src/components/layout-panel/{custom-value-modal => cell-value-modal}/styles/custom-value-option.module.css (100%) rename src/components/layout-panel/{custom-value-modal/use-custom-value-items.ts => cell-value-modal/use-cell-value-items.ts} (52%) delete mode 100644 src/components/layout-panel/custom-value-modal/__tests__/use-custom-value-items.spec.ts delete mode 100644 src/components/layout-panel/custom-value-modal/custom-value-modal.tsx delete mode 100644 src/components/layout-panel/custom-value-modal/index.ts delete mode 100644 src/components/layout-panel/custom-value-modal/stage-notice.tsx delete mode 100644 src/components/layout-panel/custom-value-modal/styles/stage-notice.module.css diff --git a/i18n/en.pot b/i18n/en.pot index 60718e9b..a2a273c5 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-03T07:30:46.814Z\n" -"PO-Revision-Date: 2026-09-03T07:30:46.814Z\n" +"POT-Creation-Date: 2026-09-03T07:39:36.209Z\n" +"PO-Revision-Date: 2026-09-03T07:39:36.209Z\n" msgid "" "Some dimensions were not added because they cannot be used in a " @@ -325,9 +325,6 @@ msgstr "Cells show {{- outputTypeLabel}} count" msgid "One row for each {{- outputTypeLabel}}" msgstr "One row for each {{- outputTypeLabel}}" -msgid "Configure table cells" -msgstr "Configure table cells" - msgid "Cell value" msgstr "Cell value" diff --git a/src/components/layout-panel/custom-value-modal/__tests__/custom-value-modal.spec.tsx b/src/components/layout-panel/cell-value-modal/__tests__/cell-value-modal.spec.tsx similarity index 79% rename from src/components/layout-panel/custom-value-modal/__tests__/custom-value-modal.spec.tsx rename to src/components/layout-panel/cell-value-modal/__tests__/cell-value-modal.spec.tsx index 3a843ed3..626ee7c2 100644 --- a/src/components/layout-panel/custom-value-modal/__tests__/custom-value-modal.spec.tsx +++ b/src/components/layout-panel/cell-value-modal/__tests__/cell-value-modal.spec.tsx @@ -7,10 +7,11 @@ import { renderWithAppWrapper, type MockOptions } from '@test-utils/app-wrapper' import { createDeferredQuery } from '@test-utils/deferred-query' import { screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import type { UserEvent } from '@testing-library/user-event' import type { RootState } from '@types' import deepmerge from 'deepmerge' import { describe, it, expect, vi } from 'vitest' -import { CustomValueModal } from '../custom-value-modal' +import { CellValueModal } from '../cell-value-modal' const ANALYTICS_RESOURCE = 'analytics/enrollments/aggregate/dimensions' @@ -92,6 +93,7 @@ const buildMockOptions = ( partialStore: { preloadedState: deepmerge(initialPreloadedState, { visUiConfig: { + visualizationType: 'PIVOT_TABLE', layout: { ...visUiConfigInitialState.layout, columns: layoutColumns, @@ -102,110 +104,111 @@ const buildMockOptions = ( }, }) -describe('CustomValueModal', () => { - it('shows the loading indicator before data items load', async () => { - const deferred = createDeferredQuery() - await renderWithAppWrapper( - {}} />, - buildMockOptions(['s1.de1'], { - [ANALYTICS_RESOURCE]: deferred.defer(() => dimensionsResponse), - } as unknown as MockOptions['queryData']) - ) +const selectCustomValueMode = async (user: UserEvent) => { + await user.click(screen.getByRole('radio', { name: /Custom value/ })) +} - expect(screen.getByText('Loading data')).toBeInTheDocument() +describe('CellValueModal', () => { + it('starts in Count mode with the item picker hidden, and can be updated straight away', async () => { + const onClose = vi.fn() + const user = userEvent.setup() + const { store } = await renderWithAppWrapper( + , + buildMockOptions(['s1.de1']) + ) - await deferred.releaseAll() + expect(screen.getByRole('radio', { name: /Count/ })).toBeChecked() + expect( + screen.queryByPlaceholderText('Search data items') + ).not.toBeInTheDocument() - await waitFor(() => { - expect(screen.queryByText('Loading data')).not.toBeInTheDocument() - }) - }) + const updateButton = screen.getByRole('button', { name: 'Update' }) + expect(updateButton).toBeEnabled() - it('renders the data items after the query resolves', async () => { - await renderWithAppWrapper( - {}} />, - buildMockOptions(['s1.de1']) - ) + await user.click(updateButton) - await waitFor(() => { - expect(screen.getByText('Weight in kg')).toBeInTheDocument() - expect(screen.getByText('Height in cm')).toBeInTheDocument() - }) + expect(onClose).toHaveBeenCalledOnce() + expect(getVisUiConfigCustomValue(store.getState())).toBeUndefined() }) - it('shows the stage-filter notice when the layout has a program stage', async () => { + it('starts in Custom value mode with the picker expanded when a custom value is stored', async () => { await renderWithAppWrapper( - {}} />, - buildMockOptions(['s1.de1']) + {}} />, + buildMockOptions(['s1.de1'], undefined, { + id: 's1.de1', + aggregationType: 'SUM', + }) ) + expect( + screen.getByRole('radio', { name: /Custom value/ }) + ).toBeChecked() await waitFor(() => { - expect( - screen.getByText( - 'Showing data items from "Stage 1", the stage used in the layout' - ) - ).toBeInTheDocument() + expect(screen.getByText('Weight in kg')).toBeInTheDocument() }) }) - it('shows the warning notice when the current custom value is from a different stage', async () => { - await renderWithAppWrapper( - {}} />, + it('clears the stored custom value when switching back to Count', async () => { + const user = userEvent.setup() + const { store } = await renderWithAppWrapper( + {}} />, buildMockOptions(['s1.de1'], undefined, { - id: 's2.someDe', + id: 's1.de1', aggregationType: 'SUM', }) ) - await waitFor(() => { - expect( - screen.getByText( - /Some DE.*different stage than the dimensions in the layout.*Choose another item/ - ) - ).toBeInTheDocument() - }) - expect( - screen.queryByText(/the stage used in the layout$/) - ).not.toBeInTheDocument() + await user.click(screen.getByRole('radio', { name: /Count/ })) + await user.click(screen.getByRole('button', { name: 'Update' })) + + expect(getVisUiConfigCustomValue(store.getState())).toBeUndefined() + expect(getCurrentVis(store.getState()).value).toBeUndefined() }) - it('omits the stage-filter notice when the layout has no program stage', async () => { + it('shows the loading indicator before data items load', async () => { + const deferred = createDeferredQuery() await renderWithAppWrapper( - {}} />, - buildMockOptions(['p1.enrollmentDate']) + {}} />, + buildMockOptions(['s1.de1'], { + [ANALYTICS_RESOURCE]: deferred.defer(() => dimensionsResponse), + } as unknown as MockOptions['queryData']) ) + await selectCustomValueMode(userEvent.setup()) + + expect(screen.getByText('Loading data')).toBeInTheDocument() + + await deferred.releaseAll() + await waitFor(() => { - expect(screen.getByText('Weight in kg')).toBeInTheDocument() + expect(screen.queryByText('Loading data')).not.toBeInTheDocument() }) - expect( - screen.queryByText(/Showing data items from/) - ).not.toBeInTheDocument() }) - it('renders the stage-scoped empty-state notice when no data items are returned and the layout has a stage', async () => { + it('renders the data items after the query resolves', async () => { await renderWithAppWrapper( - {}} />, - buildMockOptions(['s1.de1'], { - [ANALYTICS_RESOURCE]: { dimensions: [] }, - }) + {}} />, + buildMockOptions(['s1.de1']) ) + await selectCustomValueMode(userEvent.setup()) + await waitFor(() => { - expect( - screen.getByText('No numeric data items in stage "Stage 1"') - ).toBeInTheDocument() + expect(screen.getByText('Weight in kg')).toBeInTheDocument() + expect(screen.getByText('Height in cm')).toBeInTheDocument() }) }) it('renders the program-scoped empty-state notice when no data items are returned and the layout has no stage', async () => { await renderWithAppWrapper( - {}} />, + {}} />, buildMockOptions(['p1.enrollmentDate'], { [ANALYTICS_RESOURCE]: { dimensions: [] }, }) ) + await selectCustomValueMode(userEvent.setup()) + await waitFor(() => { expect( screen.getByText('No numeric data items in this program') @@ -217,10 +220,12 @@ describe('CustomValueModal', () => { const onClose = vi.fn() const user = userEvent.setup() const { store } = await renderWithAppWrapper( - , + , buildMockOptions(['s1.de1']) ) + await selectCustomValueMode(user) + await waitFor(() => { expect(screen.getByText('Weight in kg')).toBeInTheDocument() }) @@ -245,10 +250,12 @@ describe('CustomValueModal', () => { it('filters the data item list by the search term', async () => { const user = userEvent.setup() await renderWithAppWrapper( - {}} />, + {}} />, buildMockOptions(['s1.de1']) ) + await selectCustomValueMode(user) + await waitFor(() => { expect(screen.getByText('Weight in kg')).toBeInTheDocument() expect(screen.getByText('Height in cm')).toBeInTheDocument() @@ -274,7 +281,7 @@ describe('CustomValueModal', () => { const onClose = vi.fn() const user = userEvent.setup() const { store } = await renderWithAppWrapper( - , + , buildMockOptions(['s1.de1'], { [ANALYTICS_RESOURCE]: { dimensions: [ @@ -289,6 +296,8 @@ describe('CustomValueModal', () => { }) ) + await selectCustomValueMode(user) + await waitFor(() => { expect(screen.getByText('Gender score')).toBeInTheDocument() }) @@ -305,7 +314,7 @@ describe('CustomValueModal', () => { it('disables "Use item default" and selects Average when the item default is NONE', async () => { const user = userEvent.setup() await renderWithAppWrapper( - {}} />, + {}} />, buildMockOptions(['s1.de1'], { [ANALYTICS_RESOURCE]: { dimensions: [ @@ -326,6 +335,8 @@ describe('CustomValueModal', () => { }) ) + await selectCustomValueMode(user) + await waitFor(() => { expect(screen.getByText('Gender score')).toBeInTheDocument() }) @@ -350,7 +361,7 @@ describe('CustomValueModal', () => { it('reverts to "Use item default" when switching from a NONE item back to an aggregatable one', async () => { const user = userEvent.setup() await renderWithAppWrapper( - {}} />, + {}} />, buildMockOptions(['s1.de1'], { [ANALYTICS_RESOURCE]: { dimensions: [ @@ -371,6 +382,8 @@ describe('CustomValueModal', () => { }) ) + await selectCustomValueMode(user) + await waitFor(() => { expect(screen.getByText('Gender score')).toBeInTheDocument() }) diff --git a/src/components/layout-panel/custom-value-modal/__tests__/custom-value-option.spec.tsx b/src/components/layout-panel/cell-value-modal/__tests__/custom-value-option.spec.tsx similarity index 100% rename from src/components/layout-panel/custom-value-modal/__tests__/custom-value-option.spec.tsx rename to src/components/layout-panel/cell-value-modal/__tests__/custom-value-option.spec.tsx diff --git a/src/components/layout-panel/cell-value-modal/__tests__/use-cell-value-items.spec.ts b/src/components/layout-panel/cell-value-modal/__tests__/use-cell-value-items.spec.ts new file mode 100644 index 00000000..3665cb52 --- /dev/null +++ b/src/components/layout-panel/cell-value-modal/__tests__/use-cell-value-items.spec.ts @@ -0,0 +1,222 @@ +import { + visUiConfigSlice, + initialState as visUiConfigInitialState, +} from '@store/vis-ui-config-slice' +import { + renderHookWithAppWrapper, + type MockOptions, +} from '@test-utils/app-wrapper' +import { createDeferredQuery } from '@test-utils/deferred-query' +import { waitFor } from '@testing-library/react' +import { describe, it, expect } from 'vitest' +import { useCellValueItems } from '../use-cell-value-items' + +const ANALYTICS_RESOURCE = 'analytics/enrollments/aggregate/dimensions' + +const stage1 = { + id: 's1', + name: 'Stage 1', + repeatable: false, + hideDueDate: false, + program: { id: 'p1' }, +} +const stage2 = { + id: 's2', + name: 'Stage 2', + repeatable: false, + hideDueDate: false, + program: { id: 'p1' }, +} +const singleStage = { + id: 'sX', + name: 'Stage X', + repeatable: false, + hideDueDate: false, + program: { id: 'pSingle' }, +} + +const metadata = { + p1: { + id: 'p1', + name: 'Program 1', + programType: 'WITH_REGISTRATION', + programStages: [stage1, stage2], + trackedEntityType: { id: 'tet1', name: 'Person' }, + }, + pSingle: { + id: 'pSingle', + name: 'Single-stage program', + programType: 'WITH_REGISTRATION', + programStages: [singleStage], + }, + s1: stage1, + s2: stage2, + sX: singleStage, +} + +const analyticsResponse = { + dimensions: [ + { + id: 's1.de1', + name: 'DE 1', + aggregationType: 'SUM', + dimensionType: 'DATA_ELEMENT', + }, + { + id: 's2.de2', + name: 'DE 2', + aggregationType: 'AVERAGE', + dimensionType: 'DATA_ELEMENT', + }, + ], +} + +const buildMockOptions = ( + queryData: MockOptions['queryData'] = { + [ANALYTICS_RESOURCE]: analyticsResponse, + } +): MockOptions => ({ + metadata, + queryData, + partialStore: { + reducer: { visUiConfig: visUiConfigSlice.reducer }, + preloadedState: { visUiConfig: visUiConfigInitialState }, + }, +}) + +describe('useCellValueItems', () => { + it('returns items from every stage of the program, labelled with their stage', async () => { + const { result } = await renderHookWithAppWrapper( + () => useCellValueItems('p1'), + buildMockOptions() + ) + + await waitFor(() => { + expect(result.current.items).toBeDefined() + }) + + expect(result.current.items).toEqual([ + { + id: 's1.de1', + name: 'DE 1', + aggregationType: 'SUM', + dimensionType: 'DATA_ELEMENT', + stageName: 'Stage 1', + }, + { + id: 's2.de2', + name: 'DE 2', + aggregationType: 'AVERAGE', + dimensionType: 'DATA_ELEMENT', + stageName: 'Stage 2', + }, + ]) + }) + + it('sorts data items alphabetically by name regardless of API order', async () => { + const { result } = await renderHookWithAppWrapper( + () => useCellValueItems('p1'), + buildMockOptions({ + [ANALYTICS_RESOURCE]: { + dimensions: [...analyticsResponse.dimensions].reverse(), + }, + }) + ) + + await waitFor(() => { + expect(result.current.items).toBeDefined() + }) + + expect(result.current.items?.map((item) => item.name)).toEqual([ + 'DE 1', + 'DE 2', + ]) + }) + + it('omits stageName when the program has only one stage', async () => { + const { result } = await renderHookWithAppWrapper( + () => useCellValueItems('pSingle'), + buildMockOptions({ + [ANALYTICS_RESOURCE]: { + dimensions: [ + { + id: 'sX.de1', + name: 'DE 1', + aggregationType: 'SUM', + dimensionType: 'DATA_ELEMENT', + }, + ], + }, + }) + ) + + await waitFor(() => { + expect(result.current.items).toBeDefined() + }) + + expect(result.current.items).toEqual([ + { + id: 'sX.de1', + name: 'DE 1', + aggregationType: 'SUM', + dimensionType: 'DATA_ELEMENT', + }, + ]) + }) + + it('labels program attributes with the tracked entity type name', async () => { + const { result } = await renderHookWithAppWrapper( + () => useCellValueItems('p1'), + buildMockOptions({ + [ANALYTICS_RESOURCE]: { + dimensions: [ + { + id: 'attr1', + name: 'Age', + aggregationType: 'NONE', + dimensionType: 'PROGRAM_ATTRIBUTE', + }, + ], + }, + }) + ) + + await waitFor(() => { + expect(result.current.items).toBeDefined() + }) + + expect(result.current.items).toEqual([ + { + id: 'attr1', + name: 'Age', + aggregationType: 'NONE', + dimensionType: 'PROGRAM_ATTRIBUTE', + stageName: 'Person', + }, + ]) + }) + + it('returns undefined items while loading', async () => { + /* Hold the dimensions request in flight so the loading assertion is + * deterministic. Without this, the query can resolve during the + * wrapper's internal store wait, flipping isLoading to false before + * the assertion under full-suite load. */ + const deferredDimensions = createDeferredQuery() + const { result } = await renderHookWithAppWrapper( + () => useCellValueItems('p1'), + buildMockOptions({ + [ANALYTICS_RESOURCE]: deferredDimensions.defer( + () => analyticsResponse + ), + } as MockOptions['queryData']) + ) + + expect(result.current.isLoading).toBe(true) + expect(result.current.items).toBeUndefined() + + await deferredDimensions.releaseAll() + await waitFor(() => { + expect(result.current.items).toBeDefined() + }) + }) +}) diff --git a/src/components/layout-panel/cell-value-modal/cell-value-modal.tsx b/src/components/layout-panel/cell-value-modal/cell-value-modal.tsx new file mode 100644 index 00000000..32fb6b68 --- /dev/null +++ b/src/components/layout-panel/cell-value-modal/cell-value-modal.tsx @@ -0,0 +1,127 @@ +import { + RadioCard, + RadioCardGroup, +} from '@components/shared/radio-card/radio-card' +import i18n from '@dhis2/d2-i18n' +import { + Button, + ButtonStrip, + Modal, + ModalActions, + ModalContent, + ModalTitle, + Tooltip, +} from '@dhis2/ui' +import { useAppDispatch, useAppSelector, useLayoutContext } from '@hooks' +import { tUpdateCurrentVisFromVisUiConfig } from '@store/thunks' +import { + clearVisUiConfigCustomValue, + getVisUiConfigCustomValue, + setVisUiConfigCustomValue, + type CustomValueObject, +} from '@store/vis-ui-config-slice' +import { type FC, useCallback, useState } from 'react' +import { CustomValueItemPicker } from './custom-value-item-picker' +import classes from './styles/cell-value-modal.module.css' + +type CellValueModalProps = { + onClose: () => void +} + +type CellValueMode = 'COUNT' | 'CUSTOM_VALUE' + +export const CellValueModal: FC = ({ onClose }) => { + const dispatch = useAppDispatch() + const { programIds } = useLayoutContext() + const storedCustomValue = useAppSelector(getVisUiConfigCustomValue) + const [mode, setMode] = useState( + storedCustomValue ? 'CUSTOM_VALUE' : 'COUNT' + ) + const [customValue, setCustomValue] = useState< + CustomValueObject | undefined + >(storedCustomValue) + + const onSelectCount = useCallback(() => setMode('COUNT'), []) + const onSelectCustomValue = useCallback(() => setMode('CUSTOM_VALUE'), []) + + const onUpdate = useCallback(() => { + if (mode === 'COUNT') { + dispatch(clearVisUiConfigCustomValue()) + } else if (customValue) { + dispatch(setVisUiConfigCustomValue(customValue)) + } + dispatch(tUpdateCurrentVisFromVisUiConfig()) + onClose() + }, [customValue, dispatch, mode, onClose]) + + const isUpdateDisabled = mode === 'CUSTOM_VALUE' && !customValue + + return ( + + {i18n.t('Cell value')} + + + + + + + + + + + + {isUpdateDisabled ? ( + + {(tooltipProps: object) => ( + + + + )} + + ) : ( + + )} + + + + ) +} diff --git a/src/components/layout-panel/cell-value-modal/custom-value-item-picker.tsx b/src/components/layout-panel/cell-value-modal/custom-value-item-picker.tsx new file mode 100644 index 00000000..50f78932 --- /dev/null +++ b/src/components/layout-panel/cell-value-modal/custom-value-item-picker.tsx @@ -0,0 +1,180 @@ +import { + AGGREGATION_TYPES, + aggregationTypeDisplayNames, +} from '@constants/aggregation-types' +import i18n from '@dhis2/d2-i18n' +import { + CircularLoader, + InputField, + NoticeBox, + SingleSelectField, + SingleSelectOption, +} from '@dhis2/ui' +import { useMetadataStore, useStableCallback } from '@hooks' +import type { CustomValueObject } from '@store/vis-ui-config-slice' +import type { AggregationType } from '@types' +import { useEffect, useMemo, useState, type FC } from 'react' +import { CustomValueOption } from './custom-value-option' +import classes from './styles/custom-value-item-picker.module.css' +import { useCellValueItems } from './use-cell-value-items' + +/* An item whose metadata aggregation type is NONE cannot be aggregated: the + * analytics API returns 0 for every cell. Many tracked entity attributes (and + * some data elements) carry NONE, so "Use item default" is disabled for them + * and AVERAGE — a neutral numeric choice the user can override — is selected + * instead. */ +const FALLBACK_AGGREGATION_TYPE_FOR_NONE: AggregationType = 'AVERAGE' + +type CustomValueItemPickerProps = { + programId: string + initialCustomValue: CustomValueObject | undefined + /* Reports the choice ready to be stored — with the item's own aggregation + * type already resolved — or undefined while no item is selected. */ + onChange: (customValue: CustomValueObject | undefined) => void +} + +export const CustomValueItemPicker: FC = ({ + programId, + initialCustomValue, + onChange, +}) => { + const metadataStore = useMetadataStore() + const [searchTerm, setSearchTerm] = useState('') + const [selectedItemId, setSelectedItemId] = useState(initialCustomValue?.id) + const [aggregationType, setAggregationType] = useState( + initialCustomValue?.aggregationType ?? 'DEFAULT' + ) + const { items, isLoading, isError, error } = useCellValueItems(programId) + + const visibleItems = useMemo(() => { + const term = searchTerm.trim().toLocaleLowerCase() + if (!term) { + return items + } + return items?.filter((item) => + item.name.toLocaleLowerCase().includes(term) + ) + }, [items, searchTerm]) + + const { selectedItemDefaultIsNone, selectedAggregationType, customValue } = + useMemo(() => { + const selectedItem = items?.find( + (item) => item.id === selectedItemId + ) + const selectedItemDefaultIsNone = + selectedItem?.aggregationType === 'NONE' + const itemDefaultAggregationType = selectedItemDefaultIsNone + ? FALLBACK_AGGREGATION_TYPE_FOR_NONE + : selectedItem?.aggregationType + return { + selectedItemDefaultIsNone, + selectedAggregationType: + aggregationType === 'DEFAULT' && selectedItemDefaultIsNone + ? FALLBACK_AGGREGATION_TYPE_FOR_NONE + : aggregationType, + customValue: selectedItem + ? { + id: selectedItem.id, + aggregationType: + aggregationType === 'DEFAULT' + ? (itemDefaultAggregationType as AggregationType) + : aggregationType, + } + : undefined, + } + }, [aggregationType, items, selectedItemId]) + + /* The choice is only resolvable once the items are loaded, so it is + * reported up rather than derived by the parent from the click alone. */ + const reportChange = useStableCallback(onChange) + useEffect(() => { + reportChange(customValue) + }, [customValue, reportChange]) + + return ( + <> + {!isLoading && !isError && items?.length !== 0 && ( +
+ setSearchTerm(value ?? '')} + placeholder={i18n.t('Search data items')} + dataTest="custom-value-item-picker-search-field" + dense + initialFocus + type="search" + /> +
+ )} +
+ {isLoading && ( +
+ + {i18n.t('Loading data')} +
+ )} + {isError && ( + + {error?.message || i18n.t('Failed to load data items')} + + )} + {!isLoading && !isError && items?.length === 0 && ( + + {i18n.t( + 'This program does not have any numeric data items available.' + )} + + )} + {!isLoading && + !isError && + items?.length !== 0 && + visibleItems?.length === 0 && ( +
+ {i18n.t('No data items match "{{- searchTerm}}"', { + searchTerm, + })} +
+ )} + {!isLoading && + !isError && + visibleItems?.map((item) => ( + { + metadataStore.addMetadata(item) + setSelectedItemId(item.id) + }} + /> + ))} +
+
+ + setAggregationType(selected as AggregationType) + } + selected={selectedAggregationType} + dense + > + {AGGREGATION_TYPES.map((value) => ( + + ))} + +
+ + ) +} diff --git a/src/components/layout-panel/custom-value-modal/custom-value-option.tsx b/src/components/layout-panel/cell-value-modal/custom-value-option.tsx similarity index 100% rename from src/components/layout-panel/custom-value-modal/custom-value-option.tsx rename to src/components/layout-panel/cell-value-modal/custom-value-option.tsx diff --git a/src/components/layout-panel/cell-value-modal/index.ts b/src/components/layout-panel/cell-value-modal/index.ts new file mode 100644 index 00000000..fc70c4a6 --- /dev/null +++ b/src/components/layout-panel/cell-value-modal/index.ts @@ -0,0 +1 @@ +export { CellValueModal } from './cell-value-modal' diff --git a/src/components/layout-panel/cell-value-modal/styles/cell-value-modal.module.css b/src/components/layout-panel/cell-value-modal/styles/cell-value-modal.module.css new file mode 100644 index 00000000..19ccc9d0 --- /dev/null +++ b/src/components/layout-panel/cell-value-modal/styles/cell-value-modal.module.css @@ -0,0 +1,5 @@ +.content { + display: flex; + flex-direction: column; + min-block-size: 200px; +} diff --git a/src/components/layout-panel/custom-value-modal/styles/custom-value-modal.module.css b/src/components/layout-panel/cell-value-modal/styles/custom-value-item-picker.module.css similarity index 76% rename from src/components/layout-panel/custom-value-modal/styles/custom-value-modal.module.css rename to src/components/layout-panel/cell-value-modal/styles/custom-value-item-picker.module.css index 2ac89f10..26dda3de 100644 --- a/src/components/layout-panel/custom-value-modal/styles/custom-value-modal.module.css +++ b/src/components/layout-panel/cell-value-modal/styles/custom-value-item-picker.module.css @@ -1,24 +1,12 @@ -.content { - display: flex; - flex-direction: column; - min-block-size: 200px; -} - -.description { - margin: 0 0 var(--spacers-dp16) 0; - color: var(--colors-grey700); - font-size: 14px; -} - .search { margin-block-end: var(--spacers-dp8); } .listContainer { - flex: 1; overflow-y: auto; min-block-size: 200px; - max-block-size: 400px; + max-block-size: 300px; + background-color: var(--colors-white); border: 1px solid var(--colors-grey500); border-radius: 3px; } diff --git a/src/components/layout-panel/custom-value-modal/styles/custom-value-option.module.css b/src/components/layout-panel/cell-value-modal/styles/custom-value-option.module.css similarity index 100% rename from src/components/layout-panel/custom-value-modal/styles/custom-value-option.module.css rename to src/components/layout-panel/cell-value-modal/styles/custom-value-option.module.css diff --git a/src/components/layout-panel/custom-value-modal/use-custom-value-items.ts b/src/components/layout-panel/cell-value-modal/use-cell-value-items.ts similarity index 52% rename from src/components/layout-panel/custom-value-modal/use-custom-value-items.ts rename to src/components/layout-panel/cell-value-modal/use-cell-value-items.ts index 7155f85e..5b69571e 100644 --- a/src/components/layout-panel/custom-value-modal/use-custom-value-items.ts +++ b/src/components/layout-panel/cell-value-modal/use-cell-value-items.ts @@ -1,12 +1,5 @@ import { NUMERIC_VALUE_TYPES } from '@constants/value-types' -import { - useAppSelector, - useCurrentUser, - useLayoutContext, - useMetadataStore, - useRtkQuery, -} from '@hooks' -import { getVisUiConfigCustomValue } from '@store/vis-ui-config-slice' +import { useCurrentUser, useMetadataStore, useRtkQuery } from '@hooks' import type { AggregationType } from '@types' import { useMemo } from 'react' @@ -23,12 +16,7 @@ export type CustomValueItem = CustomValueDimension & { stageName?: string } -export const getStageIdFromDimensionId = ( - id: string | undefined -): string | null => { - if (!id) { - return null - } +const getStageIdFromDimensionId = (id: string): string | null => { const idParts = id.split('.') return idParts.length === 2 ? idParts[0] : null } @@ -36,39 +24,11 @@ export const getStageIdFromDimensionId = ( const compareByName = (a: CustomValueDimension, b: CustomValueDimension) => a.name.localeCompare(b.name) -export const useCustomValueItems = () => { +export const useCellValueItems = (programId: string) => { const { settings: { displayNameProperty }, } = useCurrentUser() const metadataStore = useMetadataStore() - const { programIds, programStageIds } = useLayoutContext() - const customValue = useAppSelector(getVisUiConfigCustomValue) - - if (programIds.length !== 1) { - throw new Error( - `useCustomValueItems requires exactly one program in the layout, got ${programIds.length}` - ) - } - if (programStageIds.length > 1) { - throw new Error( - `useCustomValueItems requires at most one program stage in the layout, got ${programStageIds.length}` - ) - } - - const programId = programIds[0] - const layoutStageId = programStageIds[0] ?? null - - let filteredByStageName: string | undefined - let customValueStageMismatch = false - if (layoutStageId) { - filteredByStageName = - metadataStore.getProgramStageMetadataItemOrThrow(layoutStageId).name - - const customValueStageId = getStageIdFromDimensionId(customValue?.id) - customValueStageMismatch = Boolean( - customValueStageId && customValueStageId !== layoutStageId - ) - } const { data, ...queryResult } = useRtkQuery<{ dimensions: CustomValueDimension[] @@ -94,21 +54,6 @@ export const useCustomValueItems = () => { return undefined } - if (layoutStageId) { - return data.dimensions - .filter( - (dim) => - dim.dimensionType === 'PROGRAM_ATTRIBUTE' || - getStageIdFromDimensionId(dim.id) === layoutStageId - ) - .map((dim) => - dim.dimensionType === 'PROGRAM_ATTRIBUTE' && tetName - ? { ...dim, stageName: tetName } - : dim - ) - .sort(compareByName) - } - return data.dimensions .map((dim) => { if (dim.dimensionType === 'PROGRAM_ATTRIBUTE') { @@ -123,12 +68,7 @@ export const useCustomValueItems = () => { return { ...dim, stageName: stage.name } }) .sort(compareByName) - }, [data, layoutStageId, metadataStore, programHasMultipleStages, tetName]) + }, [data, metadataStore, programHasMultipleStages, tetName]) - return { - ...queryResult, - items, - filteredByStageName, - customValueStageMismatch, - } + return { ...queryResult, items } } diff --git a/src/components/layout-panel/custom-value-modal/__tests__/use-custom-value-items.spec.ts b/src/components/layout-panel/custom-value-modal/__tests__/use-custom-value-items.spec.ts deleted file mode 100644 index 7264deb7..00000000 --- a/src/components/layout-panel/custom-value-modal/__tests__/use-custom-value-items.spec.ts +++ /dev/null @@ -1,471 +0,0 @@ -import { - visUiConfigSlice, - initialState as visUiConfigInitialState, - type VisUiConfigState, - type CustomValueObject, -} from '@store/vis-ui-config-slice' -import { - renderHookWithAppWrapper, - type MockOptions, -} from '@test-utils/app-wrapper' -import { createDeferredQuery } from '@test-utils/deferred-query' -import { waitFor } from '@testing-library/react' -import type { RootState } from '@types' -import deepmerge from 'deepmerge' -import { describe, it, expect } from 'vitest' -import { useCustomValueItems } from '../use-custom-value-items' - -const ANALYTICS_RESOURCE = 'analytics/enrollments/aggregate/dimensions' - -const stage1 = { - id: 's1', - name: 'Stage 1', - repeatable: false, - hideDueDate: false, - program: { id: 'p1' }, -} -const stage2 = { - id: 's2', - name: 'Stage 2', - repeatable: false, - hideDueDate: false, - program: { id: 'p1' }, -} -const metadata = { - p1: { - id: 'p1', - name: 'Program 1', - programType: 'WITH_REGISTRATION', - programStages: [stage1, stage2], - trackedEntityType: { id: 'tet1', name: 'Person' }, - }, - p2: { - id: 'p2', - name: 'Program 2', - programType: 'WITH_REGISTRATION', - programStages: [], - }, - s1: stage1, - s2: stage2, - 'p1.enrollmentDate': { - id: 'p1.enrollmentDate', - name: 'Enrollment Date', - dimensionType: 'PERIOD', - valueType: 'DATE', - }, - 's1.de1': { - id: 's1.de1', - name: 'DE 1', - dimensionType: 'DATA_ELEMENT', - valueType: 'NUMBER', - }, - 's2.de2': { - id: 's2.de2', - name: 'DE 2', - dimensionType: 'DATA_ELEMENT', - valueType: 'NUMBER', - }, - 'p2.enrollmentDate': { - id: 'p2.enrollmentDate', - name: 'Enrollment Date P2', - dimensionType: 'PERIOD', - valueType: 'DATE', - }, -} - -const analyticsResponse = { - dimensions: [ - { - id: 's1.de1', - name: 'DE 1', - aggregationType: 'SUM', - dimensionType: 'DATA_ELEMENT', - }, - { - id: 's2.de2', - name: 'DE 2', - aggregationType: 'AVERAGE', - dimensionType: 'DATA_ELEMENT', - }, - ], -} - -const initialPreloadedState: Partial = { - visUiConfig: visUiConfigInitialState, -} - -const buildMockOptions = ( - layoutOverride: Partial, - customValue?: CustomValueObject -) => ({ - metadata, - queryData: { - [ANALYTICS_RESOURCE]: analyticsResponse, - }, - partialStore: { - reducer: { visUiConfig: visUiConfigSlice.reducer }, - preloadedState: deepmerge(initialPreloadedState, { - visUiConfig: { - layout: { - ...visUiConfigInitialState.layout, - ...layoutOverride, - }, - customValue, - }, - }), - }, -}) - -/* The hook's three precondition throws (0 / >1 programs, >1 stages) are not - * exercised here. They fire from inside React render so RTL surfaces them - * as uncaught exceptions rather than rejected promises, which requires an - * error-boundary wrapper to assert cleanly. Those states are upstream-gated - * by `useActionButton` — that's the appropriate test surface. */ -describe('useCustomValueItems', () => { - it('attaches stageName when the layout has no program stage and dimensions span multiple stages', async () => { - const { result } = await renderHookWithAppWrapper( - () => useCustomValueItems(), - buildMockOptions({ columns: ['p1.enrollmentDate'] }) - ) - - await waitFor(() => { - expect(result.current.items).toBeDefined() - }) - - expect(result.current.items).toEqual([ - { - id: 's1.de1', - name: 'DE 1', - aggregationType: 'SUM', - dimensionType: 'DATA_ELEMENT', - stageName: 'Stage 1', - }, - { - id: 's2.de2', - name: 'DE 2', - aggregationType: 'AVERAGE', - dimensionType: 'DATA_ELEMENT', - stageName: 'Stage 2', - }, - ]) - expect(result.current.filteredByStageName).toBeUndefined() - }) - - it('sorts data items alphabetically by name regardless of API order', async () => { - const outOfOrderResponse = { - dimensions: [ - { - id: 's2.de2', - name: 'DE 2', - aggregationType: 'AVERAGE', - dimensionType: 'DATA_ELEMENT', - }, - { - id: 's1.de1', - name: 'DE 1', - aggregationType: 'SUM', - dimensionType: 'DATA_ELEMENT', - }, - ], - } - const { result } = await renderHookWithAppWrapper( - () => useCustomValueItems(), - { - ...buildMockOptions({ columns: ['p1.enrollmentDate'] }), - queryData: { - [ANALYTICS_RESOURCE]: outOfOrderResponse, - }, - } - ) - - await waitFor(() => { - expect(result.current.items).toBeDefined() - }) - - expect(result.current.items?.map((item) => item.name)).toEqual([ - 'DE 1', - 'DE 2', - ]) - }) - - it('omits stageName when the layout has no program stage and the program has only one stage', async () => { - const singleStage = { - id: 'sX', - name: 'Stage X', - repeatable: false, - hideDueDate: false, - program: { id: 'pSingle' }, - } - const singleStageMetadata = { - pSingle: { - id: 'pSingle', - name: 'Single-stage program', - programType: 'WITH_REGISTRATION', - programStages: [singleStage], - }, - sX: singleStage, - 'pSingle.enrollmentDate': { - id: 'pSingle.enrollmentDate', - name: 'Enrollment Date (single)', - dimensionType: 'PERIOD', - valueType: 'DATE', - }, - 'sX.de1': { - id: 'sX.de1', - name: 'DE 1', - dimensionType: 'DATA_ELEMENT', - valueType: 'NUMBER', - }, - 'sX.deOther': { - id: 'sX.deOther', - name: 'DE Other', - dimensionType: 'DATA_ELEMENT', - valueType: 'NUMBER', - }, - } - const singleStageResponse = { - dimensions: [ - { - id: 'sX.de1', - name: 'DE 1', - aggregationType: 'SUM', - dimensionType: 'DATA_ELEMENT', - }, - { - id: 'sX.deOther', - name: 'DE Other', - aggregationType: 'AVERAGE', - dimensionType: 'DATA_ELEMENT', - }, - ], - } - const { result } = await renderHookWithAppWrapper( - () => useCustomValueItems(), - { - ...buildMockOptions({ columns: ['pSingle.enrollmentDate'] }), - metadata: singleStageMetadata, - queryData: { - [ANALYTICS_RESOURCE]: singleStageResponse, - }, - } - ) - - await waitFor(() => { - expect(result.current.items).toBeDefined() - }) - - expect(result.current.items).toEqual([ - { - id: 'sX.de1', - name: 'DE 1', - aggregationType: 'SUM', - dimensionType: 'DATA_ELEMENT', - }, - { - id: 'sX.deOther', - name: 'DE Other', - aggregationType: 'AVERAGE', - dimensionType: 'DATA_ELEMENT', - }, - ]) - }) - - it('filters by stage and exposes the layout stage name when one stage is in the layout', async () => { - const { result } = await renderHookWithAppWrapper( - () => useCustomValueItems(), - buildMockOptions({ columns: ['s1.de1'] }) - ) - - await waitFor(() => { - expect(result.current.items).toBeDefined() - }) - - expect(result.current.items).toEqual([ - { - id: 's1.de1', - name: 'DE 1', - aggregationType: 'SUM', - dimensionType: 'DATA_ELEMENT', - }, - ]) - expect(result.current.filteredByStageName).toBe('Stage 1') - }) - - it('flags customValueStageMismatch when the custom value stage differs from the layout stage', async () => { - const { result } = await renderHookWithAppWrapper( - () => useCustomValueItems(), - buildMockOptions( - { columns: ['s1.de1'] }, - { id: 's2.de2', aggregationType: 'SUM' } - ) - ) - - await waitFor(() => { - expect(result.current.items).toBeDefined() - }) - - expect(result.current.customValueStageMismatch).toBe(true) - }) - - it('does not flag a mismatch when the custom value stage matches the layout stage', async () => { - const { result } = await renderHookWithAppWrapper( - () => useCustomValueItems(), - buildMockOptions( - { columns: ['s1.de1'] }, - { id: 's1.de1', aggregationType: 'SUM' } - ) - ) - - await waitFor(() => { - expect(result.current.items).toBeDefined() - }) - - expect(result.current.customValueStageMismatch).toBe(false) - }) - - it('does not flag a mismatch when there is no layout stage', async () => { - const { result } = await renderHookWithAppWrapper( - () => useCustomValueItems(), - buildMockOptions( - { columns: ['p1.enrollmentDate'] }, - { id: 's1.de1', aggregationType: 'SUM' } - ) - ) - - await waitFor(() => { - expect(result.current.items).toBeDefined() - }) - - expect(result.current.customValueStageMismatch).toBe(false) - }) - - it('includes program attributes and labels them with the tracked entity type name when no stage is in the layout', async () => { - const responseWithAttribute = { - dimensions: [ - { - id: 's1.de1', - name: 'DE 1', - aggregationType: 'SUM', - dimensionType: 'DATA_ELEMENT', - }, - { - id: 'attr1', - name: 'Age', - aggregationType: 'NONE', - dimensionType: 'PROGRAM_ATTRIBUTE', - }, - ], - } - const { result } = await renderHookWithAppWrapper( - () => useCustomValueItems(), - { - ...buildMockOptions({ columns: ['p1.enrollmentDate'] }), - queryData: { - [ANALYTICS_RESOURCE]: responseWithAttribute, - }, - } - ) - - await waitFor(() => { - expect(result.current.items).toBeDefined() - }) - - expect(result.current.items).toEqual([ - { - id: 'attr1', - name: 'Age', - aggregationType: 'NONE', - dimensionType: 'PROGRAM_ATTRIBUTE', - stageName: 'Person', - }, - { - id: 's1.de1', - name: 'DE 1', - aggregationType: 'SUM', - dimensionType: 'DATA_ELEMENT', - stageName: 'Stage 1', - }, - ]) - }) - - it('keeps program attributes but filters out other stages when one stage is in the layout', async () => { - const responseWithAttribute = { - dimensions: [ - { - id: 's1.de1', - name: 'DE 1', - aggregationType: 'SUM', - dimensionType: 'DATA_ELEMENT', - }, - { - id: 's2.de2', - name: 'DE 2', - aggregationType: 'AVERAGE', - dimensionType: 'DATA_ELEMENT', - }, - { - id: 'attr1', - name: 'Age', - aggregationType: 'NONE', - dimensionType: 'PROGRAM_ATTRIBUTE', - }, - ], - } - const { result } = await renderHookWithAppWrapper( - () => useCustomValueItems(), - { - ...buildMockOptions({ columns: ['s1.de1'] }), - queryData: { - [ANALYTICS_RESOURCE]: responseWithAttribute, - }, - } - ) - - await waitFor(() => { - expect(result.current.items).toBeDefined() - }) - - expect(result.current.items).toEqual([ - { - id: 'attr1', - name: 'Age', - aggregationType: 'NONE', - dimensionType: 'PROGRAM_ATTRIBUTE', - stageName: 'Person', - }, - { - id: 's1.de1', - name: 'DE 1', - aggregationType: 'SUM', - dimensionType: 'DATA_ELEMENT', - }, - ]) - }) - - it('returns undefined items while loading', async () => { - /* Hold the dimensions request in flight so the loading assertion is - * deterministic. Without this, the query can resolve during the - * wrapper's internal store wait, flipping isLoading to false before - * the assertion under full-suite load. */ - const deferredDimensions = createDeferredQuery() - const { result } = await renderHookWithAppWrapper( - () => useCustomValueItems(), - { - ...buildMockOptions({ columns: ['p1.enrollmentDate'] }), - queryData: { - [ANALYTICS_RESOURCE]: deferredDimensions.defer( - () => analyticsResponse - ), - } as MockOptions['queryData'], - } - ) - - expect(result.current.isLoading).toBe(true) - expect(result.current.items).toBeUndefined() - - await deferredDimensions.releaseAll() - await waitFor(() => { - expect(result.current.items).toBeDefined() - }) - }) -}) diff --git a/src/components/layout-panel/custom-value-modal/custom-value-modal.tsx b/src/components/layout-panel/custom-value-modal/custom-value-modal.tsx deleted file mode 100644 index b9fab7b6..00000000 --- a/src/components/layout-panel/custom-value-modal/custom-value-modal.tsx +++ /dev/null @@ -1,288 +0,0 @@ -import { - AGGREGATION_TYPES, - aggregationTypeDisplayNames, -} from '@constants/aggregation-types' -import i18n from '@dhis2/d2-i18n' -import { - Button, - ButtonStrip, - CircularLoader, - InputField, - Modal, - ModalActions, - ModalContent, - ModalTitle, - NoticeBox, - SingleSelectField, - SingleSelectOption, - Tooltip, -} from '@dhis2/ui' -import { - useAppDispatch, - useAppSelector, - useMetadataItem, - useMetadataStore, -} from '@hooks' -import { tUpdateCurrentVisFromVisUiConfig } from '@store/thunks' -import { - getVisUiConfigCustomValue, - setVisUiConfigCustomValue, - setVisUiConfigOutputType, -} from '@store/vis-ui-config-slice' -import type { AggregationType } from '@types' -import { type FC, useCallback, useMemo, useState } from 'react' -import { CustomValueOption } from './custom-value-option' -import { StageNotice } from './stage-notice' -import classes from './styles/custom-value-modal.module.css' -import { - useCustomValueItems, - type CustomValueItem, -} from './use-custom-value-items' - -type CustomValueModalProps = { - onClose: () => void -} - -/* An item whose metadata aggregation type is NONE cannot be aggregated: the - * analytics API returns 0 for every cell. Many tracked entity attributes (and - * some data elements) carry NONE, so "Use item default" is disabled for them - * and AVERAGE — a neutral numeric choice the user can override — is selected - * instead. */ -const FALLBACK_AGGREGATION_TYPE_FOR_NONE: AggregationType = 'AVERAGE' - -export const CustomValueModal: FC = ({ onClose }) => { - const dispatch = useAppDispatch() - const metadataStore = useMetadataStore() - const customValue = useAppSelector(getVisUiConfigCustomValue) - const customValueMetadata = useMetadataItem(customValue?.id) - const [aggregationType, setAggregationType] = useState( - customValue?.aggregationType ?? 'DEFAULT' - ) - const [selectedItemId, setSelectedItemId] = useState(customValue?.id) - const [searchTerm, setSearchTerm] = useState('') - - const { - items, - isLoading, - isError, - error, - filteredByStageName, - customValueStageMismatch, - } = useCustomValueItems() - - const visibleItems = useMemo(() => { - const term = searchTerm.trim().toLocaleLowerCase() - if (!term) { - return items - } - return items?.filter((item) => - item.name.toLocaleLowerCase().includes(term) - ) - }, [items, searchTerm]) - - const onAggregationTypeChange = useCallback( - ({ selected }: { selected: string }) => - setAggregationType(selected as AggregationType), - [] - ) - - const onItemChange = useCallback( - (item: CustomValueItem) => { - setSelectedItemId(item.id) - metadataStore.addMetadata(item) - }, - [metadataStore] - ) - - const { selectedItem, selectedItemDefaultIsNone, selectedAggregationType } = - useMemo(() => { - const selectedItem = items?.find( - (item) => item.id === selectedItemId - ) - const selectedItemDefaultIsNone = - selectedItem?.aggregationType === 'NONE' - const selectedAggregationType = - aggregationType === 'DEFAULT' && selectedItemDefaultIsNone - ? FALLBACK_AGGREGATION_TYPE_FOR_NONE - : aggregationType - return { - selectedItem, - selectedItemDefaultIsNone, - selectedAggregationType, - } - }, [aggregationType, items, selectedItemId]) - - const onUpdate = useCallback(() => { - if (selectedItem) { - const itemDefaultAggregationType = - selectedItem.aggregationType === 'NONE' - ? FALLBACK_AGGREGATION_TYPE_FOR_NONE - : selectedItem.aggregationType - const resolvedAggregationType = - aggregationType === 'DEFAULT' - ? itemDefaultAggregationType - : aggregationType - dispatch( - setVisUiConfigCustomValue({ - aggregationType: resolvedAggregationType, - id: selectedItem.id, - }) - ) - dispatch(setVisUiConfigOutputType('EVENT')) - dispatch(tUpdateCurrentVisFromVisUiConfig(true)) - } - - onClose() - }, [dispatch, aggregationType, selectedItem, onClose]) - - return ( - - {i18n.t('Configure custom value')} - -

- {i18n.t( - 'Choose the numeric data item to show in table cells.' - )} -

- - {!isLoading && !isError && items?.length !== 0 && ( -
- setSearchTerm(value ?? '')} - placeholder={i18n.t('Search data items')} - dataTest="custom-value-modal-search-field" - dense - initialFocus - type="search" - /> -
- )} -
- {isLoading && ( -
- - {i18n.t('Loading data')} -
- )} - {isError && ( - - {error?.message || - i18n.t('Failed to load data items')} - - )} - {!isLoading && !isError && items?.length === 0 && ( - - {filteredByStageName - ? i18n.t( - 'This stage does not have any numeric data items available.' - ) - : i18n.t( - 'This program does not have any numeric data items available.' - )} - - )} - {!isLoading && - !isError && - items?.length !== 0 && - visibleItems?.length === 0 && ( -
- {i18n.t( - 'No data items match "{{- searchTerm}}"', - { searchTerm } - )} -
- )} - {!isLoading && - !isError && - visibleItems?.map((item) => ( - onItemChange(item)} - /> - ))} -
-
- - {AGGREGATION_TYPES.map((value) => ( - - ))} - -
-
- - - - {selectedItemId ? ( - - ) : ( - - {({ - onMouseOver, - onMouseOut, - onFocus, - onBlur, - ref, - }) => ( - - - - )} - - )} - - -
- ) -} diff --git a/src/components/layout-panel/custom-value-modal/index.ts b/src/components/layout-panel/custom-value-modal/index.ts deleted file mode 100644 index 56534efb..00000000 --- a/src/components/layout-panel/custom-value-modal/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { CustomValueModal } from './custom-value-modal' -export { getStageIdFromDimensionId } from './use-custom-value-items' diff --git a/src/components/layout-panel/custom-value-modal/stage-notice.tsx b/src/components/layout-panel/custom-value-modal/stage-notice.tsx deleted file mode 100644 index 6ad3003a..00000000 --- a/src/components/layout-panel/custom-value-modal/stage-notice.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import i18n from '@dhis2/d2-i18n' -import { NoticeBox } from '@dhis2/ui' -import type { FC } from 'react' -import classes from './styles/stage-notice.module.css' - -type StageNoticeProps = { - filteredByStageName: string | undefined - customValueStageMismatch: boolean - customValueItemName?: string -} - -export const StageNotice: FC = ({ - filteredByStageName, - customValueStageMismatch, - customValueItemName, -}) => { - if (!filteredByStageName) { - return null - } - - return ( -
- {customValueStageMismatch ? ( - - {i18n.t( - '"{{- itemName}}" is from a different stage than the dimensions in the layout. Choose another item.', - { - itemName: customValueItemName ?? '', - } - )} - - ) : ( - - )} -
- ) -} diff --git a/src/components/layout-panel/custom-value-modal/styles/stage-notice.module.css b/src/components/layout-panel/custom-value-modal/styles/stage-notice.module.css deleted file mode 100644 index 160d04f5..00000000 --- a/src/components/layout-panel/custom-value-modal/styles/stage-notice.module.css +++ /dev/null @@ -1,3 +0,0 @@ -.stageNotice { - margin-block-end: var(--spacers-dp16); -} From 9ec5237a2bd80342311e574abe7318e0353f1022 Mon Sep 17 00:00:00 2001 From: Edoardo Sabadelli Date: Thu, 3 Sep 2026 11:05:37 +0200 Subject: [PATCH 06/12] refactor: use the new CellValueModal --- .../layout-panel/bottom-bar/action-buttons/base-button.tsx | 2 +- .../cell-value-button/__tests__/cell-value-button.spec.tsx | 2 +- .../bottom-bar/cell-value-button/cell-value-button.tsx | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/layout-panel/bottom-bar/action-buttons/base-button.tsx b/src/components/layout-panel/bottom-bar/action-buttons/base-button.tsx index 43e02eaa..97542b65 100644 --- a/src/components/layout-panel/bottom-bar/action-buttons/base-button.tsx +++ b/src/components/layout-panel/bottom-bar/action-buttons/base-button.tsx @@ -33,7 +33,7 @@ const BaseButton: FC = ({ const onClick = () => { dispatch(setVisUiConfigOutputType(type)) - dispatch(tUpdateCurrentVisFromVisUiConfig(false)) + dispatch(tUpdateCurrentVisFromVisUiConfig()) } return ( diff --git a/src/components/layout-panel/bottom-bar/cell-value-button/__tests__/cell-value-button.spec.tsx b/src/components/layout-panel/bottom-bar/cell-value-button/__tests__/cell-value-button.spec.tsx index 7d7cdef5..07611366 100644 --- a/src/components/layout-panel/bottom-bar/cell-value-button/__tests__/cell-value-button.spec.tsx +++ b/src/components/layout-panel/bottom-bar/cell-value-button/__tests__/cell-value-button.spec.tsx @@ -139,7 +139,7 @@ describe('CellValueButton', () => { await waitFor(() => { expect( - screen.getByText('Configure custom value') + screen.getByRole('heading', { name: 'Cell value' }) ).toBeInTheDocument() }) }) diff --git a/src/components/layout-panel/bottom-bar/cell-value-button/cell-value-button.tsx b/src/components/layout-panel/bottom-bar/cell-value-button/cell-value-button.tsx index c818a4eb..8cd12963 100644 --- a/src/components/layout-panel/bottom-bar/cell-value-button/cell-value-button.tsx +++ b/src/components/layout-panel/bottom-bar/cell-value-button/cell-value-button.tsx @@ -1,5 +1,5 @@ import { IconTableRows } from '@components/layout-panel/bottom-bar/icon-table-rows' -import { CustomValueModal } from '@components/layout-panel/custom-value-modal' +import { CellValueModal } from '@components/layout-panel/cell-value-modal' import i18n from '@dhis2/d2-i18n' import { IconEdit16, Tooltip } from '@dhis2/ui' import { @@ -88,7 +88,7 @@ export const CellValueButton: FC = () => { ) : ( button )} - {isModalOpen && } + {isModalOpen && } ) } From 84d9ce34562848d996d96e6fe497337430b5c2b2 Mon Sep 17 00:00:00 2001 From: Edoardo Sabadelli Date: Thu, 3 Sep 2026 11:10:30 +0200 Subject: [PATCH 07/12] refactor: simplify custom value handling Now it's completely separated from the action buttons, so the logic can be simpler. --- src/store/__tests__/thunks.spec.tsx | 40 ++++++++++++--------- src/store/thunks.ts | 56 +++++------------------------ src/store/vis-ui-config-slice.ts | 4 +++ 3 files changed, 36 insertions(+), 64 deletions(-) diff --git a/src/store/__tests__/thunks.spec.tsx b/src/store/__tests__/thunks.spec.tsx index bd5a8758..55c16711 100644 --- a/src/store/__tests__/thunks.spec.tsx +++ b/src/store/__tests__/thunks.spec.tsx @@ -1,7 +1,11 @@ import { getLastUsedVisualizationTypeFromLocalStorage } from '@modules/visualization/local-storage' import { getCurrentVis } from '@store/current-vis-slice' import { tUpdateCurrentVisFromVisUiConfig } from '@store/thunks' -import { initialState as visUiConfigInitialState } from '@store/vis-ui-config-slice' +import { + clearVisUiConfigCustomValue, + initialState as visUiConfigInitialState, + setVisUiConfigVisualizationType, +} from '@store/vis-ui-config-slice' import { renderHookWithAppWrapper, type MockOptions, @@ -83,52 +87,54 @@ describe('tUpdateCurrentVisFromVisUiConfig', () => { ) }) - it('clears the value when rebuilding in EVENT mode, keeping customValue remembered', async () => { + it('applies the remembered customValue to a pivot table', async () => { const { store } = await renderHookWithAppWrapper( () => null, - buildMockOptions(customValueVis) + buildMockOptions(eventVis) ) - store.dispatch(tUpdateCurrentVisFromVisUiConfig(false)) + store.dispatch(tUpdateCurrentVisFromVisUiConfig()) - expect(getCurrentVis(store.getState()).value).toBeUndefined() - // The remembered selection survives the switch to the event table - expect(store.getState().visUiConfig.customValue).toEqual(customValue) + const currentVis = getCurrentVis(store.getState()) + expect(currentVis.value).toEqual({ id: 's1.de1' }) + expect(currentVis.aggregationType).toBe('AVERAGE') }) - it('restores the value from the remembered customValue when rebuilding in CUSTOM_VALUE mode', async () => { + it('applies the customValue for any output type', async () => { const { store } = await renderHookWithAppWrapper( () => null, - buildMockOptions(eventVis) + buildMockOptions(customValueVis, 'ENROLLMENT') ) - store.dispatch(tUpdateCurrentVisFromVisUiConfig(true)) + store.dispatch(tUpdateCurrentVisFromVisUiConfig()) const currentVis = getCurrentVis(store.getState()) expect(currentVis.value).toEqual({ id: 's1.de1' }) expect(currentVis.aggregationType).toBe('AVERAGE') }) - it('preserves the current mode when withCustomValue is not passed', async () => { + it('strips the value once the customValue is cleared', async () => { const { store } = await renderHookWithAppWrapper( () => null, buildMockOptions(customValueVis) ) + store.dispatch(clearVisUiConfigCustomValue()) store.dispatch(tUpdateCurrentVisFromVisUiConfig()) - expect(getCurrentVis(store.getState()).value).toEqual({ id: 's1.de1' }) + const currentVis = getCurrentVis(store.getState()) + expect(currentVis.value).toBeUndefined() + expect(currentVis.aggregationType).toBeUndefined() }) - it('drops the value for a non-EVENT output type even when withCustomValue is true', async () => { + it('leaves the customValue out of a line list, keeping it remembered', async () => { const { store } = await renderHookWithAppWrapper( () => null, - buildMockOptions(customValueVis, 'ENROLLMENT') + buildMockOptions(customValueVis) ) - // Even explicitly asking for a custom value must not add one when - // the output type is not EVENT. - store.dispatch(tUpdateCurrentVisFromVisUiConfig(true)) + store.dispatch(setVisUiConfigVisualizationType('LINE_LIST')) + store.dispatch(tUpdateCurrentVisFromVisUiConfig()) expect(getCurrentVis(store.getState()).value).toBeUndefined() expect(store.getState().visUiConfig.customValue).toEqual(customValue) diff --git a/src/store/thunks.ts b/src/store/thunks.ts index bc82d780..f0dd63bc 100644 --- a/src/store/thunks.ts +++ b/src/store/thunks.ts @@ -131,45 +131,16 @@ export const tLoadSavedVisualization = createAsyncThunk< } ) -const shouldPopulateCustomValueFields = ( - currentVis: CurrentVisState, - visUiConfig: VisUiConfigState, - withCustomValue?: boolean -): boolean => { - // Only EVENT output can carry a custom value - if (visUiConfig.outputType !== 'EVENT') { - return false - } - if (withCustomValue !== undefined) { - return withCustomValue // explicit request: add or strip - } - return Boolean(currentVis.value?.id) // preserve what the current vis shows -} +const resolveCustomValueFields = (visUiConfig: VisUiConfigState) => { + /* Always include the `value` key: setCurrentVis merges into the previous + * currentVis, so omitting it would leave a stale value behind. Only a pivot + * table shows aggregated cells, so only it can carry a custom value. */ + const { customValue, visualizationType } = visUiConfig -const resolveCustomValueFields = ( - currentVis: CurrentVisState, - visUiConfig: VisUiConfigState, - withCustomValue?: boolean -) => { - // Always include the `value` key: setCurrentVis merges into the previous - // currentVis, so omitting it would leave a stale value behind. - if ( - !shouldPopulateCustomValueFields( - currentVis, - visUiConfig, - withCustomValue - ) - ) { + if (visualizationType !== 'PIVOT_TABLE' || !customValue) { return { value: undefined, aggregationType: undefined } } - const { customValue } = visUiConfig - - if (!customValue) { - throw new Error( - 'shouldPopulateCustomValueFields is true but visUiConfig.customValue is missing' - ) - } return { value: { id: customValue.id }, aggregationType: customValue.aggregationType, @@ -179,19 +150,15 @@ const resolveCustomValueFields = ( /* Rebuild a currentVis fresh from visUiConfig so stale currentVis fields can't * leak through. Carries over only id and sorting from the previous currentVis. * The custom value fields go after the options spread so the value's own - * aggregation type wins over the options default. `withCustomValue` overrides - * whether the result carries the custom value: true forces it on, false strips - * it; omit it to preserve the previous currentVis. */ + * aggregation type wins over the options default. */ export const buildCurrentVisFromVisUiConfig = ({ previousCurrentVis, visUiConfig, metadataStore, - withCustomValue, }: { previousCurrentVis: CurrentVisState visUiConfig: VisUiConfigState metadataStore: MetadataStore - withCustomValue?: boolean }): CurrentVisualization => ({ id: isCurrentVisualizationPersisted(previousCurrentVis) ? previousCurrentVis.id @@ -207,15 +174,11 @@ export const buildCurrentVisFromVisUiConfig = ({ programDimensions: collectProgramDimensions(visUiConfig, metadataStore), ...getEnabledOptions(visUiConfig.options), ...resolveTeiFields(visUiConfig, metadataStore), - ...resolveCustomValueFields( - previousCurrentVis, - visUiConfig, - withCustomValue - ), + ...resolveCustomValueFields(visUiConfig), }) export const tUpdateCurrentVisFromVisUiConfig = - (withCustomValue?: boolean) => + () => ( dispatch: AppDispatch, getState: () => RootState, @@ -234,7 +197,6 @@ export const tUpdateCurrentVisFromVisUiConfig = previousCurrentVis: currentVis, visUiConfig, metadataStore: extra.metadataStore, - withCustomValue, }) ) ) diff --git a/src/store/vis-ui-config-slice.ts b/src/store/vis-ui-config-slice.ts index 682c77e6..a6ba2d71 100644 --- a/src/store/vis-ui-config-slice.ts +++ b/src/store/vis-ui-config-slice.ts @@ -221,6 +221,9 @@ export const visUiConfigSlice = createSlice({ ) => { state.customValue = action.payload }, + clearVisUiConfigCustomValue: (state) => { + delete state.customValue + }, setVisUiConfigRepetitionsByDimension: ( state, action: PayloadAction @@ -413,6 +416,7 @@ export const { setVisUiConfigConditionsByDimension, setVisUiConfigGroupingByDimension, setVisUiConfigCustomValue, + clearVisUiConfigCustomValue, setVisUiConfigRepetitionsByDimension, addVisUiConfigLayoutDimension, addVisUiConfigLayoutDimensions, From a5f91e2e465061407d1ca1487f973f72f24fd078 Mon Sep 17 00:00:00 2001 From: Edoardo Sabadelli Date: Thu, 3 Sep 2026 11:31:53 +0200 Subject: [PATCH 08/12] fix: avoid disabling custom value dimension in LL --- src/modules/dimension/__tests__/dimension.spec.ts | 10 ++++++++++ src/modules/dimension/blocking.ts | 8 +++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/modules/dimension/__tests__/dimension.spec.ts b/src/modules/dimension/__tests__/dimension.spec.ts index 1c63d8c8..256126f1 100644 --- a/src/modules/dimension/__tests__/dimension.spec.ts +++ b/src/modules/dimension/__tests__/dimension.spec.ts @@ -768,6 +768,16 @@ describe('getDimensionBlockReason', () => { ).toBe('customValue') }) + it('ignores the custom value in a line list, which never shows one', () => { + expect( + getReason({ + dimension: makeDim({ id: 'x' }), + customValueId: 'x', + visualizationType: 'LINE_LIST', + }) + ).toBeNull() + }) + it('returns visType for a program indicator outside line list', () => { expect( getReason({ diff --git a/src/modules/dimension/blocking.ts b/src/modules/dimension/blocking.ts index 97c129b1..0e8e43d1 100644 --- a/src/modules/dimension/blocking.ts +++ b/src/modules/dimension/blocking.ts @@ -73,7 +73,13 @@ export const getDimensionBlockReason = ({ layoutTetId, dimensionTetId, }: DimensionBlockReasonInput): DimensionBlockReason | null => { - if (customValueId && dimension.id === customValueId) { + /* The custom value is remembered across vis types but only a pivot table + * shows it, so it only withholds its dimension from a pivot table layout. */ + if ( + visualizationType === 'PIVOT_TABLE' && + customValueId && + dimension.id === customValueId + ) { return 'customValue' } if (isDimensionFullyInvalidForVisType(dimension, visualizationType)) { From d3f8701ec52a496fa1306dbd1cccf7ebe6722a27 Mon Sep 17 00:00:00 2001 From: Edoardo Sabadelli Date: Thu, 3 Sep 2026 12:13:58 +0200 Subject: [PATCH 09/12] fix: only show label/cell value button with a visualization in canvas --- .../layout-panel/__tests__/layout-panel.cy.tsx | 6 ++++++ .../layout-panel/bottom-bar/bottom-bar.tsx | 13 +++++++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/components/layout-panel/__tests__/layout-panel.cy.tsx b/src/components/layout-panel/__tests__/layout-panel.cy.tsx index e38a54a8..6b7c37c6 100644 --- a/src/components/layout-panel/__tests__/layout-panel.cy.tsx +++ b/src/components/layout-panel/__tests__/layout-panel.cy.tsx @@ -445,6 +445,12 @@ describe('', () => { it('renders the PIVOT_TABLE update buttons in the order enrollment, event, followed by the cell value button', () => { const layoutPanelMockOptions = createMockOptions({ + /* The cell value button only shows for an existing visualization, + * so the current vis must not be empty. */ + currentVis: { + type: 'PIVOT_TABLE', + outputType: 'EVENT', + }, dimensionSelection: { ...mockOptions.partialStore?.preloadedState.dimensionSelection, dataSourceId: 'test-id', diff --git a/src/components/layout-panel/bottom-bar/bottom-bar.tsx b/src/components/layout-panel/bottom-bar/bottom-bar.tsx index 20b459c8..4ac06ccc 100644 --- a/src/components/layout-panel/bottom-bar/bottom-bar.tsx +++ b/src/components/layout-panel/bottom-bar/bottom-bar.tsx @@ -1,4 +1,6 @@ import { useAppSelector } from '@hooks' +import { isVisualizationEmpty } from '@modules/visualization/state' +import { getCurrentVis } from '@store/current-vis-slice' import { getDataSourceId } from '@store/dimensions-selection-slice' import { getIsVisualizationLoading } from '@store/loader-slice' import { getVisUiConfigVisualizationType } from '@store/vis-ui-config-slice' @@ -15,6 +17,8 @@ export const BottomBar: FC = () => { const dataSourceId = useAppSelector(getDataSourceId) const isVisualizationLoading = useAppSelector(getIsVisualizationLoading) const visualizationType = useAppSelector(getVisUiConfigVisualizationType) + const currentVis = useAppSelector(getCurrentVis) + const hasVisualizationInCanvas = !isVisualizationEmpty(currentVis) return (
{ <> - + {hasVisualizationInCanvas && } ) : ( <> - {visualizationType === 'LINE_LIST' && ( - - )} + {hasVisualizationInCanvas && + visualizationType === 'LINE_LIST' && ( + + )} )}
From 0000579409bfb1d987614e8380e96ae04d47de73 Mon Sep 17 00:00:00 2001 From: Edoardo Sabadelli Date: Thu, 3 Sep 2026 13:25:55 +0200 Subject: [PATCH 10/12] refactor: reuse tooltip wrapper component, rename helpers --- .../bottom-bar/action-buttons/base-button.tsx | 5 +- .../styles/action-buttons.module.css | 7 -- .../action-buttons/use-action-button.ts | 39 +++++------ .../cell-value-button/cell-value-button.tsx | 70 +++++-------------- .../styles/cell-value-button.module.css | 8 +-- .../program-count-tooltip-config.ts | 17 +++++ .../__tests__/row-granularity-label.spec.tsx | 48 ++++++++++++- .../row-granularity-label.tsx | 34 ++++++--- .../styles/row-granularity-label.module.css | 5 ++ .../bottom-bar/styles/with-tooltip.module.css | 4 ++ .../layout-panel/bottom-bar/with-tooltip.tsx | 32 +++++++++ 11 files changed, 172 insertions(+), 97 deletions(-) create mode 100644 src/components/layout-panel/bottom-bar/program-count-tooltip-config.ts create mode 100644 src/components/layout-panel/bottom-bar/styles/with-tooltip.module.css create mode 100644 src/components/layout-panel/bottom-bar/with-tooltip.tsx diff --git a/src/components/layout-panel/bottom-bar/action-buttons/base-button.tsx b/src/components/layout-panel/bottom-bar/action-buttons/base-button.tsx index 97542b65..4a2156b0 100644 --- a/src/components/layout-panel/bottom-bar/action-buttons/base-button.tsx +++ b/src/components/layout-panel/bottom-bar/action-buttons/base-button.tsx @@ -1,3 +1,4 @@ +import { type TooltipConfig } from '@components/layout-panel/bottom-bar/with-tooltip' import { Tooltip } from '@dhis2/ui' import { useAppDispatch } from '@hooks' import { tUpdateCurrentVisFromVisUiConfig } from '@store/thunks' @@ -57,9 +58,7 @@ const BaseButton: FC = ({ } export const BaseButtonWithConditionalTooltip: FC< - BaseButtonProps & { - tooltipConfig?: { content: string; openDelay?: number } - } + BaseButtonProps & { tooltipConfig?: TooltipConfig } > = ({ tooltipConfig, ...props }) => { if (tooltipConfig) { const { content, openDelay = 500 } = tooltipConfig diff --git a/src/components/layout-panel/bottom-bar/action-buttons/styles/action-buttons.module.css b/src/components/layout-panel/bottom-bar/action-buttons/styles/action-buttons.module.css index fe85d5a6..86ff3a5b 100644 --- a/src/components/layout-panel/bottom-bar/action-buttons/styles/action-buttons.module.css +++ b/src/components/layout-panel/bottom-bar/action-buttons/styles/action-buttons.module.css @@ -75,10 +75,3 @@ border-end-start-radius: 0; border-inline-start: 1px solid rgba(0, 0, 0, 0.1); } - -/* Lets the tooltip's pointer handlers attach to a wrapper around the button - * (disabled ` - ) - return ( <> - {unavailableReason ? ( - + + {isModalOpen && } ) diff --git a/src/components/layout-panel/bottom-bar/cell-value-button/styles/cell-value-button.module.css b/src/components/layout-panel/bottom-bar/cell-value-button/styles/cell-value-button.module.css index 1f17704e..9d24a79e 100644 --- a/src/components/layout-panel/bottom-bar/cell-value-button/styles/cell-value-button.module.css +++ b/src/components/layout-panel/bottom-bar/cell-value-button/styles/cell-value-button.module.css @@ -44,16 +44,10 @@ margin-inline-start: var(--spacers-dp4); } -.button.disabled, -.button.disabled:hover { +.button:disabled { border-color: transparent; border-inline-start-color: var(--colors-grey300); background-color: var(--colors-white); color: var(--colors-grey500); cursor: not-allowed; } - -.tooltipWrapper { - display: flex; - align-items: center; -} diff --git a/src/components/layout-panel/bottom-bar/program-count-tooltip-config.ts b/src/components/layout-panel/bottom-bar/program-count-tooltip-config.ts new file mode 100644 index 00000000..8797c16a --- /dev/null +++ b/src/components/layout-panel/bottom-bar/program-count-tooltip-config.ts @@ -0,0 +1,17 @@ +import { type TooltipConfig } from '@components/layout-panel/bottom-bar/with-tooltip' +import i18n from '@dhis2/d2-i18n' + +/* Why the layout's program count is unusable, for the parts of the bottom bar + * that can only describe a single-program layout. Mirrors the tooltips the + * output type buttons show for the same layouts. */ +export const getProgramCountTooltipConfig = ( + programIds: string[] +): TooltipConfig => { + if (programIds.length === 0) { + return { content: i18n.t('Not valid without a program') } + } + if (programIds.length > 1) { + return { content: i18n.t('Not valid with multiple programs') } + } + return undefined +} diff --git a/src/components/layout-panel/bottom-bar/row-granularity-label/__tests__/row-granularity-label.spec.tsx b/src/components/layout-panel/bottom-bar/row-granularity-label/__tests__/row-granularity-label.spec.tsx index 3dfab42d..acd99394 100644 --- a/src/components/layout-panel/bottom-bar/row-granularity-label/__tests__/row-granularity-label.spec.tsx +++ b/src/components/layout-panel/bottom-bar/row-granularity-label/__tests__/row-granularity-label.spec.tsx @@ -1,6 +1,7 @@ import { initialState as visUiConfigInitialState } from '@store/vis-ui-config-slice' import { renderWithAppWrapper } from '@test-utils/app-wrapper' -import { screen } from '@testing-library/react' +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import type { OutputType, RootState } from '@types' import { describe, it, expect } from 'vitest' import { RowGranularityLabel } from '../row-granularity-label' @@ -111,6 +112,51 @@ describe('RowGranularityLabel', () => { expect(screen.getByText('One row for each Event')).toBeInTheDocument() }) + it('mutes the label and explains why for an event list spanning programs', async () => { + await renderWithAppWrapper( + , + buildMockOptions('EVENT', ['s1.de1', 's2.de1']) + ) + + expect(screen.getByText('One row for each Visit')).toBeInTheDocument() + + await userEvent.hover(screen.getByText('One row for each Visit')) + + await waitFor(() => { + expect( + screen.getByText('Not valid with multiple programs') + ).toBeInTheDocument() + }) + }) + + it('explains why for an event list without a program', async () => { + await renderWithAppWrapper( + , + buildMockOptions('EVENT', ['tet1.enrollmentOu']) + ) + + await userEvent.hover(screen.getByText('One row for each Event')) + + await waitFor(() => { + expect( + screen.getByText('Not valid without a program') + ).toBeInTheDocument() + }) + }) + + it('leaves a tracked entity list alone when the layout spans programs', async () => { + await renderWithAppWrapper( + , + buildMockOptions('TRACKED_ENTITY_INSTANCE', ['s1.de1', 's2.de1']) + ) + + await userEvent.hover(screen.getByText('One row for each Person')) + + expect( + screen.queryByText('Not valid with multiple programs') + ).not.toBeInTheDocument() + }) + it('renders no button, so it cannot be clicked', async () => { await renderWithAppWrapper( , diff --git a/src/components/layout-panel/bottom-bar/row-granularity-label/row-granularity-label.tsx b/src/components/layout-panel/bottom-bar/row-granularity-label/row-granularity-label.tsx index eeb2b137..f052334f 100644 --- a/src/components/layout-panel/bottom-bar/row-granularity-label/row-granularity-label.tsx +++ b/src/components/layout-panel/bottom-bar/row-granularity-label/row-granularity-label.tsx @@ -1,21 +1,39 @@ import { IconTableRows } from '@components/layout-panel/bottom-bar/icon-table-rows' +import { getProgramCountTooltipConfig } from '@components/layout-panel/bottom-bar/program-count-tooltip-config' +import { WithTooltip } from '@components/layout-panel/bottom-bar/with-tooltip' import i18n from '@dhis2/d2-i18n' -import { useAppSelector, useOutputTypeLabel } from '@hooks' +import { useAppSelector, useLayoutContext, useOutputTypeLabel } from '@hooks' import { getVisUiConfigOutputType } from '@store/vis-ui-config-slice' +import cx from 'classnames' import { type FC } from 'react' import classes from './styles/row-granularity-label.module.css' export const RowGranularityLabel: FC = () => { const outputType = useAppSelector(getVisUiConfigOutputType) const outputTypeLabel = useOutputTypeLabel(outputType) + const { programIds } = useLayoutContext() + /* A tracked entity list spans programs by design; an event or enrollment + * list cannot, so with any other program count it describes a granularity + * the layout can no longer produce. */ + const tooltipConfig = + outputType === 'TRACKED_ENTITY_INSTANCE' + ? undefined + : getProgramCountTooltipConfig(programIds) return ( - - - {i18n.t('One row for each {{- outputTypeLabel}}', { - outputTypeLabel, - nsSeparator: '^^', - })} - + + + + {i18n.t('One row for each {{- outputTypeLabel}}', { + outputTypeLabel, + nsSeparator: '^^', + })} + + ) } diff --git a/src/components/layout-panel/bottom-bar/row-granularity-label/styles/row-granularity-label.module.css b/src/components/layout-panel/bottom-bar/row-granularity-label/styles/row-granularity-label.module.css index 29afec05..afb6c06c 100644 --- a/src/components/layout-panel/bottom-bar/row-granularity-label/styles/row-granularity-label.module.css +++ b/src/components/layout-panel/bottom-bar/row-granularity-label/styles/row-granularity-label.module.css @@ -14,3 +14,8 @@ .label svg { color: var(--colors-grey500); } + +.label.unavailable, +.label.unavailable svg { + color: var(--colors-grey500); +} diff --git a/src/components/layout-panel/bottom-bar/styles/with-tooltip.module.css b/src/components/layout-panel/bottom-bar/styles/with-tooltip.module.css new file mode 100644 index 00000000..34a42f41 --- /dev/null +++ b/src/components/layout-panel/bottom-bar/styles/with-tooltip.module.css @@ -0,0 +1,4 @@ +.tooltipWrapper { + display: flex; + align-items: center; +} diff --git a/src/components/layout-panel/bottom-bar/with-tooltip.tsx b/src/components/layout-panel/bottom-bar/with-tooltip.tsx new file mode 100644 index 00000000..ef39ea33 --- /dev/null +++ b/src/components/layout-panel/bottom-bar/with-tooltip.tsx @@ -0,0 +1,32 @@ +import { Tooltip } from '@dhis2/ui' +import { type FC, type ReactElement } from 'react' +import classes from './styles/with-tooltip.module.css' + +export type TooltipConfig = { content: string; openDelay?: number } | undefined + +const DEFAULT_OPEN_DELAY = 500 + +/* Wraps its child in a tooltip only when there is something to say, so the + * bottom bar's disabled controls can explain themselves without every one of + * them branching on it. The wrapping span carries the tooltip's pointer + * handlers because disabled ` - {isUpdateDisabled ? ( - - {(tooltipProps: object) => ( - - - - )} - - ) : ( - - )} diff --git a/src/components/layout-panel/cell-value-modal/custom-value-item-picker.tsx b/src/components/layout-panel/cell-value-modal/custom-value-item-picker.tsx index 50f78932..1b5dce2b 100644 --- a/src/components/layout-panel/cell-value-modal/custom-value-item-picker.tsx +++ b/src/components/layout-panel/cell-value-modal/custom-value-item-picker.tsx @@ -10,13 +10,16 @@ import { SingleSelectField, SingleSelectOption, } from '@dhis2/ui' -import { useMetadataStore, useStableCallback } from '@hooks' -import type { CustomValueObject } from '@store/vis-ui-config-slice' +import { useAppDispatch, useAppSelector, useMetadataStore } from '@hooks' +import { + getVisUiConfigCustomValue, + setVisUiConfigCustomValue, +} from '@store/vis-ui-config-slice' import type { AggregationType } from '@types' -import { useEffect, useMemo, useState, type FC } from 'react' +import { useMemo, useState, type FC } from 'react' import { CustomValueOption } from './custom-value-option' import classes from './styles/custom-value-item-picker.module.css' -import { useCellValueItems } from './use-cell-value-items' +import { useCellValueItems, type CustomValueItem } from './use-cell-value-items' /* An item whose metadata aggregation type is NONE cannot be aggregated: the * analytics API returns 0 for every cell. Many tracked entity attributes (and @@ -25,24 +28,27 @@ import { useCellValueItems } from './use-cell-value-items' * instead. */ const FALLBACK_AGGREGATION_TYPE_FOR_NONE: AggregationType = 'AVERAGE' -type CustomValueItemPickerProps = { - programId: string - initialCustomValue: CustomValueObject | undefined - /* Reports the choice ready to be stored — with the item's own aggregation - * type already resolved — or undefined while no item is selected. */ - onChange: (customValue: CustomValueObject | undefined) => void +const resolveAggregationType = ( + aggregationType: AggregationType, + item: CustomValueItem +): AggregationType => { + if (aggregationType !== 'DEFAULT') { + return aggregationType + } + return item.aggregationType === 'NONE' + ? FALLBACK_AGGREGATION_TYPE_FOR_NONE + : item.aggregationType } -export const CustomValueItemPicker: FC = ({ +export const CustomValueItemPicker: FC<{ programId: string }> = ({ programId, - initialCustomValue, - onChange, }) => { + const dispatch = useAppDispatch() const metadataStore = useMetadataStore() + const customValue = useAppSelector(getVisUiConfigCustomValue) const [searchTerm, setSearchTerm] = useState('') - const [selectedItemId, setSelectedItemId] = useState(initialCustomValue?.id) const [aggregationType, setAggregationType] = useState( - initialCustomValue?.aggregationType ?? 'DEFAULT' + customValue?.aggregationType ?? 'DEFAULT' ) const { items, isLoading, isError, error } = useCellValueItems(programId) @@ -56,40 +62,38 @@ export const CustomValueItemPicker: FC = ({ ) }, [items, searchTerm]) - const { selectedItemDefaultIsNone, selectedAggregationType, customValue } = - useMemo(() => { - const selectedItem = items?.find( - (item) => item.id === selectedItemId - ) - const selectedItemDefaultIsNone = - selectedItem?.aggregationType === 'NONE' - const itemDefaultAggregationType = selectedItemDefaultIsNone - ? FALLBACK_AGGREGATION_TYPE_FOR_NONE - : selectedItem?.aggregationType - return { - selectedItemDefaultIsNone, - selectedAggregationType: - aggregationType === 'DEFAULT' && selectedItemDefaultIsNone - ? FALLBACK_AGGREGATION_TYPE_FOR_NONE - : aggregationType, - customValue: selectedItem - ? { - id: selectedItem.id, - aggregationType: - aggregationType === 'DEFAULT' - ? (itemDefaultAggregationType as AggregationType) - : aggregationType, - } - : undefined, - } - }, [aggregationType, items, selectedItemId]) + const selectedItem = items?.find((item) => item.id === customValue?.id) + const selectedItemDefaultIsNone = selectedItem?.aggregationType === 'NONE' + const selectedAggregationType = + aggregationType === 'DEFAULT' && selectedItemDefaultIsNone + ? FALLBACK_AGGREGATION_TYPE_FOR_NONE + : aggregationType + + const onItemClick = (item: CustomValueItem) => { + metadataStore.addMetadata(item) + dispatch( + setVisUiConfigCustomValue({ + id: item.id, + aggregationType: resolveAggregationType(aggregationType, item), + }) + ) + } - /* The choice is only resolvable once the items are loaded, so it is - * reported up rather than derived by the parent from the click alone. */ - const reportChange = useStableCallback(onChange) - useEffect(() => { - reportChange(customValue) - }, [customValue, reportChange]) + const onAggregationTypeChange = ({ selected }: { selected: string }) => { + const nextAggregationType = selected as AggregationType + setAggregationType(nextAggregationType) + if (selectedItem) { + dispatch( + setVisUiConfigCustomValue({ + id: selectedItem.id, + aggregationType: resolveAggregationType( + nextAggregationType, + selectedItem + ), + }) + ) + } + } return ( <> @@ -145,21 +149,16 @@ export const CustomValueItemPicker: FC = ({ key={item.id} label={item.name} value={item.id} - active={selectedItemId === item.id} + active={customValue?.id === item.id} stageName={item.stageName} - onClick={() => { - metadataStore.addMetadata(item) - setSelectedItemId(item.id) - }} + onClick={() => onItemClick(item)} /> ))}
- setAggregationType(selected as AggregationType) - } + onChange={onAggregationTypeChange} selected={selectedAggregationType} dense >