From 6a94badd504570d0c9e38568f7b271e91e29e73d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Henrik=20=C3=98verland?= Date: Mon, 7 Sep 2026 14:18:02 +0200 Subject: [PATCH 1/2] fix: re-order options and fix dispatch bug --- .../conditions-provider.tsx | 37 +++++++++---------- .../option-set-condition.tsx | 1 + 2 files changed, 18 insertions(+), 20 deletions(-) diff --git a/src/components/dimension-modal/conditions-modal-content/conditions-provider.tsx b/src/components/dimension-modal/conditions-modal-content/conditions-provider.tsx index f25a68dcb..27efc6c1f 100644 --- a/src/components/dimension-modal/conditions-modal-content/conditions-provider.tsx +++ b/src/components/dimension-modal/conditions-modal-content/conditions-provider.tsx @@ -139,31 +139,28 @@ export const ConditionsProvider: FC = ({ const removeCondition = useCallback< ConditionsContextValue['removeCondition'] >( - (conditionIndex) => - setConditionsList((prev) => { - const updatedConditionsList = prev.filter( - (_, index) => index !== conditionIndex - ) - - storeConditions(updatedConditionsList) - - return updatedConditionsList - }), - [storeConditions] + (conditionIndex) => { + const updatedConditionsList = conditionsList.filter( + (_, index) => index !== conditionIndex + ) + + setConditionsList(updatedConditionsList) + storeConditions(updatedConditionsList) + }, + [conditionsList, storeConditions] ) const setCondition = useCallback( - (conditionIndex, value) => - setConditionsList((prev) => { - const updatedConditionsList = prev.map((condition, index) => + (conditionIndex, value) => { + const updatedConditionsList = conditionsList.map( + (condition, index) => index === conditionIndex ? value : condition - ) - - storeConditions(updatedConditionsList) + ) - return updatedConditionsList - }), - [storeConditions] + setConditionsList(updatedConditionsList) + storeConditions(updatedConditionsList) + }, + [conditionsList, storeConditions] ) const contextValue = useMemo( diff --git a/src/components/dimension-modal/conditions-modal-content/option-set-condition/option-set-condition.tsx b/src/components/dimension-modal/conditions-modal-content/option-set-condition/option-set-condition.tsx index f3f5c29a3..681a66698 100644 --- a/src/components/dimension-modal/conditions-modal-content/option-set-condition/option-set-condition.tsx +++ b/src/components/dimension-modal/conditions-modal-content/option-set-condition/option-set-condition.tsx @@ -144,6 +144,7 @@ export const OptionSetCondition: FC = ({ setValues(selected)} selected={selectedIds} + enableOrderChange selectedOptionsLookup={selectedOptionsLookup} options={transferOptions} loading={isLoading || isFetching} From 9c23df6ccd97b1f9a841c0c93590e176065a7383 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Henrik=20=C3=98verland?= Date: Mon, 7 Sep 2026 14:59:45 +0200 Subject: [PATCH 2/2] feat: option set option for no value --- i18n/en.pot | 7 ++- .../option-set-condition.spec.tsx | 37 +++++++++++++-- .../option-set-condition.tsx | 46 +++++++++++++++---- .../styles/option-set-condition.module.css | 7 +++ .../__tests__/use-conditions-texts.spec.ts | 43 ++++++++++++++++- src/hooks/use-conditions-texts.ts | 29 ++++++------ src/modules/conditions.ts | 2 + 7 files changed, 141 insertions(+), 30 deletions(-) create mode 100644 src/components/dimension-modal/conditions-modal-content/option-set-condition/styles/option-set-condition.module.css diff --git a/i18n/en.pot b/i18n/en.pot index 4bfea020b..1491b867d 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-07T12:37:02.020Z\n" +"PO-Revision-Date: 2026-09-07T12:37:02.020Z\n" msgid "" "Some dimensions were not added because they cannot be used in a " @@ -962,6 +962,9 @@ msgstr "No" msgid "Not answered" msgstr "Not answered" +msgid "No value" +msgstr "No value" + msgid "" "{{- dataSourceTetName}} dimensions cannot be combined with {{- " "layoutTetName}} dimensions already in the layout." diff --git a/src/components/dimension-modal/conditions-modal-content/option-set-condition/option-set-condition.spec.tsx b/src/components/dimension-modal/conditions-modal-content/option-set-condition/option-set-condition.spec.tsx index f21fa1be7..b136dfb07 100644 --- a/src/components/dimension-modal/conditions-modal-content/option-set-condition/option-set-condition.spec.tsx +++ b/src/components/dimension-modal/conditions-modal-content/option-set-condition/option-set-condition.spec.tsx @@ -103,7 +103,7 @@ describe('OptionSetCondition', () => { createOptionsResolver() ) - await waitFor(() => expect(leftOptionNames()).toHaveLength(4)) + await waitFor(() => expect(leftOptionNames()).toHaveLength(5)) selectOption('Absconded') await waitFor(() => expect(onChange).toHaveBeenCalled()) @@ -112,7 +112,9 @@ describe('OptionSetCondition', () => { search('ch') await new Promise((resolve) => setTimeout(resolve, 700)) - await waitFor(() => expect(leftOptionNames()).toEqual(['Discharged'])) + await waitFor(() => + expect(leftOptionNames()).toEqual(['No value', 'Discharged']) + ) }) it('keeps a previously-selected option when adding another from a filtered list', async () => { @@ -137,7 +139,9 @@ describe('OptionSetCondition', () => { search('disch') await new Promise((resolve) => setTimeout(resolve, 700)) - await waitFor(() => expect(leftOptionNames()).toEqual(['Discharged'])) + await waitFor(() => + expect(leftOptionNames()).toEqual(['No value', 'Discharged']) + ) selectOption('Discharged') await waitFor(() => expect(onChange).toHaveBeenCalledWith('IN:ABS;DIS')) @@ -148,4 +152,31 @@ describe('OptionSetCondition', () => { * the source list and the Transfer would re-derive the missing label. */ expect(rightOptionNames()).toEqual(['Absconded', 'Discharged']) }) + + it('lists the no-value option first, even when a search matches nothing', async () => { + await renderCondition(createOptionsResolver()) + + await waitFor(() => expect(leftOptionNames()[0]).toBe('No value')) + + search('zzz') + await new Promise((resolve) => setTimeout(resolve, 700)) + + await waitFor(() => expect(leftOptionNames()).toEqual(['No value'])) + }) + + it('stores the no-value option by its code and labels it in the selection', async () => { + const { onChange, rerenderWithLatestCondition } = await renderCondition( + createOptionsResolver() + ) + + await waitFor(() => expect(leftOptionNames()).toHaveLength(5)) + + selectOption('No value') + await waitFor(() => + expect(onChange).toHaveBeenCalledWith('IN:D2__NOVALUE') + ) + rerenderWithLatestCondition() + + expect(rightOptionNames()).toEqual(['No value']) + }) }) diff --git a/src/components/dimension-modal/conditions-modal-content/option-set-condition/option-set-condition.tsx b/src/components/dimension-modal/conditions-modal-content/option-set-condition/option-set-condition.tsx index 681a66698..427e0e5ad 100644 --- a/src/components/dimension-modal/conditions-modal-content/option-set-condition/option-set-condition.tsx +++ b/src/components/dimension-modal/conditions-modal-content/option-set-condition/option-set-condition.tsx @@ -10,10 +10,15 @@ import { TransferSourceEmptyPlaceholder } from '@components/dimension-modal/tran import { useInfiniteTransferOptions } from '@components/dimension-modal/transfer/use-infinite-transfer-options' import { Transfer, TransferOption } from '@dhis2/ui' import { useAddMetadata, useOptionSetMetadataItem } from '@hooks' -import { OPERATOR_IN } from '@modules/conditions' +import { + NO_VALUE_OPTION_CODE, + OPERATOR_IN, + getNoValueOptionName, +} from '@modules/conditions' import { logger } from '@modules/logger' -import { type FC, useMemo } from 'react' +import { type ComponentProps, type FC, useMemo } from 'react' import { type FetchResult, optionsApi } from './options-api' +import classes from './styles/option-set-condition.module.css' type OptionSetConditionProps = { condition: string @@ -37,9 +42,30 @@ const toSelectedOptionsLookup = ( for (const { code, name } of Object.values(optionsByCode)) { lookup[code] = { value: code, label: name } } + lookup[NO_VALUE_OPTION_CODE] = { + value: NO_VALUE_OPTION_CODE, + label: getNoValueOptionName(), + } return lookup } +const renderOption = ({ + label, + ...props +}: ComponentProps) => ( + {label} + ) : ( + label + ) + } + dataTest="option-set-transfer-option" + /> +) + export const OptionSetCondition: FC = ({ condition, optionSetId, @@ -90,6 +116,10 @@ export const OptionSetCondition: FC = ({ const optionsMetadata = selected.reduce( (options, selectedId) => { + if (selectedId === NO_VALUE_OPTION_CODE) { + return options + } + const option = allOptionsByCode[selectedId] if (option) { @@ -136,7 +166,10 @@ export const OptionSetCondition: FC = ({ } const transferOptions = useMemo( - () => data.map(({ code, name }) => ({ value: code, label: name })), + () => [ + { value: NO_VALUE_OPTION_CODE, label: getNoValueOptionName() }, + ...data.map(({ code, name }) => ({ value: code, label: name })), + ], [data] ) @@ -170,12 +203,7 @@ export const OptionSetCondition: FC = ({ selectedWidth={TRANSFER_SELECTED_WIDTH} selectedEmptyComponent={} rightHeader={} - renderOption={(props) => ( - - )} + renderOption={renderOption} dataTest={`${dataTest}-transfer`} /> ) diff --git a/src/components/dimension-modal/conditions-modal-content/option-set-condition/styles/option-set-condition.module.css b/src/components/dimension-modal/conditions-modal-content/option-set-condition/styles/option-set-condition.module.css new file mode 100644 index 000000000..fe2d0b7cb --- /dev/null +++ b/src/components/dimension-modal/conditions-modal-content/option-set-condition/styles/option-set-condition.module.css @@ -0,0 +1,7 @@ +.noValueOption { + color: var(--colors-grey700); +} + +:global(.highlighted) > .noValueOption { + color: inherit; +} diff --git a/src/hooks/__tests__/use-conditions-texts.spec.ts b/src/hooks/__tests__/use-conditions-texts.spec.ts index 3d6f86a46..bfcbf4351 100644 --- a/src/hooks/__tests__/use-conditions-texts.spec.ts +++ b/src/hooks/__tests__/use-conditions-texts.spec.ts @@ -57,6 +57,8 @@ vi.mock('@modules/conditions', () => ({ getBooleanConditionTexts: mockGetBooleanConditionTexts, getOrgUnitConditionMetadataIds: mockGetOrgUnitConditionMetadataIds, getOperatorConditionTexts: mockGetOperatorConditionTexts, + NO_VALUE_OPTION_CODE: 'D2__NOVALUE', + getNoValueOptionName: () => 'No value', })) type WrapperProps = { children: ReactNode } @@ -138,7 +140,7 @@ describe('useConditionsTexts metadata updates', () => { expect(result.current.texts).toEqual(['Legend Alpha', 'Legend Beta']) }) - it('updates option set condition texts once the option set metadata becomes available', () => { + it('names option set condition texts as their metadata arrives, falling back to the code', () => { mockShouldUseOptionSetConditions.mockReturnValue(true) const optionSetId = 'OS_123' const selectedOptionCodes = ['A', 'B'] @@ -170,7 +172,7 @@ describe('useConditionsTexts metadata updates', () => { }) }) - expect(result.current.texts).toEqual(['Alpha']) + expect(result.current.texts).toEqual(['Alpha', 'B']) act(() => { result.current.addMetadata({ @@ -325,4 +327,41 @@ describe('useConditionsTexts metadata updates', () => { expect(result.current.texts).toEqual(['Alpha', 'Beta']) }) + + it('keeps the selected order and names the no-value option without metadata', () => { + mockShouldUseOptionSetConditions.mockReturnValue(true) + const optionSetId = 'OS_ORDER' + mockGetOptionSetIdAndSelectedOptionCodes.mockReturnValue({ + optionSetId, + selectedOptionCodes: ['B', 'D2__NOVALUE', 'A'], + }) + + const { result } = renderHook( + () => { + const texts = useConditionsTexts({ + conditions: { condition: 'in:B;D2__NOVALUE;A' }, + dimension: { ...baseDimension, optionSet: optionSetId }, + formatValueOptions: {}, + }) + const addMetadata = useAddMetadata() + return { texts, addMetadata } + }, + { wrapper: DefaultWrapper } + ) + + expect(result.current.texts).toEqual(['B', 'No value', 'A']) + + act(() => { + result.current.addMetadata({ + id: optionSetId, + name: 'Status', + options: [ + { code: 'A', name: 'Alpha' }, + { code: 'B', name: 'Beta' }, + ], + }) + }) + + expect(result.current.texts).toEqual(['Beta', 'No value', 'Alpha']) + }) }) diff --git a/src/hooks/use-conditions-texts.ts b/src/hooks/use-conditions-texts.ts index 7def1442c..ac7f0bb8b 100644 --- a/src/hooks/use-conditions-texts.ts +++ b/src/hooks/use-conditions-texts.ts @@ -12,6 +12,8 @@ import { getBooleanConditionTexts, getOrgUnitConditionMetadataIds, getOperatorConditionTexts, + getNoValueOptionName, + NO_VALUE_OPTION_CODE, } from '@modules/conditions' import { isOptionSetMetadataItem } from '@modules/metadata/item-guards' import type { SavedVisualization } from '@types' @@ -92,20 +94,19 @@ export const useConditionsTexts = ({ getOptionSetIdAndSelectedOptionCodes(dimension, conditionsList) const optionSetMetadata = metadataItems[optionSetId] - if (isOptionSetMetadataItem(optionSetMetadata)) { - const selectedOptionCodesLookup = new Set(selectedOptionCodes) - return ( - optionSetMetadata.options - .filter((option) => - selectedOptionCodesLookup.has(option.code) - ) - // Prefer name - .map((option) => option.name) - ) - } else { - // Fallback to ID - return selectedOptionCodes - } + const optionNamesByCode = new Map( + isOptionSetMetadataItem(optionSetMetadata) + ? optionSetMetadata.options.map((option) => [ + option.code, + option.name, + ]) + : [] + ) + optionNamesByCode.set(NO_VALUE_OPTION_CODE, getNoValueOptionName()) + + return selectedOptionCodes.map( + (code) => optionNamesByCode.get(code) ?? code + ) } if (shouldUseBooleanConditions(conditions, dimension, conditionsList)) { return getBooleanConditionTexts(conditionsList) diff --git a/src/modules/conditions.ts b/src/modules/conditions.ts index 464ccee61..01dbfe732 100644 --- a/src/modules/conditions.ts +++ b/src/modules/conditions.ts @@ -166,6 +166,8 @@ export const FALSE_VALUE: BooleanValue = '0' export const NULL_VALUE: BooleanValue = 'NV' export const TRUE_VALUE: BooleanValue = '1' export const OPERATOR_IN: QueryOperator = 'IN' +export const NO_VALUE_OPTION_CODE = 'D2__NOVALUE' +export const getNoValueOptionName = (): string => i18n.t('No value') export const OPERATOR_EQUAL: QueryOperator = 'EQ' export const OPERATOR_EMPTY = `EQ:${NULL_VALUE}` export const OPERATOR_NOT_EMPTY = `NE:${NULL_VALUE}`