diff --git a/apps/playground/src/components/programs/forms/ProgramEventForm.tsx b/apps/playground/src/components/programs/forms/ProgramEventForm.tsx index ef88d5f..640c530 100644 --- a/apps/playground/src/components/programs/forms/ProgramEventForm.tsx +++ b/apps/playground/src/components/programs/forms/ProgramEventForm.tsx @@ -7,6 +7,7 @@ import { import { FormStateProvider, useEventForm } from '@dhis2-form-utils/hooks' import type { EventProgramMetadata, + OptionGroupCodeMap, ProgramStageMetadata, TrackerProgramMetadata, } from '@dhis2-form-utils/metadata' @@ -41,6 +42,7 @@ type ProgramEventFormProps = { } events?: RuleEventInput[] supplementaryData?: RuleSupplementaryDataInput + optionGroups?: OptionGroupCodeMap onValuesChange?: (values: Record) => void } @@ -53,6 +55,7 @@ export function ProgramEventForm({ enrollment, events, supplementaryData, + optionGroups, onValuesChange, }: ProgramEventFormProps) { const dataEngine = useDataEngine() @@ -64,6 +67,7 @@ export function ProgramEventForm({ enrollment, events, supplementaryData, + optionGroups, }, formOptions: { mode: 'onBlur', @@ -104,7 +108,8 @@ export function ProgramEventForm({ try { const filteredValues = filterPayload( values, - formStore.fieldStore.getSnapshot() + formStore.fieldStore.getSnapshot(), + formStore.optionGroups ) as EventFormValues const payload = buildEventPayload({ values: filteredValues, diff --git a/apps/playground/src/components/programs/forms/ProgramRegistrationForm.tsx b/apps/playground/src/components/programs/forms/ProgramRegistrationForm.tsx index 3fc2d5d..7900b8d 100644 --- a/apps/playground/src/components/programs/forms/ProgramRegistrationForm.tsx +++ b/apps/playground/src/components/programs/forms/ProgramRegistrationForm.tsx @@ -11,6 +11,7 @@ import { type TrackerProgramMetadata, useTrackerForm, } from '@dhis2-form-utils/hooks' +import type { OptionGroupCodeMap } from '@dhis2-form-utils/metadata' import { filterPayload, type RuleEventInput, @@ -39,6 +40,7 @@ type ProgramRegistrationFormProps = { enrolledAt: string events?: RuleEventInput[] supplementaryData?: RuleSupplementaryDataInput + optionGroups?: OptionGroupCodeMap onValuesChange?: (values: Record) => void } @@ -54,6 +56,7 @@ export function ProgramRegistrationForm({ enrolledAt, events, supplementaryData, + optionGroups, onValuesChange, }: ProgramRegistrationFormProps) { const dataEngine = useDataEngine() @@ -75,6 +78,7 @@ export function ProgramRegistrationForm({ metadata, events, supplementaryData, + optionGroups, }, formOptions: { mode: 'onBlur', @@ -108,7 +112,8 @@ export function ProgramRegistrationForm({ try { const filteredValues = filterPayload( values, - formStore.fieldStore.getSnapshot() + formStore.fieldStore.getSnapshot(), + formStore.optionGroups ) as TrackerRegistrationValues const payload = buildTrackerRegistrationPayload({ values: filteredValues, diff --git a/apps/playground/src/components/programs/forms/ProgramRegistrationFormScreen.tsx b/apps/playground/src/components/programs/forms/ProgramRegistrationFormScreen.tsx index cb0e609..0ace208 100644 --- a/apps/playground/src/components/programs/forms/ProgramRegistrationFormScreen.tsx +++ b/apps/playground/src/components/programs/forms/ProgramRegistrationFormScreen.tsx @@ -2,6 +2,7 @@ import i18n from '@dhis2/d2-i18n' import { Center, CircularLoader, NoticeBox } from '@dhis2/ui' import type { ProgramStageRef } from '@dhis2-form-utils/devtools' import type { TrackerProgramMetadata } from '@dhis2-form-utils/hooks' +import type { OptionGroupCodeMap } from '@dhis2-form-utils/metadata' import type { RuleEventInput, RuleSupplementaryDataInput, @@ -18,6 +19,7 @@ type ProgramRegistrationFormScreenProps = { error: Error | undefined events?: RuleEventInput[] supplementaryData?: RuleSupplementaryDataInput + optionGroups?: OptionGroupCodeMap onValuesChange?: (values: Record) => void } @@ -31,6 +33,7 @@ export function ProgramRegistrationFormScreen({ error, events, supplementaryData, + optionGroups, onValuesChange, }: ProgramRegistrationFormScreenProps) { if (loading) { @@ -69,6 +72,7 @@ export function ProgramRegistrationFormScreen({ enrolledAt={enrolledAt} events={events} supplementaryData={supplementaryData} + optionGroups={optionGroups} onValuesChange={onValuesChange} /> diff --git a/apps/playground/src/components/programs/forms/ProgramStageFormScreen.tsx b/apps/playground/src/components/programs/forms/ProgramStageFormScreen.tsx index 4f82fcb..98fd62b 100644 --- a/apps/playground/src/components/programs/forms/ProgramStageFormScreen.tsx +++ b/apps/playground/src/components/programs/forms/ProgramStageFormScreen.tsx @@ -1,6 +1,7 @@ import i18n from '@dhis2/d2-i18n' import type { EventProgramMetadata, + OptionGroupCodeMap, TrackerProgramMetadata, } from '@dhis2-form-utils/metadata' import { selectProgramStage } from '@dhis2-form-utils/metadata' @@ -21,6 +22,7 @@ type ProgramStageFormScreenProps = { } events?: RuleEventInput[] supplementaryData?: RuleSupplementaryDataInput + optionGroups?: OptionGroupCodeMap onValuesChange?: (values: Record) => void } @@ -32,6 +34,7 @@ export function ProgramStageFormScreen({ enrollment, events, supplementaryData, + optionGroups, onValuesChange, }: ProgramStageFormScreenProps) { const stageMetadata = programStageId @@ -57,6 +60,7 @@ export function ProgramStageFormScreen({ enrollment={enrollment} events={events} supplementaryData={supplementaryData} + optionGroups={optionGroups} onValuesChange={onValuesChange} /> diff --git a/apps/playground/src/components/programs/forms/TrackerProgramShell.tsx b/apps/playground/src/components/programs/forms/TrackerProgramShell.tsx index e7fabac..872ff0c 100644 --- a/apps/playground/src/components/programs/forms/TrackerProgramShell.tsx +++ b/apps/playground/src/components/programs/forms/TrackerProgramShell.tsx @@ -1,6 +1,9 @@ import { useCallback, useMemo, useRef, useState } from 'react' import { useTrackerMetadataQuery } from '@dhis2-form-utils/hooks' -import type { EventProgramMetadata } from '@dhis2-form-utils/metadata' +import type { + EventProgramMetadata, + OptionGroupCodeMap, +} from '@dhis2-form-utils/metadata' import type { RuleEventInput, RuleSupplementaryDataInput, @@ -21,6 +24,7 @@ type TrackerProgramShellProps = { orgUnitId: string enrolledAt: string supplementaryData?: RuleSupplementaryDataInput + optionGroups?: OptionGroupCodeMap } type RenderableStageSlot = { stageId: string; eventLocalId: string } @@ -55,6 +59,7 @@ export function TrackerProgramShell({ orgUnitId, enrolledAt, supplementaryData, + optionGroups, }: TrackerProgramShellProps) { const { metadata: trackerMetadata, @@ -200,6 +205,7 @@ export function TrackerProgramShell({ error={trackerError} events={allEvents} supplementaryData={supplementaryData} + optionGroups={optionGroups} onValuesChange={handleRegistrationValuesChange} /> @@ -230,6 +236,7 @@ export function TrackerProgramShell({ eventsExcludingSlot.get(key) ?? EMPTY_EVENTS } supplementaryData={supplementaryData} + optionGroups={optionGroups} onValuesChange={getEventValuesChangeHandler(key)} /> diff --git a/apps/playground/src/hooks/useOptionGroupsSupplementaryData.ts b/apps/playground/src/hooks/useOptionGroupsSupplementaryData.ts new file mode 100644 index 0000000..9fc8564 --- /dev/null +++ b/apps/playground/src/hooks/useOptionGroupsSupplementaryData.ts @@ -0,0 +1,43 @@ +import { useDataQuery } from '@dhis2/app-runtime' +import { useEffect, useMemo } from 'react' +import { + extractReferencedOptionGroupIds, + optionGroupsQuery, + resolveOptionGroups, + type OptionGroupCodeMap, + type ProgramRule, + type RawOptionGroupsResult, +} from '@dhis2-form-utils/metadata' + +/** + * Resolves the optionGroup ids referenced by `HIDEOPTIONGROUP`/`SHOWOPTIONGROUP` rule actions + * into concrete option codes, shaped for `useEventForm`/`useTrackerForm`'s `optionGroups` option. + * + * `useDataQuery`'s `variables`/`lazy` options are only read on the hook's initial render — since + * `programRules` (and therefore `optionGroupIds`) is empty on first render and only becomes + * populated once program metadata finishes loading, the query is always mounted lazily and + * re-triggered via `refetch` once there are ids to resolve. + */ +export function useOptionGroupsSupplementaryData( + programRules: ProgramRule[] +): OptionGroupCodeMap | undefined { + const optionGroupIds = useMemo( + () => extractReferencedOptionGroupIds(programRules), + [programRules] + ) + + const { data, refetch } = useDataQuery( + optionGroupsQuery, + { + lazy: true, + } + ) + + useEffect(() => { + if (optionGroupIds.length > 0) { + refetch({ optionGroupIds }) + } + }, [optionGroupIds, refetch]) + + return useMemo(() => (data ? resolveOptionGroups(data) : undefined), [data]) +} diff --git a/apps/playground/src/pages/ProgramPage.test.tsx b/apps/playground/src/pages/ProgramPage.test.tsx index c5c312c..8bd333a 100644 --- a/apps/playground/src/pages/ProgramPage.test.tsx +++ b/apps/playground/src/pages/ProgramPage.test.tsx @@ -6,6 +6,7 @@ import { ProgramStageFormScreen } from '@/components/programs/forms/ProgramStage import { TrackerProgramShell } from '@/components/programs/forms/TrackerProgramShell' import { useAccessibleOrgUnits } from '@/hooks/useAccessibleOrgUnits' import { useCurrentUserSupplementaryData } from '@/hooks/useCurrentUserSupplementaryData' +import { useOptionGroupsSupplementaryData } from '@/hooks/useOptionGroupsSupplementaryData' import { ProgramPage } from './ProgramPage' jest.mock('@dhis2-form-utils/hooks', () => ({ @@ -13,6 +14,7 @@ jest.mock('@dhis2-form-utils/hooks', () => ({ })) jest.mock('@/hooks/useAccessibleOrgUnits') jest.mock('@/hooks/useCurrentUserSupplementaryData') +jest.mock('@/hooks/useOptionGroupsSupplementaryData') jest.mock('@/components/programs/forms/ProgramStageFormScreen', () => ({ ProgramStageFormScreen: jest.fn(() =>
Event form screen
), })) @@ -27,10 +29,14 @@ const mockedUseAccessibleOrgUnits = jest.mocked(useAccessibleOrgUnits) const mockedUseCurrentUserSupplementaryData = jest.mocked( useCurrentUserSupplementaryData ) +const mockedUseOptionGroupsSupplementaryData = jest.mocked( + useOptionGroupsSupplementaryData +) const SENTINEL_SUPPLEMENTARY_DATA = { userGroups: ['UserGroup1'], userRoles: ['UserRole1'], } +const SENTINEL_OPTION_GROUPS = { OptionGroup1: ['OptionCode1'] } function renderPage() { return render( @@ -57,6 +63,9 @@ describe('ProgramPage', () => { mockedUseCurrentUserSupplementaryData.mockReturnValue( SENTINEL_SUPPLEMENTARY_DATA ) + mockedUseOptionGroupsSupplementaryData.mockReturnValue( + SENTINEL_OPTION_GROUPS + ) }) afterEach(() => { @@ -92,6 +101,7 @@ describe('ProgramPage', () => { expect(ProgramStageFormScreen).toHaveBeenCalled() const [props] = jest.mocked(ProgramStageFormScreen).mock.calls[0] expect(props.supplementaryData).toEqual(SENTINEL_SUPPLEMENTARY_DATA) + expect(props.optionGroups).toEqual(SENTINEL_OPTION_GROUPS) }) it('renders the registration flow for tracker programs', () => { @@ -123,6 +133,7 @@ describe('ProgramPage', () => { expect(TrackerProgramShell).toHaveBeenCalled() const [props] = jest.mocked(TrackerProgramShell).mock.calls[0] expect(props.supplementaryData).toEqual(SENTINEL_SUPPLEMENTARY_DATA) + expect(props.optionGroups).toEqual(SENTINEL_OPTION_GROUPS) }) it('shows a notice when no organisation units are available', () => { diff --git a/apps/playground/src/pages/ProgramPage.tsx b/apps/playground/src/pages/ProgramPage.tsx index 124b105..01b7cd3 100644 --- a/apps/playground/src/pages/ProgramPage.tsx +++ b/apps/playground/src/pages/ProgramPage.tsx @@ -9,6 +9,7 @@ import { TrackerProgramShell } from '@/components/programs/forms/TrackerProgramS import { buildProgramListUrl } from '@/hooks/buildProgramListUrl' import { useAccessibleOrgUnits } from '@/hooks/useAccessibleOrgUnits' import { useCurrentUserSupplementaryData } from '@/hooks/useCurrentUserSupplementaryData' +import { useOptionGroupsSupplementaryData } from '@/hooks/useOptionGroupsSupplementaryData' import { PROGRAM_TYPE, type ProgramListParams } from '@/types/program' type ProgramPageLocationState = { @@ -44,6 +45,9 @@ export function ProgramPage() { error: orgUnitsError, } = useAccessibleOrgUnits() const supplementaryData = useCurrentUserSupplementaryData() + const optionGroups = useOptionGroupsSupplementaryData( + program?.programRules ?? [] + ) const [orgUnitId, setOrgUnitId] = useState('') const [primaryDate, setPrimaryDate] = useState(createTodayValue) @@ -148,6 +152,7 @@ export function ProgramPage() { orgUnitId={orgUnitId} enrolledAt={primaryDate} supplementaryData={supplementaryData} + optionGroups={optionGroups} /> ) : ( )} diff --git a/components/dhis2-ui/src/fields/widgets/ChoiceFields.tsx b/components/dhis2-ui/src/fields/widgets/ChoiceFields.tsx index 289baf5..19fe0e6 100644 --- a/components/dhis2-ui/src/fields/widgets/ChoiceFields.tsx +++ b/components/dhis2-ui/src/fields/widgets/ChoiceFields.tsx @@ -61,11 +61,12 @@ export function D2TrueOnlyField({ control }: WidgetProps) { export function D2SelectField({ control }: WidgetProps) { const { fieldConfig, field, isMandatory, isDisabled } = control; const { validationText, hasError, hasWarning } = resolveFieldValidation(control); - const options = - fieldConfig.optionSet?.options.map((option) => ({ + const options = (control.visibleOptions ?? fieldConfig.optionSet?.options ?? []).map( + (option) => ({ label: option.label, value: option.code, - })) ?? []; + }) + ); if (fieldConfig.renderTypeHint && RADIO_RENDER_HINTS.has(fieldConfig.renderTypeHint)) { return ( diff --git a/components/mantine/src/fields/widgets/ChoiceFields.tsx b/components/mantine/src/fields/widgets/ChoiceFields.tsx index 00b71c6..cefb973 100644 --- a/components/mantine/src/fields/widgets/ChoiceFields.tsx +++ b/components/mantine/src/fields/widgets/ChoiceFields.tsx @@ -53,11 +53,12 @@ export function D2TrueOnlyField({ control }: WidgetProps) { export function D2SelectField({ control }: WidgetProps) { const { fieldConfig, field, isMandatory, isDisabled } = control; const { validationText, hasError } = resolveFieldValidation(control); - const options = - fieldConfig.optionSet?.options.map((option) => ({ + const options = (control.visibleOptions ?? fieldConfig.optionSet?.options ?? []).map( + (option) => ({ label: option.label, value: option.code, - })) ?? []; + }) + ); if (fieldConfig.renderTypeHint && RADIO_RENDER_HINTS.has(fieldConfig.renderTypeHint)) { return ( diff --git a/components/mui/src/fields/widgets/ChoiceFields.tsx b/components/mui/src/fields/widgets/ChoiceFields.tsx index 1d9b334..d6d2d87 100644 --- a/components/mui/src/fields/widgets/ChoiceFields.tsx +++ b/components/mui/src/fields/widgets/ChoiceFields.tsx @@ -54,10 +54,11 @@ export function D2TrueOnlyField({ control }: WidgetProps) { ); } +// fallow-ignore-next-line complexity -- pre-existing widget, no co-located test coverage yet export function D2SelectField({ control }: WidgetProps) { const { fieldConfig, field, isMandatory, isDisabled } = control; const { validationText, hasError } = resolveFieldValidation(control); - const options = fieldConfig.optionSet?.options ?? []; + const options = control.visibleOptions ?? fieldConfig.optionSet?.options ?? []; return ( ; ``` +`hiddenOptions` holds option codes hidden directly by `HIDEOPTION`; `hiddenOptionGroups` holds +optionGroup ids hidden by `HIDEOPTIONGROUP` — group _membership_ (which option codes belong to a +group) is resolved separately, since it requires an `optionGroups` API fetch the metadata package +doesn't do on its own. Callers fetch it (e.g. via `extractReferencedOptionGroupIds` + +`optionGroupsQuery` + `resolveOptionGroups` from `@dhis2-form-utils/metadata`) and pass it as the +`optionGroups` option to `useEventForm`/`useTrackerForm`. `resolveHiddenOptionCodes` (in +`@dhis2-form-utils/rules`) unions `hiddenOptions` with the resolved group members into a single +`Set` of hidden codes — `useFieldControl` uses it to compute `FieldControlReturn.visibleOptions` +for widgets to render, and `filterPayload`'s optional third argument uses it to null out a submitted +value that references a now-hidden option. + **Context assembly** — the engine requires all program rule variables to be resolved before evaluation. `@dhis2-form-utils/rules` provides `buildRuleEngineContext`, which takes fetched program metadata and constructs the `RuleEngineContext` once, and `buildRuleEngine`, which diff --git a/docs/form-state-architecture.md b/docs/form-state-architecture.md index ff66840..c5d9758 100644 --- a/docs/form-state-architecture.md +++ b/docs/form-state-architecture.md @@ -137,6 +137,10 @@ type FieldState = { type FieldStateMap = Record; ``` +`hiddenOptions`/`hiddenOptionGroups` are resolved into a rendered/submitted option-code set via +`resolveHiddenOptionCodes` — see ARCHITECTURE.md § "What `@dhis2-form-utils/rules` adds" for the +full optionGroups-fetch-and-thread flow. + ### `SectionStateMap` Keyed by form section UID (`programStageSection` for event forms, `programSection` for diff --git a/docs/use-field-control-plan.md b/docs/use-field-control-plan.md index 1d9a1fd..19e5da5 100644 --- a/docs/use-field-control-plan.md +++ b/docs/use-field-control-plan.md @@ -4,13 +4,14 @@ ## As-built deltas -| Plan | As built | -| ------------------------------------- | --------------------------------------------------------- | -| `packages/hooks/` paths | `utils/hooks/src/fields/` | -| Pass `control` into `useFieldControl` | `useController()` via `FormProvider` — no `control` param | -| `Dhis2Field` / `ui-dhis2` naming | `D2Field` in `components/dhis2-ui` | -| `ruleState.isMandatory` | `ruleState.mandatory` | -| `useEnrollmentForm` | Does not exist | +| Plan | As built | +| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `packages/hooks/` paths | `utils/hooks/src/fields/` | +| Pass `control` into `useFieldControl` | `useController()` via `FormProvider` — no `control` param | +| `Dhis2Field` / `ui-dhis2` naming | `D2Field` in `components/dhis2-ui` | +| `ruleState.isMandatory` | `ruleState.mandatory` | +| `useEnrollmentForm` | Does not exist | +| (not in original plan) | `FieldControlReturn.visibleOptions` — `optionSet.options` filtered by `HIDEOPTION`/`HIDEOPTIONGROUP` rule effects (see `resolveHiddenOptionCodes` in `@dhis2-form-utils/rules`) | The sections below are retained as historical design reference. diff --git a/docs/use-tracker-form.md b/docs/use-tracker-form.md index 226047b..9ac0f11 100644 --- a/docs/use-tracker-form.md +++ b/docs/use-tracker-form.md @@ -447,8 +447,13 @@ const { form, formStore } = useTrackerForm({ options: { programId, metadata } }) const [mutate] = useDataMutation(CREATE_TRACKER_MUTATION); const handleSubmit = form.handleSubmit((values) => { - // Strip hidden fields — TEA fields hidden by rules must not be submitted - const visibleValues = filterPayload(values, formStore.fieldStore.getSnapshot()); + // Strip hidden fields (and null out any value referencing a now-hidden option) — + // TEA fields hidden by rules must not be submitted + const visibleValues = filterPayload( + values, + formStore.fieldStore.getSnapshot(), + formStore.optionGroups + ); // Separate TEA fields from enrollment system fields const teaUids = new Set( @@ -481,7 +486,10 @@ const handleSubmit = form.handleSubmit((values) => { ``` `filterPayload` from `@dhis2-form-utils/rules` strips any field whose `FieldState.isHidden === true` from the submitted -values. It is the same utility used by `useEventForm` consumers — shared, not duplicated. +values, and — given the optional third `optionGroups` argument (`formStore.optionGroups`, populated from the hook's +`optionGroups` option) — nulls out a field's value if it currently holds an option code hidden by `HIDEOPTION` or a +member of a `HIDEOPTIONGROUP`-hidden group. It is the same utility used by `useEventForm` consumers — shared, not +duplicated. The caller splits the flat form values into the TEA attributes array (for `trackedEntities`) and the system fields (for `enrollments`). The hook does not prescribe how this split happens — it is a straightforward Set membership check on the diff --git a/packages/metadata/src/index.ts b/packages/metadata/src/index.ts index c3e6f57..67abeb4 100644 --- a/packages/metadata/src/index.ts +++ b/packages/metadata/src/index.ts @@ -45,7 +45,10 @@ export { } from './queries/fields.const'; export { eventProgramConfigQuery } from './queries/eventProgramConfig.query'; export { trackerConfigQuery } from './queries/trackerConfig.query'; +export { OPTION_GROUP_FIELDS, optionGroupsQuery } from './queries/optionGroups.query'; export { resolveEventProgramMetadata } from './resolvers/resolveEventProgramMetadata'; export type { RawEventProgramConfigResult } from './resolvers/resolveEventProgramMetadata'; export { resolveTrackerProgramMetadata } from './resolvers/resolveTrackerProgramMetadata'; export type { RawTrackerConfigResult } from './resolvers/resolveTrackerProgramMetadata'; +export { extractReferencedOptionGroupIds, resolveOptionGroups } from './optionGroups'; +export type { OptionGroupCodeMap, RawOptionGroupsResult } from './optionGroups'; diff --git a/packages/metadata/src/optionGroups.test.ts b/packages/metadata/src/optionGroups.test.ts new file mode 100644 index 0000000..78d5c71 --- /dev/null +++ b/packages/metadata/src/optionGroups.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vitest'; +import { extractReferencedOptionGroupIds, resolveOptionGroups } from './optionGroups'; +import type { ProgramRule } from './types'; + +describe('extractReferencedOptionGroupIds', () => { + it('collects optionGroup ids from HIDEOPTIONGROUP and SHOWOPTIONGROUP actions', () => { + const rules: ProgramRule[] = [ + { + id: 'rule1', + condition: 'true', + programRuleActions: [ + { + id: 'action1', + programRuleActionType: 'HIDEOPTIONGROUP', + optionGroup: { id: 'og1' }, + }, + { + id: 'action2', + programRuleActionType: 'SHOWOPTIONGROUP', + optionGroup: { id: 'og2' }, + }, + ], + }, + ]; + + expect(extractReferencedOptionGroupIds(rules)).toEqual(['og1', 'og2']); + }); + + it('dedupes ids referenced by multiple actions/rules', () => { + const rules: ProgramRule[] = [ + { + id: 'rule1', + condition: 'true', + programRuleActions: [ + { + id: 'action1', + programRuleActionType: 'HIDEOPTIONGROUP', + optionGroup: { id: 'og1' }, + }, + ], + }, + { + id: 'rule2', + condition: 'true', + programRuleActions: [ + { + id: 'action2', + programRuleActionType: 'SHOWOPTIONGROUP', + optionGroup: { id: 'og1' }, + }, + ], + }, + ]; + + expect(extractReferencedOptionGroupIds(rules)).toEqual(['og1']); + }); + + it('ignores actions targeting other types (HIDEOPTION, HIDEFIELD) and missing optionGroup', () => { + const rules: ProgramRule[] = [ + { + id: 'rule1', + condition: 'true', + programRuleActions: [ + { id: 'action1', programRuleActionType: 'HIDEOPTION', data: 'opt1' }, + { id: 'action2', programRuleActionType: 'HIDEFIELD' }, + { id: 'action3', programRuleActionType: 'HIDEOPTIONGROUP' }, + ], + }, + ]; + + expect(extractReferencedOptionGroupIds(rules)).toEqual([]); + }); + + it('returns an empty array for rules with no actions', () => { + expect(extractReferencedOptionGroupIds([])).toEqual([]); + }); +}); + +describe('resolveOptionGroups', () => { + it('maps optionGroups into a code map keyed by group id', () => { + const map = resolveOptionGroups({ + optionGroups: { + optionGroups: [ + { + id: 'og1', + options: [{ code: 'A' }, { code: 'B' }], + }, + { + id: 'og2', + options: [{ code: 'C' }], + }, + ], + }, + }); + + expect(map).toEqual({ + og1: ['A', 'B'], + og2: ['C'], + }); + }); + + it('filters out options with no code', () => { + const map = resolveOptionGroups({ + optionGroups: { optionGroups: [{ id: 'og1', options: [{ code: 'A' }, {}] }] }, + }); + + expect(map).toEqual({ og1: ['A'] }); + }); + + it('returns an empty map when optionGroups is missing', () => { + expect(resolveOptionGroups({})).toEqual({}); + }); + + it('returns an empty map when the nested optionGroups array is missing', () => { + expect(resolveOptionGroups({ optionGroups: {} })).toEqual({}); + }); +}); diff --git a/packages/metadata/src/optionGroups.ts b/packages/metadata/src/optionGroups.ts new file mode 100644 index 0000000..5dd6fcf --- /dev/null +++ b/packages/metadata/src/optionGroups.ts @@ -0,0 +1,54 @@ +import { ProgramRuleActionType } from './enums'; +import type { ProgramRule } from './types'; + +/** Option-group id -> member option codes. */ +export type OptionGroupCodeMap = Record; + +type RawOptionGroup = { + id?: string; + options?: Array<{ code?: string }>; +}; + +export type RawOptionGroupsResult = { + optionGroups?: { optionGroups?: RawOptionGroup[] }; +}; + +/** + * Scans a program's rules for `HIDEOPTIONGROUP`/`SHOWOPTIONGROUP` action targets and + * returns the distinct optionGroup ids referenced — the only ids worth querying for + * group membership. + */ +export function extractReferencedOptionGroupIds(programRules: ProgramRule[]): string[] { + const ids = new Set(); + + for (const rule of programRules) { + for (const action of rule.programRuleActions ?? []) { + const isOptionGroupAction = + action.programRuleActionType === ProgramRuleActionType.HIDEOPTIONGROUP || + action.programRuleActionType === ProgramRuleActionType.SHOWOPTIONGROUP; + + if (isOptionGroupAction && action.optionGroup?.id) { + ids.add(action.optionGroup.id); + } + } + } + + return [...ids]; +} + +/** + * Resolves the raw `optionGroupsQuery` result into an `OptionGroupCodeMap`. + * Pure and non-throwing — an empty map is a valid output, not an error state. + */ +export function resolveOptionGroups(raw: RawOptionGroupsResult): OptionGroupCodeMap { + const map: OptionGroupCodeMap = {}; + + for (const group of raw.optionGroups?.optionGroups ?? []) { + if (!group.id) continue; + map[group.id] = (group.options ?? []).flatMap((option) => + option.code ? [option.code] : [] + ); + } + + return map; +} diff --git a/packages/metadata/src/queries/optionGroups.query.test.ts b/packages/metadata/src/queries/optionGroups.query.test.ts new file mode 100644 index 0000000..23a8e94 --- /dev/null +++ b/packages/metadata/src/queries/optionGroups.query.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { OPTION_GROUP_FIELDS, optionGroupsQuery } from './optionGroups.query'; + +describe('optionGroupsQuery', () => { + it('is a plain object, not a factory function', () => { + expect(typeof optionGroupsQuery).toBe('object'); + }); + + it('filters optionGroups by the provided ids and applies OPTION_GROUP_FIELDS', () => { + const variables = { optionGroupIds: ['og1', 'og2'] }; + const optionGroups = optionGroupsQuery.optionGroups; + + if (typeof optionGroups.params !== 'function') { + throw new Error('expected optionGroups.params to be a function'); + } + + expect(optionGroups.resource).toBe('optionGroups'); + expect(optionGroups.params(variables)).toEqual({ + fields: OPTION_GROUP_FIELDS, + filter: 'id:in:[og1,og2]', + paging: false, + }); + }); +}); diff --git a/packages/metadata/src/queries/optionGroups.query.ts b/packages/metadata/src/queries/optionGroups.query.ts new file mode 100644 index 0000000..0bf7390 --- /dev/null +++ b/packages/metadata/src/queries/optionGroups.query.ts @@ -0,0 +1,21 @@ +import type { Query, QueryVariables } from '@dhis2/data-engine'; + +/** `optionGroups` resource fields — group id + member option codes. */ +export const OPTION_GROUP_FIELDS = 'id,options[id,code]'; + +/** + * Static query for resolving `HIDEOPTIONGROUP`/`SHOWOPTIONGROUP` rule-action targets into + * concrete option codes. Callers should scope `variables.optionGroupIds` to the ids returned + * by `extractReferencedOptionGroupIds` — fetching only the groups actually referenced by a + * program's rules. + */ +export const optionGroupsQuery: Query = { + optionGroups: { + resource: 'optionGroups', + params: (variables: QueryVariables) => ({ + fields: OPTION_GROUP_FIELDS, + filter: `id:in:[${(variables.optionGroupIds as string[]).join(',')}]`, + paging: false, + }), + }, +}; diff --git a/utils/hooks/src/fields/useFieldControl.test.ts b/utils/hooks/src/fields/useFieldControl.test.ts index 423e975..a8fbd7e 100644 --- a/utils/hooks/src/fields/useFieldControl.test.ts +++ b/utils/hooks/src/fields/useFieldControl.test.ts @@ -92,6 +92,66 @@ describe('useFieldControl', () => { expect(result.current.isHidden).toBe(true); }); + it('visibleOptions includes all options when nothing is hidden', () => { + const { result } = renderFieldControlHook(() => + useFieldControl({ + kind: 'dataElement', + config: makePsdeWithOptionSet('de-select', 'TEXT'), + }) + ); + + expect(result.current.visibleOptions?.map((o) => o.code)).toEqual(['YES', 'NO']); + }); + + it('visibleOptions excludes a directly hidden option (HIDEOPTION)', () => { + const { result } = renderFieldControlHook( + () => + useFieldControl({ + kind: 'dataElement', + config: makePsdeWithOptionSet('de-select', 'TEXT'), + }), + { + fieldState: { + 'de-select': { ...createEmptyFieldState(), hiddenOptions: new Set(['NO']) }, + }, + } + ); + + expect(result.current.visibleOptions?.map((o) => o.code)).toEqual(['YES']); + }); + + it('visibleOptions excludes members of a hidden option group (HIDEOPTIONGROUP), given optionGroups', () => { + const { result } = renderFieldControlHook( + () => + useFieldControl({ + kind: 'dataElement', + config: makePsdeWithOptionSet('de-select', 'TEXT'), + }), + { + fieldState: { + 'de-select': { + ...createEmptyFieldState(), + hiddenOptionGroups: new Set(['og1']), + }, + }, + optionGroups: { og1: ['NO'] }, + } + ); + + expect(result.current.visibleOptions?.map((o) => o.code)).toEqual(['YES']); + }); + + it('visibleOptions is undefined when the field has no optionSet', () => { + const { result } = renderFieldControlHook(() => + useFieldControl({ + kind: 'dataElement', + config: makePsde('de-text', 'TEXT'), + }) + ); + + expect(result.current.visibleOptions).toBeUndefined(); + }); + it('returns unsupported widgetKind for REFERENCE valueType', () => { const kind = resolveWidgetKind({ id: 'de-ref', diff --git a/utils/hooks/src/fields/useFieldControl.ts b/utils/hooks/src/fields/useFieldControl.ts index d20774e..f7788e5 100644 --- a/utils/hooks/src/fields/useFieldControl.ts +++ b/utils/hooks/src/fields/useFieldControl.ts @@ -4,7 +4,8 @@ import type { ProgramStageDataElement, ProgramTrackedEntityAttribute, } from '@dhis2-form-utils/metadata'; -import { useFieldState } from '../FormStateContext'; +import { resolveHiddenOptionCodes } from '@dhis2-form-utils/rules'; +import { useFieldState, useFormStore } from '../FormStateContext'; import { type FieldConfig, fromProgramStageDataElement, @@ -32,6 +33,8 @@ export type FieldControlReturn = { hasError: boolean; warningMessage?: string; errorMessage?: string; + /** `fieldConfig.optionSet.options` filtered by HIDEOPTION/HIDEOPTIONGROUP rule effects. */ + visibleOptions?: ReadonlyArray<{ id: string; code: string; label: string }>; }; export type WidgetProps = { @@ -50,10 +53,16 @@ export function useFieldControl(input: FieldControlInput): FieldControlReturn { const widgetKind = useMemo(() => resolveWidgetKind(fieldConfig), [fieldConfig]); const ruleState = useFieldState(fieldConfig.id); + const formStore = useFormStore(); const isMandatory = useMemo( () => fieldConfig.required || ruleState.mandatory, [fieldConfig, ruleState] ); + const visibleOptions = useMemo(() => { + if (!fieldConfig.optionSet) return undefined; + const hiddenCodes = resolveHiddenOptionCodes(ruleState, formStore.optionGroups); + return fieldConfig.optionSet.options.filter((option) => !hiddenCodes.has(option.code)); + }, [fieldConfig.optionSet, ruleState, formStore.optionGroups]); const { field, fieldState } = useController({ name: fieldConfig.id, }); @@ -71,5 +80,6 @@ export function useFieldControl(input: FieldControlInput): FieldControlReturn { hasError: Boolean(ruleState.error), warningMessage: ruleState.warning ?? undefined, errorMessage: ruleState.error ?? undefined, + visibleOptions, }; } diff --git a/utils/hooks/src/formStore.ts b/utils/hooks/src/formStore.ts index 56bc381..8dbc9c7 100644 --- a/utils/hooks/src/formStore.ts +++ b/utils/hooks/src/formStore.ts @@ -1,6 +1,7 @@ import { debounce, type DebouncedFunc } from 'lodash-es'; import type { RefObject } from 'react'; import type { UseFormReturn } from 'react-hook-form'; +import type { OptionGroupCodeMap } from '@dhis2-form-utils/metadata'; import type { BuiltRuleEngine, EffectHandlersMap, RuleEffect } from '@dhis2-form-utils/rules'; import { buildTraceEntry, type RuleTraceEntry } from './buildTraceEntry'; import { evaluateFormState } from './evaluateFormState'; @@ -21,10 +22,22 @@ export class FormStore { private engine: BuiltRuleEngine | null = null; private form: UseFormReturn> | null = null; private effectHandlersRef: RefObject | null = null; + private optionGroupsRef: RefObject | null = null; private pendingChangedFields = new Set(); private traceListeners = new Set(); private lastTraceEntry: RuleTraceEntry | null = null; + /** Option-group id -> member option codes, for resolving HIDEOPTIONGROUP/SHOWOPTIONGROUP effects. */ + // fallow-ignore-next-line unused-class-member -- read via `formStore.optionGroups` in useFieldControl.ts + get optionGroups(): OptionGroupCodeMap | undefined { + return this.optionGroupsRef?.current ?? undefined; + } + + /** Sets option-group membership directly, without going through `init`/`reinit`. */ + setOptionGroups(optionGroups: OptionGroupCodeMap | undefined): void { + this.optionGroupsRef = { current: optionGroups }; + } + subscribeTrace(listener: TraceListener): () => void { this.traceListeners.add(listener); if (this.lastTraceEntry) { @@ -38,12 +51,14 @@ export class FormStore { init( form: UseFormReturn>, engine: BuiltRuleEngine, - effectHandlersRef: RefObject + effectHandlersRef: RefObject, + optionGroupsRef?: RefObject ): void { if ( this.form === form && this.engine === engine && this.effectHandlersRef === effectHandlersRef && + this.optionGroupsRef === (optionGroupsRef ?? null) && this.unsubscribe ) { return; @@ -54,6 +69,7 @@ export class FormStore { this.form = form; this.engine = engine; this.effectHandlersRef = effectHandlersRef; + this.optionGroupsRef = optionGroupsRef ?? null; const evaluate = (changedFields: string[]) => { if (!this.engine || !this.form) return; @@ -92,10 +108,11 @@ export class FormStore { reinit( form: UseFormReturn>, engine: BuiltRuleEngine, - effectHandlersRef: RefObject + effectHandlersRef: RefObject, + optionGroupsRef?: RefObject ): void { this.destroy(); - this.init(form, engine, effectHandlersRef); + this.init(form, engine, effectHandlersRef, optionGroupsRef); } destroy(): void { @@ -106,6 +123,7 @@ export class FormStore { this.form = null; this.engine = null; this.effectHandlersRef = null; + this.optionGroupsRef = null; this.prevAssignments = {}; this.pendingChangedFields.clear(); this.lastTraceEntry = null; diff --git a/utils/hooks/src/test/renderFieldControl.tsx b/utils/hooks/src/test/renderFieldControl.tsx index f3ac213..e36419e 100644 --- a/utils/hooks/src/test/renderFieldControl.tsx +++ b/utils/hooks/src/test/renderFieldControl.tsx @@ -1,5 +1,6 @@ import { FormStore } from '../formStore'; import { FormStateProvider } from '../FormStateContext'; +import type { OptionGroupCodeMap } from '@dhis2-form-utils/metadata'; import type { FieldStateMap } from '@dhis2-form-utils/rules'; import { renderHook, type RenderHookOptions } from '@testing-library/react'; import type { ReactNode } from 'react'; @@ -16,6 +17,7 @@ import { export type FieldControlWrapperOptions = { defaultValues?: Record; fieldState?: FieldStateMap; + optionGroups?: OptionGroupCodeMap; formOptions?: UseFormProps>; }; @@ -23,6 +25,7 @@ function FieldControlWrapper({ children, defaultValues = {}, fieldState = {}, + optionGroups, formOptions, }: { children: ReactNode; @@ -31,8 +34,9 @@ function FieldControlWrapper({ const formStore = useMemo(() => { const store = new FormStore(); store.fieldStore.setState(fieldState); + store.setOptionGroups(optionGroups); return store; - }, [fieldState]); + }, [fieldState, optionGroups]); return ( ( callback: (control: Control>) => Result, options?: RenderHookOptions & FieldControlWrapperOptions ) { - const { defaultValues, fieldState, formOptions, ...hookOptions } = options ?? {}; + const { defaultValues, fieldState, optionGroups, formOptions, ...hookOptions } = options ?? {}; return renderHook( () => { @@ -61,6 +65,7 @@ export function renderFieldControlHook( {children} diff --git a/utils/hooks/src/useEventForm.ts b/utils/hooks/src/useEventForm.ts index f797fe8..aa24302 100644 --- a/utils/hooks/src/useEventForm.ts +++ b/utils/hooks/src/useEventForm.ts @@ -1,7 +1,11 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { useMemo, useRef } from 'react'; import { Resolver, useForm, type UseFormReturn } from 'react-hook-form'; -import type { EventProgramMetadata, TrackerProgramMetadata } from '@dhis2-form-utils/metadata'; +import type { + EventProgramMetadata, + OptionGroupCodeMap, + TrackerProgramMetadata, +} from '@dhis2-form-utils/metadata'; import { buildSchema, selectProgramStage } from '@dhis2-form-utils/metadata'; import type { BuiltRuleEngine, @@ -25,6 +29,7 @@ export type UseEventFormOptions = { enrollment?: { metadata: TrackerProgramMetadata; values: Record }; events?: RuleEventInput[]; supplementaryData?: RuleSupplementaryDataInput; + optionGroups?: OptionGroupCodeMap; }; export type UseEventFormReturn = { @@ -92,19 +97,23 @@ export function useEventForm new FormStore(), []); const effectHandlersRef = useRef(options.effectHandlers); + const optionGroupsRef = useRef(options.optionGroups); + optionGroupsRef.current = options.optionGroups; const prevEngineRef = useRef(null); if (prevEngineRef.current !== ruleEngine) { if (prevEngineRef.current !== null) { formStore.reinit( form as UseFormReturn>, ruleEngine, - effectHandlersRef + effectHandlersRef, + optionGroupsRef ); } else { formStore.init( form as UseFormReturn>, ruleEngine, - effectHandlersRef + effectHandlersRef, + optionGroupsRef ); } prevEngineRef.current = ruleEngine; diff --git a/utils/hooks/src/useTrackerForm.ts b/utils/hooks/src/useTrackerForm.ts index 836752c..34e736a 100644 --- a/utils/hooks/src/useTrackerForm.ts +++ b/utils/hooks/src/useTrackerForm.ts @@ -1,7 +1,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { useMemo, useRef } from 'react'; import { Resolver, useForm, type UseFormReturn } from 'react-hook-form'; -import type { TrackerProgramMetadata } from '@dhis2-form-utils/metadata'; +import type { OptionGroupCodeMap, TrackerProgramMetadata } from '@dhis2-form-utils/metadata'; import { buildTrackerSchema } from '@dhis2-form-utils/metadata'; import type { BuiltRuleEngine, @@ -23,6 +23,7 @@ export type UseTrackerFormOptions = { effectHandlers?: EffectHandlersMap; events?: RuleEventInput[]; supplementaryData?: RuleSupplementaryDataInput; + optionGroups?: OptionGroupCodeMap; }; export type UseTrackerFormReturn = { @@ -62,19 +63,23 @@ export function useTrackerForm new FormStore(), []); const effectHandlersRef = useRef(options.effectHandlers); + const optionGroupsRef = useRef(options.optionGroups); + optionGroupsRef.current = options.optionGroups; const prevEngineRef = useRef(null); if (prevEngineRef.current !== ruleEngine) { if (prevEngineRef.current !== null) { formStore.reinit( form as UseFormReturn>, ruleEngine, - effectHandlersRef + effectHandlersRef, + optionGroupsRef ); } else { formStore.init( form as UseFormReturn>, ruleEngine, - effectHandlersRef + effectHandlersRef, + optionGroupsRef ); } prevEngineRef.current = ruleEngine; diff --git a/utils/rules/src/filterPayload.test.ts b/utils/rules/src/filterPayload.test.ts new file mode 100644 index 0000000..b8e6bc0 --- /dev/null +++ b/utils/rules/src/filterPayload.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import { filterPayload } from './filterPayload'; +import { createEmptyFieldState, type FieldStateMap } from './types'; + +describe('filterPayload', () => { + it('passes through values for keys with no field state', () => { + expect(filterPayload({ de1: 'value' }, {})).toEqual({ de1: 'value' }); + }); + + it('drops hidden fields', () => { + const fieldState: FieldStateMap = { de1: { ...createEmptyFieldState(), hidden: true } }; + + expect(filterPayload({ de1: 'value' }, fieldState)).toEqual({}); + }); + + it('substitutes an assigned value over the raw value', () => { + const fieldState: FieldStateMap = { + de1: { ...createEmptyFieldState(), assignedValue: 'assigned' }, + }; + + expect(filterPayload({ de1: 'raw' }, fieldState)).toEqual({ de1: 'assigned' }); + }); + + it('nulls out a value matching a directly hidden option code', () => { + const fieldState: FieldStateMap = { + de1: { ...createEmptyFieldState(), hiddenOptions: new Set(['opt1']) }, + }; + + expect(filterPayload({ de1: 'opt1' }, fieldState)).toEqual({ de1: null }); + }); + + it('nulls out a value matching a hidden option-group member, given optionGroups', () => { + const fieldState: FieldStateMap = { + de1: { ...createEmptyFieldState(), hiddenOptionGroups: new Set(['og1']) }, + }; + const optionGroups = { og1: ['opt1', 'opt2'] }; + + expect(filterPayload({ de1: 'opt2' }, fieldState, optionGroups)).toEqual({ de1: null }); + }); + + it('passes through a visible option value unchanged', () => { + const fieldState: FieldStateMap = { + de1: { ...createEmptyFieldState(), hiddenOptions: new Set(['opt1']) }, + }; + + expect(filterPayload({ de1: 'opt2' }, fieldState)).toEqual({ de1: 'opt2' }); + }); + + it('only guards against directly hidden options when optionGroups is omitted', () => { + const fieldState: FieldStateMap = { + de1: { ...createEmptyFieldState(), hiddenOptionGroups: new Set(['og1']) }, + }; + + expect(filterPayload({ de1: 'opt2' }, fieldState)).toEqual({ de1: 'opt2' }); + }); +}); diff --git a/utils/rules/src/filterPayload.ts b/utils/rules/src/filterPayload.ts index bb81113..2fcffc1 100644 --- a/utils/rules/src/filterPayload.ts +++ b/utils/rules/src/filterPayload.ts @@ -1,8 +1,11 @@ +import type { OptionGroupCodeMap } from '@dhis2-form-utils/metadata'; +import { resolveHiddenOptionCodes } from './resolveHiddenOptionCodes'; import type { FieldStateMap } from './types'; export function filterPayload( values: Record, - fieldState: FieldStateMap + fieldState: FieldStateMap, + optionGroups?: OptionGroupCodeMap ): Record { const result: Record = {}; @@ -17,6 +20,11 @@ export function filterPayload( if (state.assignedValue !== null && state.assignedValue !== undefined) { result[key] = state.assignedValue; + } else if ( + typeof value === 'string' && + resolveHiddenOptionCodes(state, optionGroups).has(value) + ) { + result[key] = null; } else { result[key] = value; } diff --git a/utils/rules/src/index.ts b/utils/rules/src/index.ts index bfc83a0..cd4bb38 100644 --- a/utils/rules/src/index.ts +++ b/utils/rules/src/index.ts @@ -1,4 +1,5 @@ export { ProgramRuleActionType, ProgramRuleVariableSourceType } from '@dhis2-form-utils/metadata'; +export type { OptionGroupCodeMap } from '@dhis2-form-utils/metadata'; export type { ValueType } from '@dhis2/api-types/v43'; export type { FieldState, @@ -43,3 +44,4 @@ export { toRuleEnrollment, } from './enrollmentContext'; export { filterPayload } from './filterPayload'; +export { resolveHiddenOptionCodes } from './resolveHiddenOptionCodes'; diff --git a/utils/rules/src/resolveHiddenOptionCodes.test.ts b/utils/rules/src/resolveHiddenOptionCodes.test.ts new file mode 100644 index 0000000..132f926 --- /dev/null +++ b/utils/rules/src/resolveHiddenOptionCodes.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { resolveHiddenOptionCodes } from './resolveHiddenOptionCodes'; +import { createEmptyFieldState } from './types'; + +describe('resolveHiddenOptionCodes', () => { + it('returns directly hidden option codes', () => { + const state = { ...createEmptyFieldState(), hiddenOptions: new Set(['opt1']) }; + + expect(resolveHiddenOptionCodes(state)).toEqual(new Set(['opt1'])); + }); + + it('expands hidden option groups into their member codes', () => { + const state = { + ...createEmptyFieldState(), + hiddenOptionGroups: new Set(['og1']), + }; + const optionGroups = { og1: ['A', 'B'] }; + + expect(resolveHiddenOptionCodes(state, optionGroups)).toEqual(new Set(['A', 'B'])); + }); + + it('unions directly hidden options with hidden group members', () => { + const state = { + ...createEmptyFieldState(), + hiddenOptions: new Set(['opt1']), + hiddenOptionGroups: new Set(['og1']), + }; + const optionGroups = { og1: ['A', 'B'] }; + + expect(resolveHiddenOptionCodes(state, optionGroups)).toEqual(new Set(['opt1', 'A', 'B'])); + }); + + it('degrades gracefully when optionGroups is omitted or missing the referenced id', () => { + const state = { + ...createEmptyFieldState(), + hiddenOptionGroups: new Set(['og1']), + }; + + expect(resolveHiddenOptionCodes(state)).toEqual(new Set()); + expect(resolveHiddenOptionCodes(state, { otherGroup: ['A'] })).toEqual(new Set()); + }); + + it('returns an empty set when nothing is hidden', () => { + expect(resolveHiddenOptionCodes(createEmptyFieldState())).toEqual(new Set()); + }); +}); diff --git a/utils/rules/src/resolveHiddenOptionCodes.ts b/utils/rules/src/resolveHiddenOptionCodes.ts new file mode 100644 index 0000000..23cf70f --- /dev/null +++ b/utils/rules/src/resolveHiddenOptionCodes.ts @@ -0,0 +1,23 @@ +import type { OptionGroupCodeMap } from '@dhis2-form-utils/metadata'; +import type { FieldState } from './types'; + +/** + * Resolves a field's rule-driven option visibility state into a single set of hidden option + * codes — the union of directly hidden options and the members of any hidden option groups. + * A `hiddenOptionGroups` id with no matching entry in `optionGroups` contributes nothing, so + * this degrades gracefully when group membership hasn't been fetched. + */ +export function resolveHiddenOptionCodes( + state: Pick, + optionGroups?: OptionGroupCodeMap +): Set { + const hidden = new Set(state.hiddenOptions); + + for (const groupId of state.hiddenOptionGroups) { + for (const code of optionGroups?.[groupId] ?? []) { + hidden.add(code); + } + } + + return hidden; +}