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
38 changes: 25 additions & 13 deletions formulus-formplayer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -128,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 {
Expand Down Expand Up @@ -473,6 +472,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>(FormulusClient.getInstance());
const isLoadingRef = useRef<boolean>(
Expand Down Expand Up @@ -1149,7 +1162,12 @@ function App() {
({ data: newData }: { data: FormData }) => {
const incoming = newData as Record<string, unknown>;
const baseline = dataRef.current as Record<string, unknown>;
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)) {
Expand All @@ -1160,7 +1178,7 @@ function App() {
persistDraftIfRootSession(refreshedData);
})();
},
[refreshFormData, persistDraftIfRootSession],
[refreshFormData, persistDraftIfRootSession, uischema, ajv],
);

const commitFormData = useCallback(
Expand Down Expand Up @@ -1549,13 +1567,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}
Expand Down
3 changes: 3 additions & 0 deletions formulus-formplayer/src/DynamicEnumControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
CircularProgress,
} from '@mui/material';
import QuestionShell from './components/QuestionShell';
import { useClearOnHide } from './jsonforms/useClearOnHide';

/**
* Interface for x-dynamicEnum configuration
Expand Down Expand Up @@ -129,6 +130,8 @@ const DynamicEnumControl: React.FC<ControlProps> = ({
const { functions } = useFormEvaluation();
const ctx = useJsonForms();

useClearOnHide({ visible, path, data, handleChange });

const [choices, setChoices] = useState<Array<{ const: any; title: string }>>(
[],
);
Expand Down
13 changes: 12 additions & 1 deletion formulus-formplayer/src/jsonforms/ShellInputControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
resolveControlDescription,
resolveControlLabel,
} from '../utils/controlDisplayText';
import { useClearOnHide } from './useClearOnHide';

/**
* String control that renders the stock MuiInputText cell inside QuestionShell
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>>({
gate: 'yes',
detail: 'keep-me',
});
return (
<ThemeProvider theme={theme}>
<div data-testid="data-json">{JSON.stringify(data)}</div>
<JsonForms
schema={schema}
uischema={uischema}
data={data}
renderers={applyClearOnHideToRenderers([
...shellMaterialRenderers,
...materialRenderers,
])}
ajv={ajv}
onChange={({ data: next }) =>
setData(next as Record<string, unknown>)
}
/>
</ThemeProvider>
);
}

render(<Harness />);
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<string, unknown>;
expect(data.gate).toBe('no');
expect(data.detail).toBeUndefined();
});
expect(screen.queryByDisplayValue('keep-me')).toBeNull();
});
});
106 changes: 106 additions & 0 deletions formulus-formplayer/src/jsonforms/applyClearOnHideToRenderers.tsx
Original file line number Diff line number Diff line change
@@ -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<P extends RegistryRendererProps>(
Component: React.ComponentType<P>,
): React.FC<P> {
const Wrapped: React.FC<P> = 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<ClearOnHideHandleChange>(
(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 <Component {...props} />;
};
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<RegistryRendererProps>,
),
}));
}
Loading
Loading