Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
import { FormStateProvider, useEventForm } from '@dhis2-form-utils/hooks'
import type {
EventProgramMetadata,
OptionGroupCodeMap,
ProgramStageMetadata,
TrackerProgramMetadata,
} from '@dhis2-form-utils/metadata'
Expand Down Expand Up @@ -41,6 +42,7 @@ type ProgramEventFormProps = {
}
events?: RuleEventInput[]
supplementaryData?: RuleSupplementaryDataInput
optionGroups?: OptionGroupCodeMap
onValuesChange?: (values: Record<string, unknown>) => void
}

Expand All @@ -53,6 +55,7 @@ export function ProgramEventForm({
enrollment,
events,
supplementaryData,
optionGroups,
onValuesChange,
}: ProgramEventFormProps) {
const dataEngine = useDataEngine()
Expand All @@ -64,6 +67,7 @@ export function ProgramEventForm({
enrollment,
events,
supplementaryData,
optionGroups,
},
formOptions: {
mode: 'onBlur',
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -39,6 +40,7 @@ type ProgramRegistrationFormProps = {
enrolledAt: string
events?: RuleEventInput[]
supplementaryData?: RuleSupplementaryDataInput
optionGroups?: OptionGroupCodeMap
onValuesChange?: (values: Record<string, unknown>) => void
}

Expand All @@ -54,6 +56,7 @@ export function ProgramRegistrationForm({
enrolledAt,
events,
supplementaryData,
optionGroups,
onValuesChange,
}: ProgramRegistrationFormProps) {
const dataEngine = useDataEngine()
Expand All @@ -75,6 +78,7 @@ export function ProgramRegistrationForm({
metadata,
events,
supplementaryData,
optionGroups,
},
formOptions: {
mode: 'onBlur',
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -18,6 +19,7 @@ type ProgramRegistrationFormScreenProps = {
error: Error | undefined
events?: RuleEventInput[]
supplementaryData?: RuleSupplementaryDataInput
optionGroups?: OptionGroupCodeMap
onValuesChange?: (values: Record<string, unknown>) => void
}

Expand All @@ -31,6 +33,7 @@ export function ProgramRegistrationFormScreen({
error,
events,
supplementaryData,
optionGroups,
onValuesChange,
}: ProgramRegistrationFormScreenProps) {
if (loading) {
Expand Down Expand Up @@ -69,6 +72,7 @@ export function ProgramRegistrationFormScreen({
enrolledAt={enrolledAt}
events={events}
supplementaryData={supplementaryData}
optionGroups={optionGroups}
onValuesChange={onValuesChange}
/>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -21,6 +22,7 @@ type ProgramStageFormScreenProps = {
}
events?: RuleEventInput[]
supplementaryData?: RuleSupplementaryDataInput
optionGroups?: OptionGroupCodeMap
onValuesChange?: (values: Record<string, unknown>) => void
}

Expand All @@ -32,6 +34,7 @@ export function ProgramStageFormScreen({
enrollment,
events,
supplementaryData,
optionGroups,
onValuesChange,
}: ProgramStageFormScreenProps) {
const stageMetadata = programStageId
Expand All @@ -57,6 +60,7 @@ export function ProgramStageFormScreen({
enrollment={enrollment}
events={events}
supplementaryData={supplementaryData}
optionGroups={optionGroups}
onValuesChange={onValuesChange}
/>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -21,6 +24,7 @@ type TrackerProgramShellProps = {
orgUnitId: string
enrolledAt: string
supplementaryData?: RuleSupplementaryDataInput
optionGroups?: OptionGroupCodeMap
}

type RenderableStageSlot = { stageId: string; eventLocalId: string }
Expand Down Expand Up @@ -55,6 +59,7 @@ export function TrackerProgramShell({
orgUnitId,
enrolledAt,
supplementaryData,
optionGroups,
}: TrackerProgramShellProps) {
const {
metadata: trackerMetadata,
Expand Down Expand Up @@ -200,6 +205,7 @@ export function TrackerProgramShell({
error={trackerError}
events={allEvents}
supplementaryData={supplementaryData}
optionGroups={optionGroups}
onValuesChange={handleRegistrationValuesChange}
/>
</div>
Expand Down Expand Up @@ -230,6 +236,7 @@ export function TrackerProgramShell({
eventsExcludingSlot.get(key) ?? EMPTY_EVENTS
}
supplementaryData={supplementaryData}
optionGroups={optionGroups}
onValuesChange={getEventValuesChangeHandler(key)}
/>
</div>
Expand Down
43 changes: 43 additions & 0 deletions apps/playground/src/hooks/useOptionGroupsSupplementaryData.ts
Original file line number Diff line number Diff line change
@@ -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<RawOptionGroupsResult>(
optionGroupsQuery,
{
lazy: true,
}
)

useEffect(() => {
if (optionGroupIds.length > 0) {
refetch({ optionGroupIds })
}
}, [optionGroupIds, refetch])

return useMemo(() => (data ? resolveOptionGroups(data) : undefined), [data])
Comment on lines +36 to +42

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect option-group readiness and propagation without executing repository code.
ast-grep outline apps/playground/src/hooks/useOptionGroupsSupplementaryData.ts --items all
ast-grep outline utils/hooks/src --items all --type function --match 'useEventForm|useTrackerForm'

# Verify how undefined optionGroups reaches form initialization and payload submission.
rg -n -C 4 '\boptionGroups\b|filterPayload|handleSubmit|onSubmit' \
  apps/playground/src utils/hooks/src utils/rules/src

Repository: nnkogift/dhis2-form-utils

Length of output: 50381


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== hook implementation =="
cat -n apps/playground/src/hooks/useOptionGroupsSupplementaryData.ts

echo "== ProgramPage relevant sections =="
sed -n '1,190p' apps/playground/src/pages/ProgramPage.tsx | cat -n

echo "== ProgramEventForm submission relevant sections =="
sed -n '48,160p' apps/playground/src/components/programs/forms/ProgramEventForm.tsx | cat -n

echo "== ProgramRegistrationForm submission relevant sections =="
sed -n '60,155p' apps/playground/src/components/programs/forms/ProgramRegistrationForm.tsx | cat -n

echo "== resolveHiddenOptionCodes and filterPayload implementations =="
cat -n utils/rules/src/resolveHiddenOptionCodes.ts
cat -n utils/rules/src/filterPayload.ts

echo "== tracker and event form optionGroups assignment =="
sed -n '55,85p' utils/hooks/src/useTrackerForm.ts | cat -n
sed -n '95,112p' utils/hooks/src/useEventForm.ts | cat -n

echo "== formStore optionGroups getters/setters =="
sed -n '1,50p' utils/hooks/src/formStore.ts | cat -n

Repository: nnkogift/dhis2-form-utils

Length of output: 25895


Block form rendering until option-group metadata resolves.

OptionGroupCodeMap | undefined is passed into useEventForm/useTrackerForm, which installs it on the form store. resolveHiddenOptionCodes and filterPayload skip hidden option-group members when optionGroups is absent, so HIDEOPTIONGROUP rules do not hide option values or strip them from the payload. Delay the tracker/event form until the query returns data, and report explicit query failures.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/playground/src/hooks/useOptionGroupsSupplementaryData.ts` around lines
36 - 42, Update the form-rendering flow around the supplementary option-group
hook so tracker/event forms are not initialized or rendered until the
option-group query has returned data. Propagate and display an explicit query
failure from the hook instead of treating missing metadata as an empty map,
while preserving the existing resolveOptionGroups behavior after successful
loading.

}
11 changes: 11 additions & 0 deletions apps/playground/src/pages/ProgramPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ 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', () => ({
useEventProgramMetadataQuery: jest.fn(),
}))
jest.mock('@/hooks/useAccessibleOrgUnits')
jest.mock('@/hooks/useCurrentUserSupplementaryData')
jest.mock('@/hooks/useOptionGroupsSupplementaryData')
jest.mock('@/components/programs/forms/ProgramStageFormScreen', () => ({
ProgramStageFormScreen: jest.fn(() => <div>Event form screen</div>),
}))
Expand All @@ -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(
Expand All @@ -57,6 +63,9 @@ describe('ProgramPage', () => {
mockedUseCurrentUserSupplementaryData.mockReturnValue(
SENTINEL_SUPPLEMENTARY_DATA
)
mockedUseOptionGroupsSupplementaryData.mockReturnValue(
SENTINEL_OPTION_GROUPS
)
})

afterEach(() => {
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
6 changes: 6 additions & 0 deletions apps/playground/src/pages/ProgramPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -148,6 +152,7 @@ export function ProgramPage() {
orgUnitId={orgUnitId}
enrolledAt={primaryDate}
supplementaryData={supplementaryData}
optionGroups={optionGroups}
/>
) : (
<ProgramStageFormScreen
Expand All @@ -157,6 +162,7 @@ export function ProgramPage() {
orgUnitId={orgUnitId}
occurredAt={primaryDate}
supplementaryData={supplementaryData}
optionGroups={optionGroups}
/>
)}
</div>
Expand Down
7 changes: 4 additions & 3 deletions components/dhis2-ui/src/fields/widgets/ChoiceFields.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
7 changes: 4 additions & 3 deletions components/mantine/src/fields/widgets/ChoiceFields.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
3 changes: 2 additions & 1 deletion components/mui/src/fields/widgets/ChoiceFields.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<TextField
Expand Down
11 changes: 11 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,17 @@ export type FieldState = {
export type FieldStateMap = Record<string, FieldState>;
```

`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<string>` 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.

Comment on lines +143 to +153

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 6 \
  'UseTrackerFormOptions|optionGroups|filterPayload\s*\(' \
  . --glob '*.ts' --glob '*.tsx' --glob '*.md'

Repository: nnkogift/dhis2-form-utils

Length of output: 50383


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== utils/rules/src/filterPayload.ts =="
fd -a 'filterPayload\.ts$' . | while read -r f; do
  rel="${f#${PWD}/}"
  echo "--- $rel"
  cat -n "$rel"
done

echo "== docs/ARCHITECTURE.md relevant sections =="
sed -n '135,160p;222,233p;564,576p' docs/ARCHITECTURE.md | cat -n

echo "== docs/use-tracker-form.md relevant sections =="
sed -n '358,460p;484,496p' docs/use-tracker-form.md | cat -n

echo "== export tests/data for filterPayload optionGroups =="
rg -n -C 5 'filterPayload\(|optionGroups' utils/rules src packages --glob '*.ts' --glob '*.tsx' --glob '*.md' \
  | head -220

Repository: nnkogift/dhis2-form-utils

Length of output: 27124


Align the option-group contract across the documentation.

The implementation supports filterPayload(values, fieldState, optionGroups?) and useTrackerForm(options.optionGroups), but the docs split the contract: use-tracker-form.md passes formStore.optionGroups, while docs/ARCHITECTURE.md documents filterPayload as two args and the submission example omits option groups. Update both doc sites so callers declare optional option-group metadata and pass it to filterPayload consistently.

📍 Affects 2 files
  • docs/ARCHITECTURE.md#L143-L153 (this comment)
  • docs/use-tracker-form.md#L450-L456
  • docs/use-tracker-form.md#L489-L492
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/ARCHITECTURE.md` around lines 143 - 153, Update docs/ARCHITECTURE.md
lines 143-153 and docs/use-tracker-form.md lines 450-456 and 489-492 to
consistently document optional option-group metadata: declare or obtain
optionGroups where needed and pass it as the third argument to
filterPayload(values, fieldState, optionGroups). Align the examples with
useTrackerForm(options.optionGroups) and preserve the existing two-argument
behavior when no metadata is available.

**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
Expand Down
4 changes: 4 additions & 0 deletions docs/form-state-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,10 @@ type FieldState = {
type FieldStateMap = Record<string, FieldState>;
```

`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
Expand Down
Loading
Loading