From 543069fbd106ebddffc123f56be4355fb033c780 Mon Sep 17 00:00:00 2001 From: Emil Rossing Date: Sun, 2 Aug 2026 12:22:59 +0200 Subject: [PATCH 1/2] fix(formplayer): apply clear-on-hide to all renderers per default, no opt-out --- formulus-formplayer/src/App.tsx | 23 ++- .../src/DynamicEnumControl.tsx | 3 + .../src/jsonforms/ShellInputControl.tsx | 13 +- .../applyClearOnHideToRenderers.test.tsx | 94 ++++++++++++ .../jsonforms/applyClearOnHideToRenderers.tsx | 106 +++++++++++++ .../jsonforms/clearOnHide.cascade.test.tsx | 142 ++++++++++++++++++ .../src/jsonforms/useClearOnHide.test.tsx | 109 ++++++++++++++ .../src/jsonforms/useClearOnHide.ts | 42 ++++++ .../src/jsonforms/visibleGuard.test.tsx | 34 ++++- .../src/jsonforms/visibleGuard.tsx | 30 +++- .../src/renderers/AdateQuestionRenderer.tsx | 2 + .../src/renderers/AudioQuestionRenderer.tsx | 2 + .../renderers/CustomQuestionTypeAdapter.tsx | 2 + .../renderers/DurationQuestionRenderer.tsx | 2 + .../src/renderers/FileQuestionRenderer.tsx | 2 + .../src/renderers/GPSQuestionRenderer.tsx | 2 + .../renderers/LikertScaleQuestionRenderer.tsx | 2 + .../src/renderers/NumberStepperRenderer.tsx | 2 + .../src/renderers/PhotoQuestionRenderer.tsx | 2 + .../src/renderers/QrcodeQuestionRenderer.tsx | 2 + .../renderers/SignatureQuestionRenderer.tsx | 2 + .../SubObservationQuestionRenderer.tsx | 2 + .../src/renderers/VideoQuestionRenderer.tsx | 2 + .../src/theme/material-wrappers.tsx | 7 + 24 files changed, 617 insertions(+), 12 deletions(-) create mode 100644 formulus-formplayer/src/jsonforms/applyClearOnHideToRenderers.test.tsx create mode 100644 formulus-formplayer/src/jsonforms/applyClearOnHideToRenderers.tsx create mode 100644 formulus-formplayer/src/jsonforms/clearOnHide.cascade.test.tsx create mode 100644 formulus-formplayer/src/jsonforms/useClearOnHide.test.tsx create mode 100644 formulus-formplayer/src/jsonforms/useClearOnHide.ts diff --git a/formulus-formplayer/src/App.tsx b/formulus-formplayer/src/App.tsx index 140894fd1..1df912a69 100644 --- a/formulus-formplayer/src/App.tsx +++ b/formulus-formplayer/src/App.tsx @@ -113,6 +113,7 @@ import DynamicEnumControl, { dynamicEnumTester } from './DynamicEnumControl'; import ShellInputControl, { shellInputControlTester, } from './jsonforms/ShellInputControl'; +import { applyClearOnHideToRenderers } from './jsonforms/applyClearOnHideToRenderers'; import type { KeyboardPrimaryEnterKeyHint } from './utils/keyboardEnterKeyHint'; import ErrorBoundary from './components/ErrorBoundary'; @@ -473,6 +474,20 @@ function App() { const odeI18n = useMemo(() => createOdeI18n(uiLocale), [uiLocale]); + // Clear-on-hide is Formplayer's sole SHOW/HIDE data policy for Controls. + // Memoize so registry HOCs are stable across App re-renders. + const formRenderers = useMemo( + () => + applyClearOnHideToRenderers([ + ...shellMaterialRenderers, + ...materialRenderers, + ...customRenderers, + ...customTypeRenderers, + ...extensionRenderers, + ]), + [customTypeRenderers, extensionRenderers], + ); + // Reference to the FormulusClient instance and loading state const formulusClient = useRef(FormulusClient.getInstance()); const isLoadingRef = useRef( @@ -1549,13 +1564,7 @@ function App() { uischema={uischema} data={data} i18n={odeI18n} - renderers={[ - ...shellMaterialRenderers, - ...materialRenderers, - ...customRenderers, - ...customTypeRenderers, // Custom question types from custom_app - ...extensionRenderers, // Extension renderers (highest priority) - ]} + renderers={formRenderers} cells={materialCells} onChange={handleDataChange} validationMode={validationMode} diff --git a/formulus-formplayer/src/DynamicEnumControl.tsx b/formulus-formplayer/src/DynamicEnumControl.tsx index 4c0ca6358..29e6ae91d 100644 --- a/formulus-formplayer/src/DynamicEnumControl.tsx +++ b/formulus-formplayer/src/DynamicEnumControl.tsx @@ -20,6 +20,7 @@ import { CircularProgress, } from '@mui/material'; import QuestionShell from './components/QuestionShell'; +import { useClearOnHide } from './jsonforms/useClearOnHide'; /** * Interface for x-dynamicEnum configuration @@ -129,6 +130,8 @@ const DynamicEnumControl: React.FC = ({ const { functions } = useFormEvaluation(); const ctx = useJsonForms(); + useClearOnHide({ visible, path, data, handleChange }); + const [choices, setChoices] = useState>( [], ); diff --git a/formulus-formplayer/src/jsonforms/ShellInputControl.tsx b/formulus-formplayer/src/jsonforms/ShellInputControl.tsx index 7fde889d1..98f4a201d 100644 --- a/formulus-formplayer/src/jsonforms/ShellInputControl.tsx +++ b/formulus-formplayer/src/jsonforms/ShellInputControl.tsx @@ -13,6 +13,7 @@ import { resolveControlDescription, resolveControlLabel, } from '../utils/controlDisplayText'; +import { useClearOnHide } from './useClearOnHide'; /** * String control that renders the stock MuiInputText cell inside QuestionShell @@ -21,8 +22,18 @@ import { */ const ShellInputControl = (props: ControlProps) => { const { keyboardEnterKeyHint } = useFormContext(); - const { uischema, schema, errors, visible, required } = props; + const { + uischema, + schema, + errors, + visible, + required, + path, + data, + handleChange, + } = props; + useClearOnHide({ visible, path, data, handleChange }); if (visible === false) return null; const title = resolveControlLabel(props); diff --git a/formulus-formplayer/src/jsonforms/applyClearOnHideToRenderers.test.tsx b/formulus-formplayer/src/jsonforms/applyClearOnHideToRenderers.test.tsx new file mode 100644 index 000000000..cadf23728 --- /dev/null +++ b/formulus-formplayer/src/jsonforms/applyClearOnHideToRenderers.test.tsx @@ -0,0 +1,94 @@ +// @vitest-environment jsdom +import React, { useState } from 'react'; +import { describe, it, expect, afterEach } from 'vitest'; +import { + render, + screen, + cleanup, + fireEvent, + waitFor, +} from '@testing-library/react'; +import { ThemeProvider } from '@mui/material/styles'; +import { JsonForms } from '@jsonforms/react'; +import type { JsonSchema7, UISchemaElement } from '@jsonforms/core'; +import { materialRenderers } from '@jsonforms/material-renderers'; +import Ajv from 'ajv'; +import { theme } from '../theme/theme'; +import { applyClearOnHideToRenderers } from './applyClearOnHideToRenderers'; +import { shellMaterialRenderers } from '../theme/material-wrappers'; + +const ajv = new Ajv({ allErrors: true, strict: false }); + +afterEach(() => cleanup()); + +describe('applyClearOnHideToRenderers', () => { + it('clears a Control value via the registry wrapper when hidden', async () => { + const schema: JsonSchema7 = { + type: 'object', + properties: { + gate: { type: 'string', title: 'Gate', enum: ['yes', 'no'] }, + detail: { type: 'string', title: 'Detail' }, + }, + }; + const uischema: UISchemaElement = { + type: 'VerticalLayout', + elements: [ + { + type: 'Control', + scope: '#/properties/gate', + options: { display: 'buttons' }, + }, + { + type: 'Control', + scope: '#/properties/detail', + rule: { + effect: 'SHOW', + condition: { + scope: '#/properties/gate', + schema: { const: 'yes' }, + }, + }, + }, + ], + } as UISchemaElement; + + function Harness() { + const [data, setData] = useState>({ + gate: 'yes', + detail: 'keep-me', + }); + return ( + +
{JSON.stringify(data)}
+ + setData(next as Record) + } + /> +
+ ); + } + + render(); + expect(screen.getByDisplayValue('keep-me')).toBeTruthy(); + + fireEvent.click(screen.getAllByRole('button', { name: 'no' })[0]); + + await waitFor(() => { + const data = JSON.parse( + screen.getByTestId('data-json').textContent || '{}', + ) as Record; + expect(data.gate).toBe('no'); + expect(data.detail).toBeUndefined(); + }); + expect(screen.queryByDisplayValue('keep-me')).toBeNull(); + }); +}); diff --git a/formulus-formplayer/src/jsonforms/applyClearOnHideToRenderers.tsx b/formulus-formplayer/src/jsonforms/applyClearOnHideToRenderers.tsx new file mode 100644 index 000000000..89b218e9e --- /dev/null +++ b/formulus-formplayer/src/jsonforms/applyClearOnHideToRenderers.tsx @@ -0,0 +1,106 @@ +import React, { useCallback } from 'react'; +import { + Actions, + Resolve, + Runtime, + composeWithUi, + getAjv, + type JsonFormsRendererRegistryEntry, + type UISchemaElement, +} from '@jsonforms/core'; +import { useJsonForms } from '@jsonforms/react'; +import { useClearOnHide, type ClearOnHideHandleChange } from './useClearOnHide'; + +type RegistryRendererProps = { + visible?: boolean; + path?: string; + data?: unknown; + handleChange?: ClearOnHideHandleChange; + uischema?: UISchemaElement & { type?: string; scope?: string }; +}; + +/** + * Registry HOC: clear Control values on SHOW/HIDE hide. + * + * JsonForms does **not** pass `visible` or the composed control `path` into + * registry renderers — those are injected later by `withJsonFormsControlProps`. + * We therefore: + * - compose the data path via {@link composeWithUi} + * - evaluate visibility via {@link Runtime.isVisible} + * + * Layouts and Labels are skipped (`uischema.type !== 'Control'`). + */ +export function withRegistryClearOnHide

( + Component: React.ComponentType

, +): React.FC

{ + const Wrapped: React.FC

= props => { + const isControl = props.uischema?.type === 'Control'; + const ctx = useJsonForms(); + const rootData = ctx.core?.data; + const dispatch = ctx.dispatch; + const ajv = getAjv({ jsonforms: { ...ctx } } as never); + + const parentPath = typeof props.path === 'string' ? props.path : ''; + const path = + isControl && props.uischema + ? composeWithUi(props.uischema, parentPath) + : undefined; + + // Prefer an explicit `visible` when present (inner ControlProps / tests); + // otherwise mirror JsonForms rule evaluation for registry-level wraps. + const visible = + props.visible !== undefined + ? props.visible + : isControl && props.uischema && ajv + ? Runtime.isVisible(props.uischema, rootData, ajv, ctx.config) + : true; + + const dataFromStore = + isControl && path && rootData != null + ? Resolve.data(rootData, path) + : undefined; + // Only trust props.data when ControlProps were injected (has handleChange). + // Registry dispatch never passes data — undefined means "use store". + const data = + props.handleChange !== undefined && props.data !== undefined + ? props.data + : dataFromStore; + + const injectedHandleChange = props.handleChange; + const handleChange = useCallback( + (p, value) => { + if (typeof injectedHandleChange === 'function') { + injectedHandleChange(p, value); + return; + } + dispatch?.(Actions.update(p, () => value)); + }, + [injectedHandleChange, dispatch], + ); + + useClearOnHide({ + visible: isControl ? visible : true, + path: isControl ? path : undefined, + data: isControl ? data : undefined, + handleChange: isControl ? handleChange : undefined, + }); + + return ; + }; + Wrapped.displayName = `WithRegistryClearOnHide(${ + Component.displayName || Component.name || 'Component' + })`; + return Wrapped; +} + +/** Apply clear-on-hide to every renderer registry entry (Formplayer default). */ +export function applyClearOnHideToRenderers( + renderers: JsonFormsRendererRegistryEntry[], +): JsonFormsRendererRegistryEntry[] { + return renderers.map(entry => ({ + ...entry, + renderer: withRegistryClearOnHide( + entry.renderer as React.ComponentType, + ), + })); +} diff --git a/formulus-formplayer/src/jsonforms/clearOnHide.cascade.test.tsx b/formulus-formplayer/src/jsonforms/clearOnHide.cascade.test.tsx new file mode 100644 index 000000000..e7322e530 --- /dev/null +++ b/formulus-formplayer/src/jsonforms/clearOnHide.cascade.test.tsx @@ -0,0 +1,142 @@ +// @vitest-environment jsdom +/** + * Parent SHOW → child SHOW → grandchild: hiding the parent must clear the + * child's value so the grandchild's SHOW rule fails (ODK-style relevant). + */ +import React, { useState } from 'react'; +import { describe, it, expect, afterEach } from 'vitest'; +import { + render, + screen, + cleanup, + fireEvent, + waitFor, +} from '@testing-library/react'; +import { ThemeProvider } from '@mui/material/styles'; +import { JsonForms } from '@jsonforms/react'; +import type { JsonSchema7, UISchemaElement } from '@jsonforms/core'; +import { materialRenderers } from '@jsonforms/material-renderers'; +import Ajv from 'ajv'; +import { theme } from '../theme/theme'; +import { FormContext } from '../App'; +import { shellMaterialRenderers } from '../theme/material-wrappers'; + +const ajv = new Ajv({ allErrors: true, strict: false }); + +const schema: JsonSchema7 = { + type: 'object', + properties: { + parent: { + type: 'string', + title: 'Parent', + enum: ['1', '2'], + }, + child: { + type: 'string', + title: 'Child', + enum: ['1', '2'], + }, + grandchild: { + type: 'string', + title: 'Grandchild', + enum: ['1', '2'], + }, + }, +}; + +const uischema: UISchemaElement = { + type: 'VerticalLayout', + elements: [ + { + type: 'Control', + scope: '#/properties/parent', + options: { display: 'buttons' }, + }, + { + type: 'Control', + scope: '#/properties/child', + options: { display: 'buttons' }, + rule: { + effect: 'SHOW', + condition: { + scope: '#/properties/parent', + schema: { const: '1' }, + }, + }, + }, + { + type: 'Control', + scope: '#/properties/grandchild', + options: { display: 'buttons' }, + rule: { + effect: 'SHOW', + condition: { + scope: '#/properties/child', + schema: { const: '1' }, + }, + }, + }, + ], +} as UISchemaElement; + +const renderers = [...shellMaterialRenderers, ...materialRenderers]; + +function CascadeHarness({ + initialData, +}: { + initialData: Record; +}) { + const [data, setData] = useState(initialData); + return ( + + +

{JSON.stringify(data)}
+ + setData(next as Record) + } + /> + + + ); +} + +afterEach(() => cleanup()); + +describe('clear-on-hide cascade (SHOW/HIDE)', () => { + it('clears child when parent hides it, which hides grandchild', async () => { + render( + , + ); + + expect(screen.getByText('Child')).toBeTruthy(); + expect(screen.getByText('Grandchild')).toBeTruthy(); + + // Three fields each expose a "2" toggle; first is Parent. + const parentTwo = screen.getAllByRole('button', { name: '2' })[0]; + fireEvent.click(parentTwo); + + await waitFor(() => { + const raw = screen.getByTestId('data-json').textContent || '{}'; + const data = JSON.parse(raw) as Record; + expect(data.parent).toBe('2'); + expect(data.child).toBeUndefined(); + expect(data.grandchild).toBeUndefined(); + }); + + expect(screen.queryByText('Child')).toBeNull(); + expect(screen.queryByText('Grandchild')).toBeNull(); + }); +}); diff --git a/formulus-formplayer/src/jsonforms/useClearOnHide.test.tsx b/formulus-formplayer/src/jsonforms/useClearOnHide.test.tsx new file mode 100644 index 000000000..c6f96af23 --- /dev/null +++ b/formulus-formplayer/src/jsonforms/useClearOnHide.test.tsx @@ -0,0 +1,109 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, cleanup, waitFor } from '@testing-library/react'; +import { hasClearableValue, useClearOnHide } from './useClearOnHide'; + +afterEach(() => cleanup()); + +function Probe(props: { + visible?: boolean; + path?: string; + data?: unknown; + handleChange?: (path: string, value: unknown) => void; +}) { + useClearOnHide(props); + return null; +} + +describe('hasClearableValue', () => { + it('is false for empty sentinels', () => { + expect(hasClearableValue(undefined)).toBe(false); + expect(hasClearableValue(null)).toBe(false); + expect(hasClearableValue('')).toBe(false); + expect(hasClearableValue([])).toBe(false); + }); + + it('is true for scalars and non-empty arrays', () => { + expect(hasClearableValue('1')).toBe(true); + expect(hasClearableValue(0)).toBe(true); + expect(hasClearableValue(false)).toBe(true); + expect(hasClearableValue(['x'])).toBe(true); + }); +}); + +describe('useClearOnHide', () => { + it('clears once when visible flips to false with a value', async () => { + const handleChange = vi.fn(); + const { rerender } = render( + , + ); + expect(handleChange).not.toHaveBeenCalled(); + + rerender( + , + ); + await waitFor(() => { + expect(handleChange).toHaveBeenCalledWith('child', undefined); + }); + expect(handleChange).toHaveBeenCalledTimes(1); + + // Already empty → no further clears + rerender( + , + ); + await waitFor(() => { + expect(handleChange).toHaveBeenCalledTimes(1); + }); + }); + + it('does not clear when already empty or when visible stays true', async () => { + const handleChange = vi.fn(); + const { rerender } = render( + , + ); + await waitFor(() => { + expect(handleChange).not.toHaveBeenCalled(); + }); + + rerender( + , + ); + await waitFor(() => { + expect(handleChange).not.toHaveBeenCalled(); + }); + }); + + it('no-ops without path or handleChange', async () => { + const handleChange = vi.fn(); + render(); + render(); + await waitFor(() => { + expect(handleChange).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/formulus-formplayer/src/jsonforms/useClearOnHide.ts b/formulus-formplayer/src/jsonforms/useClearOnHide.ts new file mode 100644 index 000000000..464d16d8e --- /dev/null +++ b/formulus-formplayer/src/jsonforms/useClearOnHide.ts @@ -0,0 +1,42 @@ +import { useEffect } from 'react'; + +export type ClearOnHideHandleChange = (path: string, value: unknown) => void; + +export type UseClearOnHideArgs = { + visible?: boolean; + path?: string; + data?: unknown; + handleChange?: ClearOnHideHandleChange; +}; + +/** True when the control holds a value worth clearing on hide. */ +export function hasClearableValue(data: unknown): boolean { + if (data === undefined || data === null || data === '') return false; + if (Array.isArray(data) && data.length === 0) return false; + return true; +} + +/** + * When JsonForms marks a control `visible: false` (SHOW/HIDE), clear its value + * so dependent relevance rules re-evaluate (ODK-style `relevant` behaviour). + * + * Formplayer policy: this is the default (and only) behaviour for Controls — + * applied via {@link applyClearOnHideToRenderers} on the renderer registry and + * also called from individual control shells for defense in depth. + * + * Safe to call on every render: no-ops when already empty or when handleChange/path + * are missing (e.g. simple withVisibleGuard demos). + */ +export function useClearOnHide({ + visible, + path, + data, + handleChange, +}: UseClearOnHideArgs): void { + useEffect(() => { + if (visible !== false) return; + if (!path || typeof handleChange !== 'function') return; + if (!hasClearableValue(data)) return; + handleChange(path, undefined); + }, [visible, path, data, handleChange]); +} diff --git a/formulus-formplayer/src/jsonforms/visibleGuard.test.tsx b/formulus-formplayer/src/jsonforms/visibleGuard.test.tsx index 3c736b833..0c76c73f8 100644 --- a/formulus-formplayer/src/jsonforms/visibleGuard.test.tsx +++ b/formulus-formplayer/src/jsonforms/visibleGuard.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import { describe, it, expect } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; import { isControlHidden, withVisibleGuard } from './visibleGuard'; describe('visibleGuard', () => { @@ -20,4 +20,34 @@ describe('visibleGuard', () => { rerender(); expect(screen.queryByText('shown')).toBeNull(); }); + + it('withVisibleGuard clears control data when visible becomes false', async () => { + const handleChange = vi.fn(); + const Inner = ({ label }: { label: string; visible?: boolean }) => ( + {label} + ); + const Guarded = withVisibleGuard(Inner); + const { rerender } = render( + , + ); + expect(handleChange).not.toHaveBeenCalled(); + rerender( + , + ); + await waitFor(() => { + expect(handleChange).toHaveBeenCalledWith('fez', undefined); + }); + }); }); diff --git a/formulus-formplayer/src/jsonforms/visibleGuard.tsx b/formulus-formplayer/src/jsonforms/visibleGuard.tsx index 143fb1fde..fd83e283f 100644 --- a/formulus-formplayer/src/jsonforms/visibleGuard.tsx +++ b/formulus-formplayer/src/jsonforms/visibleGuard.tsx @@ -1,17 +1,40 @@ import React from 'react'; +import { useClearOnHide, type ClearOnHideHandleChange } from './useClearOnHide'; +import { + applyClearOnHideToRenderers, + withRegistryClearOnHide, +} from './applyClearOnHideToRenderers'; /** JsonForms passes `visible: false` when SHOW/HIDE rules hide a control. */ export function isControlHidden(visible?: boolean): boolean { return visible === false; } +type VisibleGuardProps = { + visible?: boolean; + path?: string; + data?: unknown; + handleChange?: ClearOnHideHandleChange; +}; + /** - * HOC: return null when JsonForms marks the control hidden (SHOW/HIDE rules). + * HOC: clear the control value when hidden, then return null (SHOW/HIDE rules). + * Clearing requires JsonForms ControlProps (`path`, `data`, `handleChange`); + * without them it only hides (same as before). + * + * Prefer {@link applyClearOnHideToRenderers} for the Formplayer registry so + * stock Material controls are covered too; this HOC remains for local wrappers. */ -export function withVisibleGuard

( +export function withVisibleGuard

( Component: React.ComponentType

, ): React.FC

{ const Guarded = (props: P) => { + useClearOnHide({ + visible: props.visible, + path: props.path, + data: props.data, + handleChange: props.handleChange, + }); if (isControlHidden(props.visible)) return null; return ; }; @@ -20,3 +43,6 @@ export function withVisibleGuard

( })`; return Guarded; } + +export { useClearOnHide, hasClearableValue } from './useClearOnHide'; +export { applyClearOnHideToRenderers, withRegistryClearOnHide }; diff --git a/formulus-formplayer/src/renderers/AdateQuestionRenderer.tsx b/formulus-formplayer/src/renderers/AdateQuestionRenderer.tsx index d431ce46c..d2a6d503a 100644 --- a/formulus-formplayer/src/renderers/AdateQuestionRenderer.tsx +++ b/formulus-formplayer/src/renderers/AdateQuestionRenderer.tsx @@ -18,6 +18,7 @@ import { } from '@mui/material'; import { CalendarToday } from '@mui/icons-material'; import QuestionShell from '../components/QuestionShell'; +import { useClearOnHide } from '../jsonforms/useClearOnHide'; import { adateToStorageFormat, storageFormatToAdate, @@ -179,6 +180,7 @@ const AdateQuestionRenderer: React.FC = ({ setYearUnknown(false); }, []); + useClearOnHide({ visible, path, data, handleChange }); // Don't render if not visible if (!visible) { return null; diff --git a/formulus-formplayer/src/renderers/AudioQuestionRenderer.tsx b/formulus-formplayer/src/renderers/AudioQuestionRenderer.tsx index 50fbb4faf..251a9ff6a 100644 --- a/formulus-formplayer/src/renderers/AudioQuestionRenderer.tsx +++ b/formulus-formplayer/src/renderers/AudioQuestionRenderer.tsx @@ -36,6 +36,7 @@ import { } from '../types/FormulusInterfaceDefinition'; import QuestionShell from '../components/QuestionShell'; import { tokens } from '../theme/tokens-adapter'; +import { useClearOnHide } from '../jsonforms/useClearOnHide'; import { attachmentBasenameFromFilename, attachmentBasenameFromObservation, @@ -376,6 +377,7 @@ const AudioQuestionRenderer: React.FC = ({ !!currentAudioData && typeof meta?.duration === 'number'; + useClearOnHide({ visible, path, data, handleChange }); if (visible === false) return null; return ( diff --git a/formulus-formplayer/src/renderers/CustomQuestionTypeAdapter.tsx b/formulus-formplayer/src/renderers/CustomQuestionTypeAdapter.tsx index 51d641653..d3122c7ba 100644 --- a/formulus-formplayer/src/renderers/CustomQuestionTypeAdapter.tsx +++ b/formulus-formplayer/src/renderers/CustomQuestionTypeAdapter.tsx @@ -17,6 +17,7 @@ import { resolveControlLabel, } from '../utils/controlDisplayText'; import { useOdeT } from '../i18n/useOdeT'; +import { useClearOnHide } from '../jsonforms/useClearOnHide'; interface ErrorBoundaryLabels { title: string; @@ -139,6 +140,7 @@ export function createCustomQuestionTypeRenderer( visible, } = props; const t = useOdeT(); + useClearOnHide({ visible, path, data, handleChange }); const errorBoundaryLabels = { title: t('cqt.errorTitle', 'Custom Question Type Error'), body: t( diff --git a/formulus-formplayer/src/renderers/DurationQuestionRenderer.tsx b/formulus-formplayer/src/renderers/DurationQuestionRenderer.tsx index 21b5fdf9d..594799914 100644 --- a/formulus-formplayer/src/renderers/DurationQuestionRenderer.tsx +++ b/formulus-formplayer/src/renderers/DurationQuestionRenderer.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { withJsonFormsControlProps } from '@jsonforms/react'; import { ControlProps, rankWith, schemaMatches } from '@jsonforms/core'; import QuestionShell from '../components/QuestionShell'; +import { useClearOnHide } from '../jsonforms/useClearOnHide'; import DurationControl from '../components/duration/DurationControl'; import { formatControlErrors } from '../utils/formatControlErrors'; @@ -42,6 +43,7 @@ const DurationQuestionRenderer: React.FC = ({ : null, ); + useClearOnHide({ visible, path, data, handleChange }); if (visible === false) { return null; } diff --git a/formulus-formplayer/src/renderers/FileQuestionRenderer.tsx b/formulus-formplayer/src/renderers/FileQuestionRenderer.tsx index f8802ba10..8dce9f985 100644 --- a/formulus-formplayer/src/renderers/FileQuestionRenderer.tsx +++ b/formulus-formplayer/src/renderers/FileQuestionRenderer.tsx @@ -19,6 +19,7 @@ import { FileResultData, } from '../types/FormulusInterfaceDefinition'; import QuestionShell from '../components/QuestionShell'; +import { useClearOnHide } from '../jsonforms/useClearOnHide'; import { attachmentBasenameFromFilename, attachmentBasenameFromObservation, @@ -174,6 +175,7 @@ const FileQuestionRenderer: React.FC = ({ setSafeError(null); }, [handleChange, path, setSafeError]); + useClearOnHide({ visible, path, data, handleChange }); if (!visible) { return null; } diff --git a/formulus-formplayer/src/renderers/GPSQuestionRenderer.tsx b/formulus-formplayer/src/renderers/GPSQuestionRenderer.tsx index 572589bc9..79093350b 100644 --- a/formulus-formplayer/src/renderers/GPSQuestionRenderer.tsx +++ b/formulus-formplayer/src/renderers/GPSQuestionRenderer.tsx @@ -19,6 +19,7 @@ import { MyLocation as MyLocationIcon, } from '@mui/icons-material'; import QuestionShell from '../components/QuestionShell'; +import { useClearOnHide } from '../jsonforms/useClearOnHide'; import { tokens } from '../theme/tokens-adapter'; import FormulusClient from '../services/FormulusInterface'; import type { LocationResult } from '../types/FormulusInterfaceDefinition'; @@ -155,6 +156,7 @@ const GPSQuestionRenderer: React.FC = props => { const hasValidationErrors = errors && errors.length > 0; const isDisabled = !enabled || isCapturing; + useClearOnHide({ visible, path, data, handleChange }); if (visible === false) return null; return ( diff --git a/formulus-formplayer/src/renderers/LikertScaleQuestionRenderer.tsx b/formulus-formplayer/src/renderers/LikertScaleQuestionRenderer.tsx index ca5b7067e..0712e3d57 100644 --- a/formulus-formplayer/src/renderers/LikertScaleQuestionRenderer.tsx +++ b/formulus-formplayer/src/renderers/LikertScaleQuestionRenderer.tsx @@ -5,6 +5,7 @@ import QuestionShell from '../components/QuestionShell'; import LikertScaleControl from '../components/likert/LikertScaleControl'; import { resolveLikertOptions } from '../components/likert/likertConfig'; import { formatControlErrors } from '../utils/formatControlErrors'; +import { useClearOnHide } from '../jsonforms/useClearOnHide'; export const likertScaleQuestionTester = rankWith( 12, @@ -47,6 +48,7 @@ const LikertScaleQuestionRenderer: React.FC = ({ : null, ); + useClearOnHide({ visible, path, data, handleChange }); if (visible === false) { return null; } diff --git a/formulus-formplayer/src/renderers/NumberStepperRenderer.tsx b/formulus-formplayer/src/renderers/NumberStepperRenderer.tsx index a0ffab3d1..212724a53 100644 --- a/formulus-formplayer/src/renderers/NumberStepperRenderer.tsx +++ b/formulus-formplayer/src/renderers/NumberStepperRenderer.tsx @@ -25,6 +25,7 @@ import { useNumericDraftInput, type NumericSchemaKind, } from '../hooks/useNumericDraftInput'; +import { useClearOnHide } from '../jsonforms/useClearOnHide'; const isNumberControl: RankedTester = rankWith( 5, @@ -73,6 +74,7 @@ const NumberStepperRenderer = ({ enabled, }); + useClearOnHide({ visible, path, data, handleChange }); if (visible === false) return null; const min = schema.minimum ?? (schema as { minimum?: number }).minimum; diff --git a/formulus-formplayer/src/renderers/PhotoQuestionRenderer.tsx b/formulus-formplayer/src/renderers/PhotoQuestionRenderer.tsx index 9a1624460..c999c45f8 100644 --- a/formulus-formplayer/src/renderers/PhotoQuestionRenderer.tsx +++ b/formulus-formplayer/src/renderers/PhotoQuestionRenderer.tsx @@ -23,6 +23,7 @@ import { } from '../types/FormulusInterfaceDefinition'; import QuestionShell from '../components/QuestionShell'; import { tokens } from '../theme/tokens-adapter'; +import { useClearOnHide } from '../jsonforms/useClearOnHide'; import { attachmentBasenameFromFilename, attachmentBasenameFromObservation, @@ -240,6 +241,7 @@ const PhotoQuestionRenderer: React.FC = ({ currentPhotoData as Record | null, ); + useClearOnHide({ visible, path, data, handleChange }); if (visible === false) return null; return ( diff --git a/formulus-formplayer/src/renderers/QrcodeQuestionRenderer.tsx b/formulus-formplayer/src/renderers/QrcodeQuestionRenderer.tsx index 5c3bfcc3e..58bbf9544 100644 --- a/formulus-formplayer/src/renderers/QrcodeQuestionRenderer.tsx +++ b/formulus-formplayer/src/renderers/QrcodeQuestionRenderer.tsx @@ -22,6 +22,7 @@ import { ControlProps, rankWith, formatIs } from '@jsonforms/core'; import FormulusClient from '../services/FormulusInterface'; import { QrcodeResult } from '../types/FormulusInterfaceDefinition'; import QuestionShell from '../components/QuestionShell'; +import { useClearOnHide } from '../jsonforms/useClearOnHide'; import { resolveControlDescription, resolveControlLabel, @@ -135,6 +136,7 @@ const QrcodeQuestionRenderer: React.FC = props => { console.log('QR code value cleared for field:', fieldId); }, [fieldId, handleChange, path]); + useClearOnHide({ visible, path, data, handleChange }); // Don't render if not visible if (!visible) { return null; diff --git a/formulus-formplayer/src/renderers/SignatureQuestionRenderer.tsx b/formulus-formplayer/src/renderers/SignatureQuestionRenderer.tsx index 81a696759..e4ef06a51 100644 --- a/formulus-formplayer/src/renderers/SignatureQuestionRenderer.tsx +++ b/formulus-formplayer/src/renderers/SignatureQuestionRenderer.tsx @@ -9,6 +9,7 @@ import { withJsonFormsControlProps } from '@jsonforms/react'; import { ControlProps, rankWith, formatIs } from '@jsonforms/core'; import QuestionShell from '../components/QuestionShell'; import { tokens } from '../theme/tokens-adapter'; +import { useClearOnHide } from '../jsonforms/useClearOnHide'; // Helper to parse pixel values from tokens const parsePx = (value: string): number => { @@ -212,6 +213,7 @@ const SignatureQuestionRenderer: React.FC = ({ } }, [hasData]); + useClearOnHide({ visible, path, data, handleChange }); // Don't render if not visible if (!visible) { return null; diff --git a/formulus-formplayer/src/renderers/SubObservationQuestionRenderer.tsx b/formulus-formplayer/src/renderers/SubObservationQuestionRenderer.tsx index 962112270..ae179157f 100644 --- a/formulus-formplayer/src/renderers/SubObservationQuestionRenderer.tsx +++ b/formulus-formplayer/src/renderers/SubObservationQuestionRenderer.tsx @@ -11,6 +11,7 @@ import { Box, Typography, Button, IconButton, Tooltip } from '@mui/material'; import EditOutlinedIcon from '@mui/icons-material/EditOutlined'; import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; import QuestionShell from '../components/QuestionShell'; +import { useClearOnHide } from '../jsonforms/useClearOnHide'; import FormulusClient from '../services/FormulusInterface'; import type { FormCompletionResult } from '../types/FormulusInterfaceDefinition'; import { useFormContext } from '../App'; @@ -481,6 +482,7 @@ const SubObservationQuestionRendererInner: React.FC = ({ ], ); + useClearOnHide({ visible, path, data, handleChange }); if (!visible) return null; const shellError = diff --git a/formulus-formplayer/src/renderers/VideoQuestionRenderer.tsx b/formulus-formplayer/src/renderers/VideoQuestionRenderer.tsx index 95711b11c..f85c4a264 100644 --- a/formulus-formplayer/src/renderers/VideoQuestionRenderer.tsx +++ b/formulus-formplayer/src/renderers/VideoQuestionRenderer.tsx @@ -33,6 +33,7 @@ import { VideoFile as VideoFileIcon, } from '@mui/icons-material'; import QuestionShell from '../components/QuestionShell'; +import { useClearOnHide } from '../jsonforms/useClearOnHide'; import FormulusClient from '../services/FormulusInterface'; import { VideoResult, @@ -331,6 +332,7 @@ const VideoQuestionRenderer: React.FC = ({ !!currentVideoData && typeof meta?.duration === 'number'; + useClearOnHide({ visible, path, data, handleChange }); if (visible === false) return null; return ( diff --git a/formulus-formplayer/src/theme/material-wrappers.tsx b/formulus-formplayer/src/theme/material-wrappers.tsx index b9b0df3f5..fc8f256aa 100644 --- a/formulus-formplayer/src/theme/material-wrappers.tsx +++ b/formulus-formplayer/src/theme/material-wrappers.tsx @@ -37,6 +37,7 @@ import { } from '@mui/material'; import QuestionShell from '../components/QuestionShell'; import { isControlHidden } from '../jsonforms/visibleGuard'; +import { useClearOnHide } from '../jsonforms/useClearOnHide'; import { tokens } from './tokens-adapter'; import { parseChoiceLayout, @@ -66,6 +67,7 @@ const CardEnumControl = (props: AnyControlProps) => { visible, } = props; + useClearOnHide({ visible, path, data, handleChange }); if (isControlHidden(visible)) return null; const label = (uischema as any)?.label || schema.title; const description = schema.description; @@ -207,6 +209,7 @@ const SelectOneOfEnumControl = (props: ControlProps & OwnPropsOfEnum) => { visible, } = props; + useClearOnHide({ visible, path, data, handleChange }); if (isControlHidden(visible)) return null; const label = (uischema as any)?.label || schema.title; const description = schema.description; @@ -314,6 +317,7 @@ const EnumArrayShellControl = ( label, } = props; + useClearOnHide({ visible, path, data, handleChange: props.handleChange }); if (visible === false) return null; const title = (uischema as any)?.label || schema.title || label; @@ -403,6 +407,7 @@ export const ChoiceControl = (props: AnyControlProps) => { visible, } = props; + useClearOnHide({ visible, path, data, handleChange }); if (isControlHidden(visible)) return null; const label = (uischema as any)?.label || schema.title; const description = schema.description; @@ -491,8 +496,10 @@ export const MultiChoiceControl = ( errors, enabled = true, visible, + handleChange, } = props; + useClearOnHide({ visible, path, data, handleChange }); if (isControlHidden(visible)) return null; const label = (uischema as any)?.label || schema.title; const description = schema.description; From cc7c593eef9f80dea20555590542474a942f480d Mon Sep 17 00:00:00 2001 From: Emil Rossing Date: Sun, 2 Aug 2026 12:48:31 +0200 Subject: [PATCH 2/2] fix(formplayer): refactor clear-on-hide to remove the property completey (to satisfy AJD validation as unanswered instead of null), but then re-running clear-on-hide after merge of off-screen properties to ensure they don't come back as zombies --- formulus-formplayer/src/App.tsx | 15 ++- .../jsonforms/clearHiddenControlData.test.ts | 116 ++++++++++++++++++ .../src/jsonforms/clearHiddenControlData.ts | 109 ++++++++++++++++ .../src/jsonforms/useClearOnHide.ts | 9 +- .../renderers/subObservationHelpers.test.ts | 8 ++ .../src/renderers/subObservationHelpers.ts | 39 +++++- 6 files changed, 282 insertions(+), 14 deletions(-) create mode 100644 formulus-formplayer/src/jsonforms/clearHiddenControlData.test.ts create mode 100644 formulus-formplayer/src/jsonforms/clearHiddenControlData.ts diff --git a/formulus-formplayer/src/App.tsx b/formulus-formplayer/src/App.tsx index 1df912a69..07da65418 100644 --- a/formulus-formplayer/src/App.tsx +++ b/formulus-formplayer/src/App.tsx @@ -129,10 +129,8 @@ import { runCustomValidatorsAndRefreshData } from './services/customValidatorDat import { resolveErrorPageIndex } from './utils/errorPageNavigation'; import { applyAutoSequences } from './utils/autoSequence'; import { newDraftSessionKey } from './utils/draftSessionKey'; -import { - formDataJsonEqual, - mergePreservingSubObsArrays, -} from './renderers/subObservationHelpers'; +import { formDataJsonEqual } from './renderers/subObservationHelpers'; +import { mergeIncomingFormData } from './jsonforms/clearHiddenControlData'; /** Embedded sub-observation session (also accepts legacy `returnOnly` from older hosts). */ function isSubObservationSession(init: FormInitData): boolean { @@ -1164,7 +1162,12 @@ function App() { ({ data: newData }: { data: FormData }) => { const incoming = newData as Record; const baseline = dataRef.current as Record; - const merged = mergePreservingSubObsArrays(baseline, incoming); + // Preserve off-page SwipeLayout fields, then omit answers that are not + // relevant (key deletion = unanswered for AJV — not null). + const merged = mergeIncomingFormData(baseline, incoming, { + uischema, + ajv, + }); void (async () => { const refreshedData = await refreshFormData(merged); if (formDataJsonEqual(refreshedData, baseline)) { @@ -1175,7 +1178,7 @@ function App() { persistDraftIfRootSession(refreshedData); })(); }, - [refreshFormData, persistDraftIfRootSession], + [refreshFormData, persistDraftIfRootSession, uischema, ajv], ); const commitFormData = useCallback( diff --git a/formulus-formplayer/src/jsonforms/clearHiddenControlData.test.ts b/formulus-formplayer/src/jsonforms/clearHiddenControlData.test.ts new file mode 100644 index 000000000..4a58f295c --- /dev/null +++ b/formulus-formplayer/src/jsonforms/clearHiddenControlData.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect } from 'vitest'; +import { createAjv } from '@jsonforms/core'; +import type { UISchemaElement } from '@jsonforms/core'; +import { + clearHiddenControlData, + mergeIncomingFormData, +} from './clearHiddenControlData'; +import { mergePreservingSubObsArrays } from '../renderers/subObservationHelpers'; + +const ajv = createAjv(); + +/** Minimal cap_ses-style consent page + following group. */ +const uischema = { + type: 'SwipeLayout', + elements: [ + { + type: 'Group', + label: 'Consentimento', + elements: [ + { type: 'Control', scope: '#/properties/consent' }, + { + type: 'Control', + scope: '#/properties/reason_no_consent', + rule: { + effect: 'SHOW', + condition: { + scope: '#/properties/consent', + schema: { const: '2' }, + }, + }, + }, + ], + }, + { + type: 'Group', + label: 'Principal respondente', + rule: { + effect: 'SHOW', + condition: { + scope: '#/properties/consent', + schema: { const: '1' }, + }, + }, + elements: [ + { + type: 'Control', + scope: '#/properties/m1_sexo', + rule: { + effect: 'SHOW', + condition: { + scope: '#/properties/consent', + schema: { const: '1' }, + }, + }, + }, + ], + }, + ], +} as UISchemaElement; + +describe('clearHiddenControlData', () => { + it('omits reason_no_consent when consent flips to Sim (1)', () => { + const data = { + consent: '1', + reason_no_consent: 'Não tenho tempo', + m1_sexo: '1', + }; + const next = clearHiddenControlData(data, uischema, ajv); + expect(next.consent).toBe('1'); + expect(next).not.toHaveProperty('reason_no_consent'); + expect(next.m1_sexo).toBe('1'); + }); + + it('omits Sim-path fields when consent is Não (2), including under a hidden Group', () => { + const data = { + consent: '2', + reason_no_consent: 'Não tenho tempo', + m1_sexo: '2', + }; + const next = clearHiddenControlData(data, uischema, ajv); + expect(next.consent).toBe('2'); + expect(next.reason_no_consent).toBe('Não tenho tempo'); + expect(next).not.toHaveProperty('m1_sexo'); + }); + + it('strips stale null clears so AJV sees unanswered, not Invalid value', () => { + const data = { + consent: '1', + reason_no_consent: null, + }; + const next = clearHiddenControlData(data, uischema, ajv); + expect(next).not.toHaveProperty('reason_no_consent'); + }); +}); + +describe('mergeIncomingFormData', () => { + it('keeps off-page answers but drops ones made irrelevant by SHOW/HIDE', () => { + const baseline = { + consent: '2', + reason_no_consent: 'Não tenho tempo', + m1_sexo: '1', + obsdate: '2026-06-19', + }; + // Partial SwipeLayout payload after flipping consent to Sim; reason key deleted. + const incoming = { consent: '1', m1_sexo: '1' }; + + const restoredOnly = mergePreservingSubObsArrays(baseline, incoming); + expect(restoredOnly.reason_no_consent).toBe('Não tenho tempo'); + + const next = mergeIncomingFormData(baseline, incoming, { uischema, ajv }); + expect(next.consent).toBe('1'); + expect(next.obsdate).toBe('2026-06-19'); // off-page prefill kept + expect(next).not.toHaveProperty('reason_no_consent'); // irrelevant omitted + expect(next.m1_sexo).toBe('1'); + }); +}); diff --git a/formulus-formplayer/src/jsonforms/clearHiddenControlData.ts b/formulus-formplayer/src/jsonforms/clearHiddenControlData.ts new file mode 100644 index 000000000..8c138007d --- /dev/null +++ b/formulus-formplayer/src/jsonforms/clearHiddenControlData.ts @@ -0,0 +1,109 @@ +import { + composeWithUi, + isControl, + isVisible, + Resolve, + type UISchemaElement, +} from '@jsonforms/core'; +import type Ajv from 'ajv'; +import { + mergePreservingSubObsArrays, + omitDataPath, +} from '../renderers/subObservationHelpers'; + +type WalkEl = UISchemaElement & { + type?: string; + elements?: UISchemaElement[]; + scope?: string; +}; + +/** True when a value (including stale `null` clears) should be stripped for a hidden Control. */ +function hasPresentHiddenValue(data: unknown): boolean { + // Missing key → undefined. Stale null/'' from older clear-on-hide builds still strip. + return data !== undefined; +} + +/** + * Omit every Control that is not visible under current data (including Controls + * nested under a hidden Group/page), even if those Controls are not currently + * mounted (SwipeLayout / FlatGroup early-return). + * + * Deletes keys (unanswered) — do not write `null`, which AJV reports as + * "Invalid value" for typed schemas. + */ +export function clearHiddenControlData( + data: Record, + uischema: UISchemaElement | null | undefined, + ajv: Ajv | undefined, + config: unknown = {}, + dispatchPath = '', +): Record { + if (!uischema || !ajv) return data; + + let next = data; + let changed = false; + + const omitHidden = (controlPath: string, current: unknown) => { + if (!hasPresentHiddenValue(current)) return; + if (!changed) { + next = { ...data }; + changed = true; + } + next = omitDataPath(next, controlPath); + }; + + const visit = (element: WalkEl, path: string, ancestorVisible: boolean) => { + const selfVisible = + ancestorVisible && isVisible(element, data, path, ajv, config); + + if (isControl(element)) { + if (!selfVisible) { + const controlPath = composeWithUi(element, path); + if (controlPath) { + omitHidden(controlPath, Resolve.data(next, controlPath)); + } + } + return; + } + + const children = element.elements; + if (!Array.isArray(children) || children.length === 0) { + return; + } + + for (const child of children) { + visit(child as WalkEl, path, selfVisible); + } + }; + + visit(uischema as WalkEl, dispatchPath, true); + return next; +} + +export type MergeIncomingFormDataOptions = { + uischema?: UISchemaElement | null; + ajv?: Ajv; + config?: unknown; +}; + +/** + * SwipeLayout-safe data update: preserve off-page baseline fields, then omit + * answers that SHOW/HIDE says are not relevant. + * + * This is the App `onChange` entry point — keeps clear-on-hide (key deletion / + * unanswered) compatible with {@link mergePreservingSubObsArrays} without a + * `null` sentinel that breaks AJV. + */ +export function mergeIncomingFormData( + baseline: Record, + incoming: Record, + options: MergeIncomingFormDataOptions = {}, +): Record { + const merged = mergePreservingSubObsArrays(baseline, incoming); + return clearHiddenControlData( + merged, + options.uischema, + options.ajv, + options.config, + ); +} diff --git a/formulus-formplayer/src/jsonforms/useClearOnHide.ts b/formulus-formplayer/src/jsonforms/useClearOnHide.ts index 464d16d8e..679b63022 100644 --- a/formulus-formplayer/src/jsonforms/useClearOnHide.ts +++ b/formulus-formplayer/src/jsonforms/useClearOnHide.ts @@ -20,12 +20,9 @@ export function hasClearableValue(data: unknown): boolean { * When JsonForms marks a control `visible: false` (SHOW/HIDE), clear its value * so dependent relevance rules re-evaluate (ODK-style `relevant` behaviour). * - * Formplayer policy: this is the default (and only) behaviour for Controls — - * applied via {@link applyClearOnHideToRenderers} on the renderer registry and - * also called from individual control shells for defense in depth. - * - * Safe to call on every render: no-ops when already empty or when handleChange/path - * are missing (e.g. simple withVisibleGuard demos). + * Clears with `undefined` (JsonForms deletes the key = unanswered). App must + * apply {@link mergeIncomingFormData} on `onChange` so SwipeLayout's baseline + * merge cannot resurrect those keys. */ export function useClearOnHide({ visible, diff --git a/formulus-formplayer/src/renderers/subObservationHelpers.test.ts b/formulus-formplayer/src/renderers/subObservationHelpers.test.ts index b29a8f488..c4a727759 100644 --- a/formulus-formplayer/src/renderers/subObservationHelpers.test.ts +++ b/formulus-formplayer/src/renderers/subObservationHelpers.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; import { formDataJsonEqual, mergePreservingSubObsArrays, + omitDataPath, readDataPath, writeDataPath, resolveTemplateValue, @@ -70,6 +71,13 @@ describe('subObservationHelpers', () => { expect(merged.num).toBe(7); }); + it('omitDataPath removes top-level and nested keys', () => { + expect(omitDataPath({ a: 1, b: 2 }, 'a')).toEqual({ b: 2 }); + expect(omitDataPath({ nest: { x: 1, y: 2 } }, 'nest.x')).toEqual({ + nest: { y: 2 }, + }); + }); + it('formDataJsonEqual compares stable JSON snapshots', () => { expect(formDataJsonEqual({ a: 1 }, { a: 1 })).toBe(true); expect(formDataJsonEqual({ a: 1 }, { a: 2 })).toBe(false); diff --git a/formulus-formplayer/src/renderers/subObservationHelpers.ts b/formulus-formplayer/src/renderers/subObservationHelpers.ts index 8d271d460..0c8022f1e 100644 --- a/formulus-formplayer/src/renderers/subObservationHelpers.ts +++ b/formulus-formplayer/src/renderers/subObservationHelpers.ts @@ -130,6 +130,37 @@ export function writeDataPath( return root; } +/** Immutable omit of a leaf at `dotPath` (top-level or nested object paths). */ +export function omitDataPath( + data: Record, + dotPath: string, +): Record { + if (!dotPath) return data; + const keys = dotPath.split('.'); + if (keys.length === 1) { + if (!(dotPath in data)) return data; + const { [dotPath]: _removed, ...rest } = data; + return rest; + } + + const root = { ...data }; + let cur: Record = root; + for (let i = 0; i < keys.length - 1; i++) { + const key = keys[i]; + const next = cur[key]; + if (next == null || typeof next !== 'object' || Array.isArray(next)) { + return data; + } + const cloned = { ...(next as Record) }; + cur[key] = cloned; + cur = cloned; + } + const leaf = keys[keys.length - 1]; + if (!(leaf in cur)) return data; + delete cur[leaf]; + return root; +} + /** Embedded arrays and derived indexes preserved when JsonForms omits unmounted fields. */ const PRESERVED_ARRAY_KEYS = [ 'quartos', @@ -156,8 +187,12 @@ export function formDataJsonEqual( * absent from `incoming` even though they still live in our baseline draft * state. Starting from `{ ...baseline, ...incoming }` preserves off-page * scalars (host prefills like cluster stamps / obsdate, and directly-used - * `x-autoSequence` fields) so they are not dropped and re-allocated. On-page - * edits (including clearing a field) still override baseline via `incoming`. + * `x-autoSequence` fields) so they are not dropped and re-allocated. + * + * Clear-on-hide deletes keys (unanswered). That is indistinguishable from an + * off-page omit here, so App must use {@link mergeIncomingFormData} which + * re-applies SHOW/HIDE after this merge. Do not clear to `null` — AJV treats + * null as an invalid typed value ("Invalid value") rather than unanswered. * Sub-observation arrays get extra protection below against shorter/partial * payloads. */