diff --git a/i18n/en.pot b/i18n/en.pot index 4bfea020..9c9710da 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-03T13:35:19.408Z\n" +"PO-Revision-Date: 2026-09-03T13:35:19.408Z\n" msgid "" "Some dimensions were not added because they cannot be used in a " @@ -235,27 +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 "Enrollment" -msgstr "Enrollment" - msgid "Create {{- enrollmentLabel}} list" msgstr "Create {{- enrollmentLabel}} list" @@ -274,9 +253,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 +271,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" @@ -343,11 +316,40 @@ 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 "Configure custom value" -msgstr "Configure custom value" +msgid "Cells show {{- valueName}}" +msgstr "Cells show {{- valueName}}" -msgid "Choose the numeric data item to show in table cells." -msgstr "Choose the numeric data item to show in table cells." +msgid "Cells show {{- outputTypeLabel}} count" +msgstr "Cells show {{- outputTypeLabel}} count" + +msgid "One row for each {{- outputTypeLabel}}" +msgstr "One row for each {{- outputTypeLabel}}" + +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 "Done" +msgstr "Done" msgid "Search data items" msgstr "Search data items" @@ -361,15 +363,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." @@ -379,25 +375,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" @@ -419,6 +396,9 @@ msgstr "Convert to {{- visType}}" msgid "{{- names}} will be removed. Are you sure?" msgstr "{{- names}} will be removed. Are you sure?" +msgid "Cancel" +msgstr "Cancel" + msgid "Convert" msgstr "Convert" @@ -627,6 +607,9 @@ msgstr "Show without filters" msgid "Create a {{- visType}} before updating" msgstr "Create a {{- visType}} before updating" +msgid "Update" +msgstr "Update" + msgid "Metadata" msgstr "Metadata" @@ -645,6 +628,9 @@ msgstr "Program indicators" msgid "{{- name}} registration" msgstr "{{- name}} registration" +msgid "Event" +msgstr "Event" + msgid "Choose a data source" msgstr "Choose a data source" @@ -881,9 +867,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" @@ -905,6 +888,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/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/layout-panel/__tests__/layout-panel.cy.tsx b/src/components/layout-panel/__tests__/layout-panel.cy.tsx index 19fa0e0e..6b7c37c6 100644 --- a/src/components/layout-panel/__tests__/layout-panel.cy.tsx +++ b/src/components/layout-panel/__tests__/layout-panel.cy.tsx @@ -443,8 +443,14 @@ 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({ + /* 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', @@ -468,8 +474,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/base-button.tsx b/src/components/layout-panel/bottom-bar/action-buttons/base-button.tsx index 43e02eaa..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' @@ -33,7 +34,7 @@ const BaseButton: FC = ({ const onClick = () => { dispatch(setVisUiConfigOutputType(type)) - dispatch(tUpdateCurrentVisFromVisUiConfig(false)) + dispatch(tUpdateCurrentVisFromVisUiConfig()) } return ( @@ -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/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/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..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 @@ -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,20 +8,8 @@ import { useActionButton } from './use-action-button' 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 { action, tooltipConfig } = useActionButton('EVENT') + const eventLabel = useOutputTypeLabel('EVENT') const buttonLabelLookup = useMemo( () => ({ 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 ` + + {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..9e386050 --- /dev/null +++ b/src/components/layout-panel/bottom-bar/cell-value-button/styles/cell-value-button.module.css @@ -0,0 +1,65 @@ +.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:not(:disabled) { + border-color: var(--colors-grey400); + background-color: var(--colors-grey100); + color: var(--colors-grey900); +} + +.button:active:not(:disabled) { + 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.hasCustomValue { + border-color: var(--colors-grey400); + background-color: var(--colors-grey100); + color: var(--colors-grey900); + font-weight: 500; +} + +.button.hasCustomValue:hover:not(:disabled) { + border-color: var(--colors-grey500); +} + +.button:disabled { + border-color: transparent; + border-inline-start-color: var(--colors-grey300); + background-color: var(--colors-white); + color: var(--colors-grey500); + cursor: not-allowed; + font-weight: 400; +} diff --git a/src/components/layout-panel/bottom-bar/icon-table-rows.tsx b/src/components/layout-panel/bottom-bar/icon-table-rows.tsx new file mode 100644 index 00000000..0c1ff962 --- /dev/null +++ b/src/components/layout-panel/bottom-bar/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/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 new file mode 100644 index 00000000..acd99394 --- /dev/null +++ b/src/components/layout-panel/bottom-bar/row-granularity-label/__tests__/row-granularity-label.spec.tsx @@ -0,0 +1,168 @@ +import { initialState as visUiConfigInitialState } from '@store/vis-ui-config-slice' +import { renderWithAppWrapper } from '@test-utils/app-wrapper' +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' + +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('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( + , + buildMockOptions('EVENT', ['s1.de1']) + ) + + expect(screen.queryByRole('button')).not.toBeInTheDocument() + }) +}) 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..f052334f --- /dev/null +++ b/src/components/layout-panel/bottom-bar/row-granularity-label/row-granularity-label.tsx @@ -0,0 +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, 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: '^^', + })} + + + ) +} 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..afb6c06c --- /dev/null +++ b/src/components/layout-panel/bottom-bar/row-granularity-label/styles/row-granularity-label.module.css @@ -0,0 +1,21 @@ +.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); +} + +.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 ` + + + + ) +} 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..1b5dce2b --- /dev/null +++ b/src/components/layout-panel/cell-value-modal/custom-value-item-picker.tsx @@ -0,0 +1,179 @@ +import { + AGGREGATION_TYPES, + aggregationTypeDisplayNames, +} from '@constants/aggregation-types' +import i18n from '@dhis2/d2-i18n' +import { + CircularLoader, + InputField, + NoticeBox, + SingleSelectField, + SingleSelectOption, +} from '@dhis2/ui' +import { useAppDispatch, useAppSelector, useMetadataStore } from '@hooks' +import { + getVisUiConfigCustomValue, + setVisUiConfigCustomValue, +} from '@store/vis-ui-config-slice' +import type { AggregationType } from '@types' +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, 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 + * 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' + +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<{ programId: string }> = ({ + programId, +}) => { + const dispatch = useAppDispatch() + const metadataStore = useMetadataStore() + const customValue = useAppSelector(getVisUiConfigCustomValue) + const [searchTerm, setSearchTerm] = useState('') + const [aggregationType, setAggregationType] = useState( + customValue?.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 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), + }) + ) + } + + const onAggregationTypeChange = ({ selected }: { selected: string }) => { + const nextAggregationType = selected as AggregationType + setAggregationType(nextAggregationType) + if (selectedItem) { + dispatch( + setVisUiConfigCustomValue({ + id: selectedItem.id, + aggregationType: resolveAggregationType( + nextAggregationType, + selectedItem + ), + }) + ) + } + } + + 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) => ( + onItemClick(item)} + /> + ))} +
+
+ + {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); -} 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 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') + } +} 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)) { 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,