From 5a91a6191eb1ed6952707d0c3cd7e33c609c6733 Mon Sep 17 00:00:00 2001 From: Emil Rossing Date: Thu, 18 Jun 2026 08:54:56 +0000 Subject: [PATCH 01/46] refactor(theme): update header height calculation and add isClampableInputType tests - Adjusted the calculation for `odeFormplayerHeaderHeight` to use `odeTypography.bodySm` for title line height and added vertical padding. - Introduced tests for `isClampableInputType` to validate input types that can be clamped, ensuring correct behavior for various input scenarios. --- .../src/hooks/useKeyboardScrollClamp.test.ts | 19 ++++++++++++++++++- formulus/src/theme/odeDesign.ts | 5 +++-- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/formulus-formplayer/src/hooks/useKeyboardScrollClamp.test.ts b/formulus-formplayer/src/hooks/useKeyboardScrollClamp.test.ts index 78cbdf49c..36dd1c415 100644 --- a/formulus-formplayer/src/hooks/useKeyboardScrollClamp.test.ts +++ b/formulus-formplayer/src/hooks/useKeyboardScrollClamp.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest'; -import { clampScrollTop } from './useKeyboardScrollClamp'; +import { + clampScrollTop, + isClampableInputType, +} from './useKeyboardScrollClamp'; describe('clampScrollTop', () => { it('does not change scrollTop when within range', () => { @@ -32,3 +35,17 @@ describe('clampScrollTop', () => { expect(el.scrollTop).toBe(0); }); }); + +describe('isClampableInputType', () => { + it('includes number and text types', () => { + expect(isClampableInputType('number')).toBe(true); + expect(isClampableInputType('text')).toBe(true); + expect(isClampableInputType(undefined)).toBe(true); + }); + + it('excludes buttons and hidden inputs', () => { + expect(isClampableInputType('hidden')).toBe(false); + expect(isClampableInputType('button')).toBe(false); + expect(isClampableInputType('checkbox')).toBe(false); + }); +}); diff --git a/formulus/src/theme/odeDesign.ts b/formulus/src/theme/odeDesign.ts index 0c8ad0dac..1ed8a39c1 100644 --- a/formulus/src/theme/odeDesign.ts +++ b/formulus/src/theme/odeDesign.ts @@ -81,6 +81,7 @@ export const odeScreenHeaderHeight = (() => { /** Compact single-line header for the Formplayer modal (title + close only). */ export const odeFormplayerHeaderHeight = (() => { const lineHeightFactor = 1.25; - const titleLineHeight = odeTypography.screenTitle * lineHeightFactor; - return odeSpacing.sm * 2 + titleLineHeight; + const titleLineHeight = odeTypography.bodySm * lineHeightFactor; + const verticalPadding = 4; + return verticalPadding + titleLineHeight; })(); From ea996680d1d945a5f0292a56f0d46cf268518855 Mon Sep 17 00:00:00 2001 From: Emil Rossing Date: Thu, 18 Jun 2026 08:56:01 +0000 Subject: [PATCH 02/46] feat(formulus): more compact UI in formplayer header, changed autofocus to opt-in --- formulus-formplayer/README.md | 12 ++ .../src/components/FormLayout.tsx | 9 +- .../src/components/FormProgressBar.tsx | 2 - .../src/hooks/useKeyboardScrollClamp.ts | 66 ++++++-- .../MaterialTextControlWithImeHint.tsx | 18 ++- .../src/jsonforms/ShellInputControl.tsx | 17 +- .../src/renderers/NumberStepperRenderer.tsx | 9 +- .../src/renderers/SwipeLayoutRenderer.tsx | 149 +++++++++--------- .../src/utils/autofocusHelpers.test.ts | 58 +++++++ .../src/utils/autofocusHelpers.ts | 94 +++++++++++ formulus/src/components/FormplayerModal.tsx | 22 +-- 11 files changed, 337 insertions(+), 119 deletions(-) create mode 100644 formulus-formplayer/src/utils/autofocusHelpers.test.ts create mode 100644 formulus-formplayer/src/utils/autofocusHelpers.ts diff --git a/formulus-formplayer/README.md b/formulus-formplayer/README.md index 3042e3781..d49a91cfc 100644 --- a/formulus-formplayer/README.md +++ b/formulus-formplayer/README.md @@ -167,3 +167,15 @@ flowchart LR FP -->|postMessage events| SM CA -->|postMessage commands| SM ``` + +## SwipeLayout options (keyboard & header) + +Forms that use a root `SwipeLayout` in `ui.json` support these `options`: + +| Option | Default | Description | +|--------|---------|-------------| +| `autoFocusFirstInput` | `false` | When `true`, focuses the first text-like field each time the user changes page (legacy behavior). | +| `headerFields` | `[]` | Up to two field keys shown as context tags below the progress bar. | +| `headerTitle` | — | Optional inner title below the context bar. | + +Per-control focus: set `options.autoFocus: true` on a `Control` to focus that field when its page is shown (takes precedence over `autoFocusFirstInput`). diff --git a/formulus-formplayer/src/components/FormLayout.tsx b/formulus-formplayer/src/components/FormLayout.tsx index 9e56cec82..e93fbfe08 100644 --- a/formulus-formplayer/src/components/FormLayout.tsx +++ b/formulus-formplayer/src/components/FormLayout.tsx @@ -258,15 +258,14 @@ const FormLayout: React.FC = ({ width: '100%', boxSizing: 'border-box', backgroundColor: 'background.default', - paddingTop: `max(${theme.spacing(2)}, env(safe-area-inset-top, 0px))`, - paddingRight: theme.spacing(2), - paddingBottom: theme.spacing(2), - paddingLeft: theme.spacing(2), + paddingTop: `max(${theme.spacing(0.5)}, env(safe-area-inset-top, 0px))`, + paddingRight: theme.spacing(1.5), + paddingBottom: theme.spacing(0.5), + paddingLeft: theme.spacing(1.5), overflow: 'visible', borderBottom: `1px solid ${theme.palette.divider}`, borderRadius: 0, boxShadow: 'none', - minHeight: 82, })}> {header} diff --git a/formulus-formplayer/src/components/FormProgressBar.tsx b/formulus-formplayer/src/components/FormProgressBar.tsx index 2ffa27d97..51fa0f35b 100644 --- a/formulus-formplayer/src/components/FormProgressBar.tsx +++ b/formulus-formplayer/src/components/FormProgressBar.tsx @@ -203,7 +203,6 @@ const FormProgressBar: React.FC = ({ = ({ display: 'flex', alignItems: 'center', gap: { xs: 0.25, sm: 0.5 }, - mb: 0.5, px: { xs: 0.5, sm: 1 }, }}> {showHeaderNav ? ( diff --git a/formulus-formplayer/src/hooks/useKeyboardScrollClamp.ts b/formulus-formplayer/src/hooks/useKeyboardScrollClamp.ts index 5109815dc..7cd19f98c 100644 --- a/formulus-formplayer/src/hooks/useKeyboardScrollClamp.ts +++ b/formulus-formplayer/src/hooks/useKeyboardScrollClamp.ts @@ -1,21 +1,29 @@ import { useCallback, useEffect, useRef } from 'react'; -function isTextInput( +/** Input types that should trigger scroll clamp on value change. */ +export function isClampableInputType(type: string | undefined): boolean { + const normalized = type?.toLowerCase() ?? 'text'; + return ![ + 'hidden', + 'checkbox', + 'radio', + 'file', + 'button', + 'submit', + 'reset', + ].includes(normalized); +} + +/** Inputs that participate in IME scroll clamping (focus + value change). */ +export function isFormFieldForScrollClamp( el: EventTarget | null, -): el is HTMLInputElement | HTMLTextAreaElement { +): el is HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement { if (!el || !(el instanceof HTMLElement)) return false; - if (el instanceof HTMLTextAreaElement) return true; + if (el instanceof HTMLTextAreaElement || el instanceof HTMLSelectElement) { + return true; + } if (el instanceof HTMLInputElement) { - const type = el.type?.toLowerCase() ?? 'text'; - return ![ - 'hidden', - 'checkbox', - 'radio', - 'file', - 'button', - 'submit', - 'reset', - ].includes(type); + return isClampableInputType(el.type); } return false; } @@ -29,8 +37,8 @@ export function clampScrollTop(el: HTMLElement): void { } /** - * Clamps FormLayout scroll when the IME opens and after text field focus. - * Avoids scrolling into flex filler / padding void above the nav bar. + * Clamps FormLayout scroll when the IME opens, on field focus, and after value + * changes (number stepper +/-, numeric keyboard input, layout reflow). */ export function useKeyboardScrollClamp() { const scrollRef = useRef(null); @@ -52,7 +60,7 @@ export function useKeyboardScrollClamp() { const onFocusIn = (event: FocusEvent) => { const target = event.target; - if (!isTextInput(target)) return; + if (!isFormFieldForScrollClamp(target)) return; requestAnimationFrame(() => { requestAnimationFrame(() => { @@ -68,14 +76,40 @@ export function useKeyboardScrollClamp() { }); }; + const onInputOrChange = (event: Event) => { + if (!isFormFieldForScrollClamp(event.target)) return; + requestAnimationFrame(clamp); + }; + + const resizeObserver = + typeof ResizeObserver !== 'undefined' + ? new ResizeObserver(() => { + const active = document.activeElement; + if ( + active && + el.contains(active) && + isFormFieldForScrollClamp(active) + ) { + requestAnimationFrame(clamp); + } + }) + : null; + + resizeObserver?.observe(el); + vv?.addEventListener('resize', onViewportChange); vv?.addEventListener('scroll', onViewportChange); el.addEventListener('focusin', onFocusIn); + el.addEventListener('input', onInputOrChange, true); + el.addEventListener('change', onInputOrChange, true); return () => { + resizeObserver?.disconnect(); vv?.removeEventListener('resize', onViewportChange); vv?.removeEventListener('scroll', onViewportChange); el.removeEventListener('focusin', onFocusIn); + el.removeEventListener('input', onInputOrChange, true); + el.removeEventListener('change', onInputOrChange, true); }; }, [clamp]); diff --git a/formulus-formplayer/src/jsonforms/MaterialTextControlWithImeHint.tsx b/formulus-formplayer/src/jsonforms/MaterialTextControlWithImeHint.tsx index 9cb7f9e43..57a0df26a 100644 --- a/formulus-formplayer/src/jsonforms/MaterialTextControlWithImeHint.tsx +++ b/formulus-formplayer/src/jsonforms/MaterialTextControlWithImeHint.tsx @@ -26,15 +26,23 @@ const MaterialInputControlUntyped = MaterialInputControl as React.FC< */ const MaterialTextControlWithImeHint = (props: ControlProps) => { const { keyboardEnterKeyHint } = useFormContext(); + const { uischema } = props; + const autoFocus = + (uischema as { options?: { autoFocus?: boolean } })?.options?.autoFocus === + true; return ( ); diff --git a/formulus-formplayer/src/jsonforms/ShellInputControl.tsx b/formulus-formplayer/src/jsonforms/ShellInputControl.tsx index e4b638cea..367e658f3 100644 --- a/formulus-formplayer/src/jsonforms/ShellInputControl.tsx +++ b/formulus-formplayer/src/jsonforms/ShellInputControl.tsx @@ -32,6 +32,9 @@ const ShellInputControl = (props: ControlProps) => { ? errors.filter(Boolean).join(', ') : errors || null; const isValid = !errorStr; + const autoFocus = + (uischema as { options?: { autoFocus?: boolean } })?.options?.autoFocus === + true; return ( { {...(props as React.ComponentProps)} label={undefined} isValid={isValid} - muiInputProps={ - keyboardEnterKeyHint - ? { enterKeyHint: keyboardEnterKeyHint } - : undefined - } + muiInputProps={{ + ...(keyboardEnterKeyHint ? { enterKeyHint: keyboardEnterKeyHint } : {}), + ...(autoFocus + ? { + autoFocus: true, + 'data-formplayer-autofocus': 'true', + } + : {}), + }} /> ); diff --git a/formulus-formplayer/src/renderers/NumberStepperRenderer.tsx b/formulus-formplayer/src/renderers/NumberStepperRenderer.tsx index 4a319e8f7..26b2be048 100644 --- a/formulus-formplayer/src/renderers/NumberStepperRenderer.tsx +++ b/formulus-formplayer/src/renderers/NumberStepperRenderer.tsx @@ -55,6 +55,9 @@ const NumberStepperRenderer = ({ (uischema as { options?: { required?: boolean } })?.options?.required ?? required, ); + const autoFocus = + (uischema as { options?: { autoFocus?: boolean } })?.options?.autoFocus === + true; const handleAdd = () => { const currentValue = numericValue || 0; @@ -108,7 +111,11 @@ const NumberStepperRenderer = ({ disabled={!enabled} error={Boolean(errorStr)} fullWidth - inputProps={{ step }} + autoFocus={autoFocus} + inputProps={{ + step, + ...(autoFocus ? { 'data-formplayer-autofocus': 'true' } : {}), + }} InputProps={{ endAdornment: isFocused ? ( diff --git a/formulus-formplayer/src/renderers/SwipeLayoutRenderer.tsx b/formulus-formplayer/src/renderers/SwipeLayoutRenderer.tsx index 5fa04f105..2acae1a59 100644 --- a/formulus-formplayer/src/renderers/SwipeLayoutRenderer.tsx +++ b/formulus-formplayer/src/renderers/SwipeLayoutRenderer.tsx @@ -36,6 +36,11 @@ import { pageIsVisibleInSwipe, visiblePageIndicesFromLayouts, } from './swipeLayoutVisibility'; +import { + findAutoFocusPropertyPath, + focusFieldInContainer, + focusFirstEnabledTextInput, +} from '../utils/autofocusHelpers'; // --------------------------------------------------------------------------- // Testers @@ -69,20 +74,6 @@ const CONFIRM_CARD_RADIUS = 0.7; const CONFIRM_BORDER_WIDTH = 1; const CONFIRM_CARD_PADDING = 16; -/** Focus first text-like input on the screen (keeps mobile keyboard open across page changes). */ -function focusFirstEnabledTextInput(container: HTMLElement | null): void { - if (!container) return; - const sel = - 'input:not([disabled]):not([type="hidden"]):not([type="checkbox"]):not([type="radio"]):not([type="file"]):not([type="button"]):not([type="submit"]):not([type="reset"]),textarea:not([disabled])'; - const el = container.querySelector(sel); - if (!el || typeof el.focus !== 'function') return; - try { - el.focus({ preventScroll: true }); - } catch { - el.focus(); - } -} - const SwipeLayoutRenderer = ({ schema, uischema, @@ -137,7 +128,7 @@ const SwipeLayoutRenderer = ({ }; }, [uischema]); - const autoFocusFirstInput = swipeOptions.autoFocusFirstInput !== false; + const autoFocusFirstInput = swipeOptions.autoFocusFirstInput === true; const labelLayout: LabelLayout = swipeOptions.labelLayout === 'stacked' ? 'stacked' : 'inline'; const showInnerTitle = swipeOptions.showInnerTitle === true; @@ -148,10 +139,18 @@ const SwipeLayoutRenderer = ({ const swipeScreenRef = useRef(null); useEffect(() => { - if (!autoFocusFirstInput) return; let cancelled = false; const timer = window.setTimeout(() => { - if (!cancelled) { + if (cancelled || !swipeScreenRef.current) return; + const pageUi = layouts[currentPage]; + const propPath = findAutoFocusPropertyPath(pageUi); + if ( + propPath && + focusFieldInContainer(swipeScreenRef.current, propPath) + ) { + return; + } + if (autoFocusFirstInput) { focusFirstEnabledTextInput(swipeScreenRef.current); } }, 150); @@ -159,7 +158,7 @@ const SwipeLayoutRenderer = ({ cancelled = true; window.clearTimeout(timer); }; - }, [currentPage, autoFocusFirstInput]); + }, [currentPage, autoFocusFirstInput, layouts]); if (typeof handleChange !== 'function') { console.warn( @@ -539,64 +538,6 @@ const SwipeLayoutRenderer = ({ keyboardSubmitAction={keyboardSubmitAction} header={ <> - {/* Author-configured form title and sticky fields */} - {((showInnerTitle && headerTitle) || headerFields.length > 0) && ( - 0 ? 0 : 0.25 }}> - {showInnerTitle && headerTitle && ( - 0 ? 0.5 : 0, - textAlign: 'left', - }}> - {headerTitle} - - )} - {headerFields.length > 0 && ( - - {headerFields.map((fieldKey: string) => { - const fieldSchema = (schema as any)?.properties?.[ - fieldKey - ]; - const label = fieldSchema?.title || fieldKey; - const value = data?.[fieldKey]; - const displayValue = - value != null && value !== '' ? String(value) : '—'; - return ( - - {label}: {displayValue} - - ); - })} - - )} - - )} + {headerFields.length > 0 && ( + ({ + display: 'flex', + flexWrap: 'wrap', + gap: 0.5, + py: 0.5, + px: { xs: 0.5, sm: 1 }, + borderTop: `1px solid ${theme.palette.divider}`, + })}> + {headerFields.map((fieldKey: string) => { + const fieldSchema = (schema as any)?.properties?.[ + fieldKey + ]; + const label = fieldSchema?.title || fieldKey; + const value = data?.[fieldKey]; + const displayValue = + value != null && value !== '' ? String(value) : '—'; + return ( + + {label}: {displayValue} + + ); + })} + + )} + {showInnerTitle && headerTitle && ( + + {headerTitle} + + )} } previousButton={ diff --git a/formulus-formplayer/src/utils/autofocusHelpers.test.ts b/formulus-formplayer/src/utils/autofocusHelpers.test.ts new file mode 100644 index 000000000..3473c3e14 --- /dev/null +++ b/formulus-formplayer/src/utils/autofocusHelpers.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +import { + findAutoFocusPropertyPath, + controlWantsAutoFocus, +} from './autofocusHelpers'; + +describe('controlWantsAutoFocus', () => { + it('is false unless options.autoFocus is true', () => { + expect(controlWantsAutoFocus({ type: 'Control' })).toBe(false); + expect( + controlWantsAutoFocus({ type: 'Control', options: { autoFocus: false } }), + ).toBe(false); + expect( + controlWantsAutoFocus({ type: 'Control', options: { autoFocus: true } }), + ).toBe(true); + }); +}); + +describe('findAutoFocusPropertyPath', () => { + it('returns the first Control with autoFocus on the page', () => { + const page = { + type: 'VerticalLayout', + elements: [ + { type: 'Control', scope: '#/properties/foo' }, + { + type: 'Control', + scope: '#/properties/bar', + options: { autoFocus: true }, + }, + { + type: 'Control', + scope: '#/properties/baz', + options: { autoFocus: true }, + }, + ], + }; + expect(findAutoFocusPropertyPath(page)).toBe('bar'); + }); + + it('walks nested layouts', () => { + const page = { + type: 'Group', + elements: [ + { + type: 'VerticalLayout', + elements: [ + { + type: 'Control', + scope: '#/properties/nested', + options: { autoFocus: true }, + }, + ], + }, + ], + }; + expect(findAutoFocusPropertyPath(page)).toBe('nested'); + }); +}); diff --git a/formulus-formplayer/src/utils/autofocusHelpers.ts b/formulus-formplayer/src/utils/autofocusHelpers.ts new file mode 100644 index 000000000..1a41324cb --- /dev/null +++ b/formulus-formplayer/src/utils/autofocusHelpers.ts @@ -0,0 +1,94 @@ +/** Whether a Control ui schema opts into autofocus when its page is shown. */ +export function controlWantsAutoFocus(uischema: unknown): boolean { + if (!uischema || typeof uischema !== 'object') return false; + const options = (uischema as { options?: { autoFocus?: boolean } }).options; + return options?.autoFocus === true; +} + +/** First property key on the current page with `options.autoFocus: true`. */ +export function findAutoFocusPropertyPath( + uischema: unknown, +): string | undefined { + let found: string | undefined; + + const walk = (node: unknown): void => { + if (found || !node || typeof node !== 'object') return; + const ui = node as { + type?: string; + scope?: string; + options?: { autoFocus?: boolean }; + elements?: unknown[]; + }; + + if ( + ui.type === 'Control' && + controlWantsAutoFocus(ui) && + typeof ui.scope === 'string' + ) { + const match = /^#\/properties\/(.+)$/.exec(ui.scope); + if (match?.[1]) { + found = match[1]; + return; + } + } + + if (Array.isArray(ui.elements)) { + for (const child of ui.elements) { + walk(child); + if (found) return; + } + } + }; + + walk(uischema); + return found; +} + +export function focusFieldInContainer( + container: HTMLElement, + propertyPath?: string, +): boolean { + const marked = container.querySelector( + '[data-formplayer-autofocus="true"]', + ); + if (marked && typeof marked.focus === 'function') { + try { + marked.focus({ preventScroll: true }); + } catch { + marked.focus(); + } + return true; + } + + if (propertyPath) { + const byName = container.querySelector( + `input[name="${CSS.escape(propertyPath)}"], textarea[name="${CSS.escape(propertyPath)}"]`, + ); + if (byName && typeof byName.focus === 'function') { + try { + byName.focus({ preventScroll: true }); + } catch { + byName.focus(); + } + return true; + } + } + + return false; +} + +/** Focus first enabled text-like input on the screen. */ +export function focusFirstEnabledTextInput( + container: HTMLElement | null, +): void { + if (!container) return; + const sel = + 'input:not([disabled]):not([type="hidden"]):not([type="checkbox"]):not([type="radio"]):not([type="file"]):not([type="button"]):not([type="submit"]):not([type="reset"]),textarea:not([disabled])'; + const el = container.querySelector(sel); + if (!el || typeof el.focus !== 'function') return; + try { + el.focus({ preventScroll: true }); + } catch { + el.focus(); + } +} diff --git a/formulus/src/components/FormplayerModal.tsx b/formulus/src/components/FormplayerModal.tsx index 06070d970..b92689549 100644 --- a/formulus/src/components/FormplayerModal.tsx +++ b/formulus/src/components/FormplayerModal.tsx @@ -701,7 +701,8 @@ const FormplayerModal = forwardRef( styles.headerTitle, { color: themeColors.onBackground }, ]} - numberOfLines={1}> + numberOfLines={1} + ellipsizeMode="tail"> {currentFormDisplayName || (currentObservationId ? 'Edit Observation' @@ -742,13 +743,14 @@ const styles = StyleSheet.create({ }, header: { flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'flex-start', + justifyContent: 'flex-start', + alignItems: 'center', width: '100%', - padding: odeSpacing.md, + paddingLeft: 6, + paddingRight: odeSpacing.sm, + paddingVertical: 2, borderBottomWidth: odeBorderWidth.hairline, minHeight: odeFormplayerHeaderHeight, - paddingVertical: odeSpacing.sm, borderTopWidth: 0, borderLeftWidth: 0, borderRightWidth: 0, @@ -756,13 +758,15 @@ const styles = StyleSheet.create({ overflow: 'visible', }, headerTitle: { - fontSize: odeTypography.screenTitle, - fontWeight: 'bold', - marginLeft: 0, + fontSize: odeTypography.bodySm, + fontWeight: 'normal', + marginLeft: 8, + flex: 1, flexShrink: 1, }, closeButton: { - padding: odeSpacing.xs, + paddingVertical: 2, + paddingHorizontal: 2, }, disabledButton: { opacity: 0.5, From 874c413f3d27a6d53d67004ddfaabb15d733d616 Mon Sep 17 00:00:00 2001 From: Emil Rossing Date: Thu, 18 Jun 2026 08:59:05 +0000 Subject: [PATCH 03/46] chore: formatting --- formulus-formplayer/README.md | 8 ++++---- .../src/hooks/useKeyboardScrollClamp.test.ts | 5 +---- formulus-formplayer/src/jsonforms/ShellInputControl.tsx | 4 +++- .../src/renderers/SwipeLayoutRenderer.tsx | 9 ++------- 4 files changed, 10 insertions(+), 16 deletions(-) diff --git a/formulus-formplayer/README.md b/formulus-formplayer/README.md index d49a91cfc..5bc107496 100644 --- a/formulus-formplayer/README.md +++ b/formulus-formplayer/README.md @@ -172,10 +172,10 @@ flowchart LR Forms that use a root `SwipeLayout` in `ui.json` support these `options`: -| Option | Default | Description | -|--------|---------|-------------| +| Option | Default | Description | +| --------------------- | ------- | ------------------------------------------------------------------------------------------------- | | `autoFocusFirstInput` | `false` | When `true`, focuses the first text-like field each time the user changes page (legacy behavior). | -| `headerFields` | `[]` | Up to two field keys shown as context tags below the progress bar. | -| `headerTitle` | — | Optional inner title below the context bar. | +| `headerFields` | `[]` | Up to two field keys shown as context tags below the progress bar. | +| `headerTitle` | — | Optional inner title below the context bar. | Per-control focus: set `options.autoFocus: true` on a `Control` to focus that field when its page is shown (takes precedence over `autoFocusFirstInput`). diff --git a/formulus-formplayer/src/hooks/useKeyboardScrollClamp.test.ts b/formulus-formplayer/src/hooks/useKeyboardScrollClamp.test.ts index 36dd1c415..7e0759200 100644 --- a/formulus-formplayer/src/hooks/useKeyboardScrollClamp.test.ts +++ b/formulus-formplayer/src/hooks/useKeyboardScrollClamp.test.ts @@ -1,8 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { - clampScrollTop, - isClampableInputType, -} from './useKeyboardScrollClamp'; +import { clampScrollTop, isClampableInputType } from './useKeyboardScrollClamp'; describe('clampScrollTop', () => { it('does not change scrollTop when within range', () => { diff --git a/formulus-formplayer/src/jsonforms/ShellInputControl.tsx b/formulus-formplayer/src/jsonforms/ShellInputControl.tsx index 367e658f3..d4b5c68c0 100644 --- a/formulus-formplayer/src/jsonforms/ShellInputControl.tsx +++ b/formulus-formplayer/src/jsonforms/ShellInputControl.tsx @@ -47,7 +47,9 @@ const ShellInputControl = (props: ControlProps) => { label={undefined} isValid={isValid} muiInputProps={{ - ...(keyboardEnterKeyHint ? { enterKeyHint: keyboardEnterKeyHint } : {}), + ...(keyboardEnterKeyHint + ? { enterKeyHint: keyboardEnterKeyHint } + : {}), ...(autoFocus ? { autoFocus: true, diff --git a/formulus-formplayer/src/renderers/SwipeLayoutRenderer.tsx b/formulus-formplayer/src/renderers/SwipeLayoutRenderer.tsx index 2acae1a59..1b1dd3439 100644 --- a/formulus-formplayer/src/renderers/SwipeLayoutRenderer.tsx +++ b/formulus-formplayer/src/renderers/SwipeLayoutRenderer.tsx @@ -144,10 +144,7 @@ const SwipeLayoutRenderer = ({ if (cancelled || !swipeScreenRef.current) return; const pageUi = layouts[currentPage]; const propPath = findAutoFocusPropertyPath(pageUi); - if ( - propPath && - focusFieldInContainer(swipeScreenRef.current, propPath) - ) { + if (propPath && focusFieldInContainer(swipeScreenRef.current, propPath)) { return; } if (autoFocusFirstInput) { @@ -569,9 +566,7 @@ const SwipeLayoutRenderer = ({ borderTop: `1px solid ${theme.palette.divider}`, })}> {headerFields.map((fieldKey: string) => { - const fieldSchema = (schema as any)?.properties?.[ - fieldKey - ]; + const fieldSchema = (schema as any)?.properties?.[fieldKey]; const label = fieldSchema?.title || fieldKey; const value = data?.[fieldKey]; const displayValue = From f22cd4d7161f5ceb830ef699c9329571366d9c72 Mon Sep 17 00:00:00 2001 From: Emil Rossing Date: Thu, 18 Jun 2026 08:59:34 +0000 Subject: [PATCH 04/46] feat(formulus): include note to remember formatting before commit --- AGENTS.md | 2 +- desktop/AGENTS.md | 10 ++++++++++ formulus-formplayer/AGENTS.md | 3 ++- formulus/AGENTS.md | 2 ++ formulus/custom_app_development.md | 8 ++++---- synkronus-portal/AGENTS.md | 8 ++++++++ 6 files changed, 27 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3a8faf404..b7cdca582 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,7 +75,7 @@ Do not assume custom app authors have local checkouts of **ODE** or internal exa - **Pipelines:** [.github/CICD.md](.github/CICD.md). - **Lint/format:** Run the relevant scripts in the **package you touch** (see root [README.md](README.md) and each package). -- **Pre-flight before opening a PR:** each package `AGENTS.md` lists the local `lint` / `format:check` / `test` / `build` commands that match CI — run them in every package you changed (e.g. [formulus-formplayer/AGENTS.md](formulus-formplayer/AGENTS.md#pre-flight-before-a-pr)). +- **Pre-flight before opening a PR:** each package `AGENTS.md` lists the local `lint` / `format` / `format:check` / `test` / `build` commands that match CI — run them in every package you changed (e.g. [formulus-formplayer/AGENTS.md](formulus-formplayer/AGENTS.md#pre-flight-before-a-pr)). - **Commits/PRs:** Conventional Commits and PR expectations are documented in [formulus-formplayer/AGENTS.md](formulus-formplayer/AGENTS.md) (project-wide convention). --- diff --git a/desktop/AGENTS.md b/desktop/AGENTS.md index 7a19520a3..d2598914a 100644 --- a/desktop/AGENTS.md +++ b/desktop/AGENTS.md @@ -95,4 +95,14 @@ pnpm typecheck cd src-tauri && cargo test ``` +**Pre-flight before a PR** (from `desktop/`): + +```bash +pnpm run lint +pnpm run format +pnpm run format:check +pnpm test +pnpm typecheck +``` + Conventional Commits; see root [AGENTS.md](../AGENTS.md) and [.github/CICD.md](../.github/CICD.md). diff --git a/formulus-formplayer/AGENTS.md b/formulus-formplayer/AGENTS.md index b20d8df19..30634756f 100644 --- a/formulus-formplayer/AGENTS.md +++ b/formulus-formplayer/AGENTS.md @@ -88,6 +88,7 @@ Run from **`formulus-formplayer/`** (CI runs the same steps): ```bash pnpm run lint +pnpm run format pnpm run format:check pnpm run test run pnpm run build @@ -102,7 +103,7 @@ If the PR also touches **Formulus** (`../formulus/`), run `pnpm run lint` there ## Commit and pull request workflow - **Commit messages** must follow [Conventional Commits](https://www.conventionalcommits.org/) (e.g. `feat(scope): add X`, `fix(scope): resolve Y`). -- **Before opening a PR**, run `pnpm run format` so Prettier has formatted the files. +- **Before opening a PR**, run the pre-flight block above (including `pnpm run format` so CI `format:check` does not fail on unformatted files). - **PRs** should use the following template: --- diff --git a/formulus/AGENTS.md b/formulus/AGENTS.md index 1684495ab..85af14920 100644 --- a/formulus/AGENTS.md +++ b/formulus/AGENTS.md @@ -53,6 +53,8 @@ From **`formulus/`**: ```bash pnpm run lint +pnpm run format +pnpm run format:check pnpm run test --ci --watchAll=false ``` diff --git a/formulus/custom_app_development.md b/formulus/custom_app_development.md index 9acae9e67..7562042fb 100644 --- a/formulus/custom_app_development.md +++ b/formulus/custom_app_development.md @@ -212,11 +212,11 @@ Use `openFormplayer(formType, params, savedData, options?)`. Reserved `params` k Common **options** (4th argument): -| Option | Purpose | -| -------------------- | ---------------------------------------------------------------------------- | -| `subObservationMode` | Embedded child form; result JSON returns to parent without top-level persist | +| Option | Purpose | +| -------------------- | ------------------------------------------------------------------------------------- | +| `subObservationMode` | Embedded child form; result JSON returns to parent without top-level persist | | `skipFinalize` | Omit Finalize page; **Done** validates child schema then returns `formData` to parent | -| `skipDraftSelection` | Skip draft picker when the app orchestrates a new root session | +| `skipDraftSelection` | Skip draft picker when the app orchestrates a new root session | **Nested sub-observations:** Each child session has its own schema and validators. `skipFinalize` does not defer validation to the root form. For multi-level embedded trees, put validators on each form where rows are added, or pass parent snapshots via flat `subObservationInitValues` — see [Custom Extensions](https://opendataensemble.org/docs/guides/custom-extensions#nested-sessions-and-custom-validators) on opendataensemble.org. diff --git a/synkronus-portal/AGENTS.md b/synkronus-portal/AGENTS.md index f034c96df..5faed7038 100644 --- a/synkronus-portal/AGENTS.md +++ b/synkronus-portal/AGENTS.md @@ -25,3 +25,11 @@ ## Quick commands From `synkronus-portal/`: `pnpm install`, `pnpm run dev`, `pnpm run lint`, `pnpm run format` — align with root [README.md](../README.md) and CI. + +**Pre-flight before a PR** (from `synkronus-portal/`): + +```bash +pnpm run lint +pnpm run format +pnpm run format:check +``` From 3832dee7adbdc8c1dc8c31f3caedfdf4763752b8 Mon Sep 17 00:00:00 2001 From: Emil Rossing Date: Thu, 18 Jun 2026 09:33:53 +0000 Subject: [PATCH 05/46] fix(formplayer): fix duplicate error message issue --- formulus-formplayer/README.md | 6 +++ .../src/DynamicEnumControl.tsx | 44 +++++++------------ .../src/jsonforms/ShellInputControl.tsx | 7 ++- .../src/renderers/AdateQuestionRenderer.tsx | 18 +------- .../renderers/CustomQuestionTypeAdapter.tsx | 20 ++++++--- .../src/theme/material-wrappers.tsx | 2 - .../src/types/CustomQuestionTypeContract.ts | 8 +++- .../src/utils/formatControlErrors.test.ts | 23 ++++++++++ .../src/utils/formatControlErrors.ts | 11 +++++ 9 files changed, 82 insertions(+), 57 deletions(-) create mode 100644 formulus-formplayer/src/utils/formatControlErrors.test.ts create mode 100644 formulus-formplayer/src/utils/formatControlErrors.ts diff --git a/formulus-formplayer/README.md b/formulus-formplayer/README.md index 5bc107496..cc3bd826e 100644 --- a/formulus-formplayer/README.md +++ b/formulus-formplayer/README.md @@ -179,3 +179,9 @@ Forms that use a root `SwipeLayout` in `ui.json` support these `options`: | `headerTitle` | — | Optional inner title below the context bar. | Per-control focus: set `options.autoFocus: true` on a `Control` to focus that field when its page is shown (takes precedence over `autoFocusFirstInput`). + +## Validation error display + +Built-in controls and custom question types wrapped by `CustomQuestionTypeAdapter` show validation messages **once** in `QuestionShell` (error alert with icon below the field). Child widgets should use `error` / `validation.error` for red borders only — do not also render `validation.message` as `helperText` or inline copy. + +**Manual check (device):** trigger a required-field error on a shared `$ref` dropdown (e.g. GBMIS `censo_milda` → Iniciais do inquiridor), a plain string, and a custom `int` / `count_stepper` field — each should show **one** message with the exclamation icon, not duplicate text at the control. diff --git a/formulus-formplayer/src/DynamicEnumControl.tsx b/formulus-formplayer/src/DynamicEnumControl.tsx index 8aa3ecfe9..4c0ca6358 100644 --- a/formulus-formplayer/src/DynamicEnumControl.tsx +++ b/formulus-formplayer/src/DynamicEnumControl.tsx @@ -19,6 +19,7 @@ import { Alert, CircularProgress, } from '@mui/material'; +import QuestionShell from './components/QuestionShell'; /** * Interface for x-dynamicEnum configuration @@ -171,6 +172,14 @@ const DynamicEnumControl: React.FC = ({ const description = schema.description; const hasValidationErrors = errors && errors.length > 0; + const validationErrorStr = hasValidationErrors + ? Array.isArray(errors) + ? errors.filter(Boolean).join(', ') + : String(errors) + : null; + const isRequired = Boolean( + (uischema as { options?: { required?: boolean } })?.options?.required, + ); // Load choices when component mounts or params change const loadChoices = useCallback(async () => { @@ -305,27 +314,11 @@ const DynamicEnumControl: React.FC = ({ } return ( - - {/* Field Label */} - - {label} - {schema.required && *} - - - {/* Description */} - {description && ( - - {description} - - )} - - {/* Validation Errors */} - {hasValidationErrors && ( - - {Array.isArray(errors) ? errors.join(', ') : String(errors)} - - )} - {/* Control */} + {loading ? ( @@ -363,19 +356,12 @@ const DynamicEnumControl: React.FC = ({ )} /> )} - + ); }; diff --git a/formulus-formplayer/src/jsonforms/ShellInputControl.tsx b/formulus-formplayer/src/jsonforms/ShellInputControl.tsx index d4b5c68c0..b9ea46bb9 100644 --- a/formulus-formplayer/src/jsonforms/ShellInputControl.tsx +++ b/formulus-formplayer/src/jsonforms/ShellInputControl.tsx @@ -36,6 +36,11 @@ const ShellInputControl = (props: ControlProps) => { (uischema as { options?: { autoFocus?: boolean } })?.options?.autoFocus === true; + const cellProps = { + ...(props as React.ComponentProps), + }; + cellProps.errors = ''; + return ( { required={isRequired} error={errorStr}> )} + {...cellProps} label={undefined} isValid={isValid} muiInputProps={{ diff --git a/formulus-formplayer/src/renderers/AdateQuestionRenderer.tsx b/formulus-formplayer/src/renderers/AdateQuestionRenderer.tsx index 1ab73603a..d431ce46c 100644 --- a/formulus-formplayer/src/renderers/AdateQuestionRenderer.tsx +++ b/formulus-formplayer/src/renderers/AdateQuestionRenderer.tsx @@ -12,12 +12,11 @@ import { MenuItem, Box, Typography, - Alert, Button, FormControl, InputLabel, } from '@mui/material'; -import { CalendarToday, ErrorOutline } from '@mui/icons-material'; +import { CalendarToday } from '@mui/icons-material'; import QuestionShell from '../components/QuestionShell'; import { adateToStorageFormat, @@ -357,21 +356,6 @@ const AdateQuestionRenderer: React.FC = ({ )} - - {/* Validation errors – same as QuestionShell: icon, no background, text color matches icon */} - {hasError && ( - } - sx={{ - mt: 2, - backgroundColor: 'transparent', - color: 'error.main', - '& .MuiAlert-icon': { color: 'error.main' }, - }}> - {Array.isArray(errors) ? errors.join(', ') : String(errors)} - - )} ); diff --git a/formulus-formplayer/src/renderers/CustomQuestionTypeAdapter.tsx b/formulus-formplayer/src/renderers/CustomQuestionTypeAdapter.tsx index e40d57b87..30facc887 100644 --- a/formulus-formplayer/src/renderers/CustomQuestionTypeAdapter.tsx +++ b/formulus-formplayer/src/renderers/CustomQuestionTypeAdapter.tsx @@ -11,6 +11,7 @@ import { withJsonFormsControlProps, useJsonForms } from '@jsonforms/react'; import type { ControlProps } from '@jsonforms/core'; import QuestionShell from '../components/QuestionShell'; import type { CustomQuestionTypeProps } from '../types/CustomQuestionTypeContract'; +import { formatControlErrors } from '../utils/formatControlErrors'; // --------------------------------------------------------------------------- // Error Boundary — catches crashes in custom components @@ -129,11 +130,18 @@ export function createCustomQuestionTypeRenderer( // Build the simplified props for the custom component const hasErrors = errors && (Array.isArray(errors) ? errors.length > 0 : true); - const errorMessage = hasErrors - ? Array.isArray(errors) - ? errors.map((e: any) => e.message || String(e)).join(', ') - : String(errors) - : ''; + const errorMessage = + formatControlErrors( + hasErrors + ? Array.isArray(errors) + ? errors.map((e: { message?: string } | string) => + typeof e === 'object' && e && 'message' in e && e.message + ? String(e.message) + : String(e), + ) + : errors + : null, + ) ?? ''; // Extract all schema properties (except reserved ones) as config // This allows parameters alongside "format" to be passed to the renderer @@ -233,7 +241,7 @@ export function createCustomQuestionTypeRenderer( title={label} description={description} required={required} - error={errors}> + error={errorMessage || null}> diff --git a/formulus-formplayer/src/theme/material-wrappers.tsx b/formulus-formplayer/src/theme/material-wrappers.tsx index 3971566c8..6b4d8a5b2 100644 --- a/formulus-formplayer/src/theme/material-wrappers.tsx +++ b/formulus-formplayer/src/theme/material-wrappers.tsx @@ -28,7 +28,6 @@ import { useTheme, FormControl, Select, - FormHelperText, Radio, Checkbox, FormControlLabel, @@ -243,7 +242,6 @@ const SelectOneOfEnumControl = (props: ControlProps & OwnPropsOfEnum) => { ))} - {hasError ? {errors} : null} ); diff --git a/formulus-formplayer/src/types/CustomQuestionTypeContract.ts b/formulus-formplayer/src/types/CustomQuestionTypeContract.ts index 410c1e596..dc4e2be25 100644 --- a/formulus-formplayer/src/types/CustomQuestionTypeContract.ts +++ b/formulus-formplayer/src/types/CustomQuestionTypeContract.ts @@ -31,9 +31,13 @@ export interface CustomQuestionTypeProps { /** Current validation state for this field */ validation: { - /** Whether the field currently has a validation error */ + /** Whether the field currently has a validation error (use for red border / `error` prop only) */ error: boolean; - /** The validation error message (empty string if no error) */ + /** + * The validation error message from JSON Forms. **Do not render this as visible + * text** in custom question types — `CustomQuestionTypeAdapter` shows it in + * `QuestionShell` below the control. + */ message: string; }; diff --git a/formulus-formplayer/src/utils/formatControlErrors.test.ts b/formulus-formplayer/src/utils/formatControlErrors.test.ts new file mode 100644 index 000000000..65513aa21 --- /dev/null +++ b/formulus-formplayer/src/utils/formatControlErrors.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { formatControlErrors } from './formatControlErrors'; + +describe('formatControlErrors', () => { + it('returns null for empty input', () => { + expect(formatControlErrors(null)).toBeNull(); + expect(formatControlErrors(undefined)).toBeNull(); + expect(formatControlErrors('')).toBeNull(); + expect(formatControlErrors([])).toBeNull(); + }); + + it('joins string arrays', () => { + expect(formatControlErrors(['Required', 'Too short'])).toBe( + 'Required, Too short', + ); + }); + + it('stringifies scalar errors', () => { + expect(formatControlErrors('must have required property')).toBe( + 'must have required property', + ); + }); +}); diff --git a/formulus-formplayer/src/utils/formatControlErrors.ts b/formulus-formplayer/src/utils/formatControlErrors.ts new file mode 100644 index 000000000..041cd918b --- /dev/null +++ b/formulus-formplayer/src/utils/formatControlErrors.ts @@ -0,0 +1,11 @@ +/** Normalize JSON Forms `errors` for QuestionShell display. */ +export function formatControlErrors( + errors: string | string[] | undefined | null, +): string | null { + if (!errors) return null; + if (Array.isArray(errors)) { + const joined = errors.filter(Boolean).join(', '); + return joined || null; + } + return String(errors) || null; +} From 682c9314088be5852b1a67f092760f3e822150ff Mon Sep 17 00:00:00 2001 From: Emil Rossing Date: Thu, 18 Jun 2026 10:07:04 +0000 Subject: [PATCH 06/46] fix(formplayer): fix validation issues on sub-observations --- formulus-formplayer/src/App.tsx | 10 +-- .../src/jsonforms/ShellInputControl.tsx | 2 +- .../renderers/CustomQuestionTypeAdapter.tsx | 4 +- .../SubObservationQuestionRenderer.tsx | 11 +++ .../src/renderers/SwipeLayoutRenderer.tsx | 31 ++++++-- .../src/theme/material-wrappers.tsx | 8 +-- .../src/types/CustomQuestionTypeContract.ts | 5 +- .../src/utils/formObservationData.ts | 50 +++++++++++++ .../src/utils/validationNavigation.test.ts | 71 +++++++++++++++++++ .../src/utils/validationNavigation.ts | 36 ++++++++++ 10 files changed, 206 insertions(+), 22 deletions(-) create mode 100644 formulus-formplayer/src/utils/validationNavigation.test.ts create mode 100644 formulus-formplayer/src/utils/validationNavigation.ts diff --git a/formulus-formplayer/src/App.tsx b/formulus-formplayer/src/App.tsx index 4a6a0bfee..bbe190a0e 100644 --- a/formulus-formplayer/src/App.tsx +++ b/formulus-formplayer/src/App.tsx @@ -36,8 +36,8 @@ import FormulusClient from './services/FormulusInterface'; import { FormInitData } from './types/FormulusInterfaceDefinition'; import { applySchemaDefaultTokens, - dataMatchingSchemaRoot, initialFormDataFromParams, + prepareRootObservationData, shouldOfferDraftSelector, } from './utils/formObservationData'; import { @@ -583,7 +583,7 @@ function App() { if (savedData && Object.keys(savedData).length > 0) { console.log('Preloading saved data:', savedData); setData( - dataMatchingSchemaRoot(savedData as FormData, formSchemaTyped), + prepareRootObservationData(savedData as FormData, formSchemaTyped), ); } else if (!isSubObservationSession(initData)) { const formVersion = (formSchemaTyped as { version?: string }) @@ -604,14 +604,14 @@ function App() { ); const withSticky = applyStickyDefaults(withTokens, relevantSticky); console.log('Preloading initialization form values:', withSticky); - setData(dataMatchingSchemaRoot(withSticky, formSchemaTyped)); + setData(prepareRootObservationData(withSticky, formSchemaTyped)); } else { const defaultData = applySchemaDefaultTokens( initialFormDataFromParams(params), formSchemaTyped, ); console.log('Preloading initialization form values:', defaultData); - setData(dataMatchingSchemaRoot(defaultData, formSchemaTyped)); + setData(prepareRootObservationData(defaultData, formSchemaTyped)); } console.log('Form params (if any, beyond schemas/data):', params); @@ -973,7 +973,7 @@ function App() { return; } - const rootPayload = dataMatchingSchemaRoot(rawPayload, schema); + const rootPayload = prepareRootObservationData(rawPayload, schema); const { errors: finalizeValidatorErrors, data: payloadData } = runCustomValidatorsAndRefreshData( uischema ?? undefined, diff --git a/formulus-formplayer/src/jsonforms/ShellInputControl.tsx b/formulus-formplayer/src/jsonforms/ShellInputControl.tsx index b9ea46bb9..c8c0dbe39 100644 --- a/formulus-formplayer/src/jsonforms/ShellInputControl.tsx +++ b/formulus-formplayer/src/jsonforms/ShellInputControl.tsx @@ -38,8 +38,8 @@ const ShellInputControl = (props: ControlProps) => { const cellProps = { ...(props as React.ComponentProps), + errors: '', }; - cellProps.errors = ''; return ( handleChange(path, newValue), validation: { error: Boolean(hasErrors), - message: errorMessage, + // QuestionShell shows errorMessage; omit copy here so legacy/custom + // widgets do not duplicate text via helperText / Typography. + message: '', }, enabled: enabled ?? true, visible: visible ?? true, diff --git a/formulus-formplayer/src/renderers/SubObservationQuestionRenderer.tsx b/formulus-formplayer/src/renderers/SubObservationQuestionRenderer.tsx index fd2ecba6d..2accb85e5 100644 --- a/formulus-formplayer/src/renderers/SubObservationQuestionRenderer.tsx +++ b/formulus-formplayer/src/renderers/SubObservationQuestionRenderer.tsx @@ -231,11 +231,18 @@ const SubObservationQuestionRendererInner: React.FC = ({ (next: Record[]) => { const sorted = sortRows(next, config.orderBy as OrderBySpec); setRows(sorted); + setPrevSortedFromProps(sorted); handleChange(path, sorted); }, [config.orderBy, handleChange, path], ); + const refreshRowsFromFormData = useCallback(() => { + const sorted = sortRows(valueRows, config.orderBy as OrderBySpec); + setPrevSortedFromProps(sorted); + setRows(sorted as Record[]); + }, [valueRows, config.orderBy]); + const handleAdd = useCallback(async () => { if (!enabled || missingKeys.length || !childFormType) return; const client = FormulusClient.getInstance(); @@ -269,6 +276,7 @@ const SubObservationQuestionRendererInner: React.FC = ({ ); } finally { setBusyId(null); + window.setTimeout(() => refreshRowsFromFormData(), 0); } }, [ enabled, @@ -280,6 +288,7 @@ const SubObservationQuestionRendererInner: React.FC = ({ config, rows, pushSorted, + refreshRowsFromFormData, ]); const handleEdit = useCallback( @@ -327,6 +336,7 @@ const SubObservationQuestionRendererInner: React.FC = ({ ); } finally { setBusyId(null); + window.setTimeout(() => refreshRowsFromFormData(), 0); } }, [ @@ -338,6 +348,7 @@ const SubObservationQuestionRendererInner: React.FC = ({ config, rows, pushSorted, + refreshRowsFromFormData, ], ); diff --git a/formulus-formplayer/src/renderers/SwipeLayoutRenderer.tsx b/formulus-formplayer/src/renderers/SwipeLayoutRenderer.tsx index 1b1dd3439..89da3d832 100644 --- a/formulus-formplayer/src/renderers/SwipeLayoutRenderer.tsx +++ b/formulus-formplayer/src/renderers/SwipeLayoutRenderer.tsx @@ -41,6 +41,7 @@ import { focusFieldInContainer, focusFirstEnabledTextInput, } from '../utils/autofocusHelpers'; +import { navigateToFirstBlockingError } from '../utils/validationNavigation'; // --------------------------------------------------------------------------- // Testers @@ -421,11 +422,16 @@ const SwipeLayoutRenderer = ({ const isLastContentPage = nextVisiblePage === null && !isOnFinalizePage; + const validationErrorCount = core?.errors?.length ?? 0; + const trySubmitForm = useCallback(() => { if (!formInitData) return; + const errors = core?.errors ?? []; + if (errors.length > 0) { + navigateToFirstBlockingError(errors); + return; + } window.dispatchEvent(new CustomEvent('formShowValidation')); - const errorCount = core?.errors?.length ?? 0; - if (errorCount > 0) return; window.dispatchEvent( new CustomEvent('finalizeForm', { detail: { formInitData, data }, @@ -444,7 +450,7 @@ const SwipeLayoutRenderer = ({ if (skipFinalize && isLastContentPage) { return { onTrigger: trySubmitForm, - disabled: errorCount > 0 || !formInitData || isNavigating, + disabled: !formInitData || isNavigating, }; } if (nextVisiblePage !== null) { @@ -623,10 +629,7 @@ const SwipeLayoutRenderer = ({ skipFinalize && isLastContentPage ? { onClick: trySubmitForm, - disabled: - isNavigating || - !formInitData || - (core?.errors?.length ?? 0) > 0, + disabled: isNavigating || !formInitData, label: finalizeButtonLabelOption ?? 'Done', } : nextVisiblePage !== null @@ -656,6 +659,20 @@ const SwipeLayoutRenderer = ({ )} + {skipFinalize && + isLastContentPage && + validationErrorCount > 0 && ( + + {validationErrorCount}{' '} + {validationErrorCount === 1 ? 'field needs' : 'fields need'}{' '} + attention. Tap Done to review. + + )} + {snackbarOpen && typeof document !== 'undefined' && createPortal( diff --git a/formulus-formplayer/src/theme/material-wrappers.tsx b/formulus-formplayer/src/theme/material-wrappers.tsx index 6b4d8a5b2..05dbc2f56 100644 --- a/formulus-formplayer/src/theme/material-wrappers.tsx +++ b/formulus-formplayer/src/theme/material-wrappers.tsx @@ -351,7 +351,7 @@ const EnumArrayShellControl = ( // options.display: // single-select (enum / oneOf): "radio" | "buttons" // multi-select (array enum): "checkboxes" | "buttons" -// options.orientation: "vertical" (default) | "horizontal" | "flow" (wrap) +// options.orientation: "vertical" (default) | "horizontal" | "flow" (wrap in inline layout) // options.buttonGroup: "segmented" (default) | "separated" // // Single-select radio/buttons support tap-the-selected-option-to-clear. @@ -483,8 +483,7 @@ export const ChoiceControl = (props: AnyControlProps) => { title={label} description={description} required={required} - error={errors} - block={orientation === 'flow'}> + error={errors}> {body} ); @@ -573,8 +572,7 @@ export const MultiChoiceControl = ( title={label} description={description} required={required} - error={errors} - block={orientation === 'flow'}> + error={errors}> {body} ); diff --git a/formulus-formplayer/src/types/CustomQuestionTypeContract.ts b/formulus-formplayer/src/types/CustomQuestionTypeContract.ts index dc4e2be25..e2614ab13 100644 --- a/formulus-formplayer/src/types/CustomQuestionTypeContract.ts +++ b/formulus-formplayer/src/types/CustomQuestionTypeContract.ts @@ -34,9 +34,8 @@ export interface CustomQuestionTypeProps { /** Whether the field currently has a validation error (use for red border / `error` prop only) */ error: boolean; /** - * The validation error message from JSON Forms. **Do not render this as visible - * text** in custom question types — `CustomQuestionTypeAdapter` shows it in - * `QuestionShell` below the control. + * Always empty when rendered via `CustomQuestionTypeAdapter` — error copy is shown + * only in `QuestionShell`. Do not render this string in custom widgets. */ message: string; }; diff --git a/formulus-formplayer/src/utils/formObservationData.ts b/formulus-formplayer/src/utils/formObservationData.ts index 508473b5d..70fdb2cd0 100644 --- a/formulus-formplayer/src/utils/formObservationData.ts +++ b/formulus-formplayer/src/utils/formObservationData.ts @@ -167,3 +167,53 @@ export function dataMatchingSchemaRoot( } return out; } + +/** Coerce a single value to a JSON integer when schema expects integer / format int. */ +export function coerceSchemaIntegerValue(value: unknown): unknown { + if (value === undefined || value === null || value === '') return value; + if (typeof value === 'number' && Number.isInteger(value)) return value; + if (typeof value === 'string' && /^-?\d+$/.test(value.trim())) { + return parseInt(value.trim(), 10); + } + return value; +} + +/** + * Coerce root-level integer fields copied via params / subObservationInitValues + * so AJV `type: integer` passes even when no Control runs format:int coercion. + */ +export function coerceSchemaRootIntegers( + data: FormObservationData, + formSchema: unknown, +): FormObservationData { + const props = (formSchema as { properties?: unknown } | null)?.properties; + if (!props || typeof props !== 'object' || Array.isArray(props)) { + return { ...data }; + } + const out: FormObservationData = { ...data }; + for (const [key, prop] of Object.entries(props as Record)) { + if (!Object.prototype.hasOwnProperty.call(out, key)) continue; + const schemaProp = prop as { type?: string; format?: string }; + if (schemaProp.type === 'integer' || schemaProp.format === 'int') { + const coerced = coerceSchemaIntegerValue(out[key]); + if (coerced !== out[key]) { + out[key] = coerced; + } + } + } + return out; +} + +/** + * Align observation JSON with schema root keys, then coerce integer fields. + */ +export function prepareRootObservationData( + data: FormObservationData, + formSchema: unknown, + extraRootKeys: string[] = ['locale'], +): FormObservationData { + return coerceSchemaRootIntegers( + dataMatchingSchemaRoot(data, formSchema, extraRootKeys), + formSchema, + ); +} diff --git a/formulus-formplayer/src/utils/validationNavigation.test.ts b/formulus-formplayer/src/utils/validationNavigation.test.ts new file mode 100644 index 000000000..c3b2d5525 --- /dev/null +++ b/formulus-formplayer/src/utils/validationNavigation.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import { + coerceSchemaIntegerValue, + coerceSchemaRootIntegers, + prepareRootObservationData, +} from './formObservationData'; +import { + firstBlockingErrorInstancePath, +} from './validationNavigation'; + +describe('coerceSchemaIntegerValue', () => { + it('coerces numeric strings to integers', () => { + expect(coerceSchemaIntegerValue('5')).toBe(5); + expect(coerceSchemaIntegerValue(5)).toBe(5); + }); + + it('leaves non-integer values unchanged', () => { + expect(coerceSchemaIntegerValue('')).toBe(''); + expect(coerceSchemaIntegerValue('1.5')).toBe('1.5'); + }); +}); + +describe('coerceSchemaRootIntegers', () => { + it('coerces integer schema properties including format int', () => { + const schema = { + properties: { + quarto_num: { type: 'integer' }, + cama_num: { type: 'integer', format: 'int' }, + af: { type: 'string' }, + }, + }; + const data = { quarto_num: '2', cama_num: '3', af: 'A1' }; + expect(coerceSchemaRootIntegers(data, schema)).toEqual({ + quarto_num: 2, + cama_num: 3, + af: 'A1', + }); + }); +}); + +describe('prepareRootObservationData', () => { + it('strips non-schema keys and coerces integers', () => { + const schema = { + properties: { + quarto_num: { type: 'integer' }, + }, + }; + expect( + prepareRootObservationData( + { quarto_num: '1', theme: 'dark' }, + schema, + ), + ).toEqual({ quarto_num: 1 }); + }); +}); + +describe('firstBlockingErrorInstancePath', () => { + it('prefers instancePath from JSON Forms errors', () => { + expect( + firstBlockingErrorInstancePath([{ instancePath: '/quarto_num' }]), + ).toBe('/quarto_num'); + }); + + it('falls back to custom validator path', () => { + expect( + firstBlockingErrorInstancePath([ + { path: '#/properties/validar_cama' }, + ]), + ).toBe('#/properties/validar_cama'); + }); +}); diff --git a/formulus-formplayer/src/utils/validationNavigation.ts b/formulus-formplayer/src/utils/validationNavigation.ts new file mode 100644 index 000000000..cc482e477 --- /dev/null +++ b/formulus-formplayer/src/utils/validationNavigation.ts @@ -0,0 +1,36 @@ +/** + * Helpers for surfacing validation errors on SwipeLayout Done / Finalize actions. + */ + +export type BlockingValidationError = { + instancePath?: string; + schemaPath?: string; + path?: string; +}; + +export function firstBlockingErrorInstancePath( + errors: ReadonlyArray, +): string | null { + const first = errors[0]; + if (!first) return null; + if (first.instancePath) return first.instancePath; + if (typeof first.path === 'string' && first.path.length > 0) { + return first.path; + } + return null; +} + +/** Switch to ValidateAndShow and jump to the first blocking field when possible. */ +export function navigateToFirstBlockingError( + errors: ReadonlyArray, +): void { + window.dispatchEvent(new CustomEvent('formShowValidation')); + const instancePath = firstBlockingErrorInstancePath(errors); + if (instancePath) { + window.dispatchEvent( + new CustomEvent('navigateToError', { + detail: { path: instancePath }, + }), + ); + } +} From 2d823d15623ef40d8ed99ab6c851cf8c13c270d2 Mon Sep 17 00:00:00 2001 From: Emil Rossing Date: Thu, 18 Jun 2026 10:13:56 +0000 Subject: [PATCH 07/46] fix(desktop): automatically clear staging area after import --- desktop/src/pages/ImportPage.tsx | 3 +++ desktop/src/store/useImportStagingStore.ts | 7 ++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/desktop/src/pages/ImportPage.tsx b/desktop/src/pages/ImportPage.tsx index 84e9e6b5a..e5cd30063 100644 --- a/desktop/src/pages/ImportPage.tsx +++ b/desktop/src/pages/ImportPage.tsx @@ -183,6 +183,7 @@ export function ImportPage() { const removeStagedAttachment = useImportStagingStore( s => s.removeStagedAttachment, ); + const clearStagedFiles = useImportStagingStore(s => s.clearStagedFiles); const clearStagingLists = useImportStagingStore(s => s.clearStagingLists); const setMessage = useImportStagingStore(s => s.setMessage); const setError = useImportStagingStore(s => s.setError); @@ -472,6 +473,7 @@ export function ImportPage() { ? ` Copied ${attachmentsCopied}/${copyItems.length} referenced attachment(s) to queue.${copyErrors.length ? ` Errors: ${copyErrors.slice(0, 5).join('; ')}${copyErrors.length > 5 ? '…' : ''}` : ''}` : ''; setMessage(`${baseMsg}${attMsg}`); + clearStagedFiles(); setPreviewReport(null); } catch (e) { setError(messageFromUnknown(e, 'Import failed')); @@ -485,6 +487,7 @@ export function ImportPage() { setMessage, setError, setImportActivity, + clearStagedFiles, loadObservations, loadHealth, ]); diff --git a/desktop/src/store/useImportStagingStore.ts b/desktop/src/store/useImportStagingStore.ts index 8f35a9fc5..f6323e5bd 100644 --- a/desktop/src/store/useImportStagingStore.ts +++ b/desktop/src/store/useImportStagingStore.ts @@ -20,7 +20,9 @@ interface ImportStagingState { addScanEntries: (entries: ImportStagingScanEntry[]) => void; removeStagedJson: (nativePath: string) => void; removeStagedAttachment: (nativePath: string) => void; - /** Clears staged JSON + attachment lists. */ + /** Clears staged JSON + attachment lists (keeps message/error). */ + clearStagedFiles: () => void; + /** Clears staged files and resets message/error/activity. */ clearStagingLists: () => void; setMessage: (m: string | null) => void; setError: (e: string | null) => void; @@ -96,6 +98,9 @@ export const useImportStagingStore = create(set => ({ ), })), + clearStagedFiles: () => + set({ stagedJson: [], stagedAttachments: [] }), + clearStagingLists: () => set(emptyStagingState()), setMessage: m => set({ message: m }), From 14dd60028ff5ecfef4f5b9913bf6609eb4e01277 Mon Sep 17 00:00:00 2001 From: Emil Rossing Date: Thu, 18 Jun 2026 10:14:15 +0000 Subject: [PATCH 08/46] chore: formatting --- .../src/renderers/SwipeLayoutRenderer.tsx | 24 +++++++++---------- .../src/utils/validationNavigation.test.ts | 13 +++------- 2 files changed, 14 insertions(+), 23 deletions(-) diff --git a/formulus-formplayer/src/renderers/SwipeLayoutRenderer.tsx b/formulus-formplayer/src/renderers/SwipeLayoutRenderer.tsx index 89da3d832..7c35ea742 100644 --- a/formulus-formplayer/src/renderers/SwipeLayoutRenderer.tsx +++ b/formulus-formplayer/src/renderers/SwipeLayoutRenderer.tsx @@ -659,19 +659,17 @@ const SwipeLayoutRenderer = ({ )} - {skipFinalize && - isLastContentPage && - validationErrorCount > 0 && ( - - {validationErrorCount}{' '} - {validationErrorCount === 1 ? 'field needs' : 'fields need'}{' '} - attention. Tap Done to review. - - )} + {skipFinalize && isLastContentPage && validationErrorCount > 0 && ( + + {validationErrorCount}{' '} + {validationErrorCount === 1 ? 'field needs' : 'fields need'}{' '} + attention. Tap Done to review. + + )} {snackbarOpen && typeof document !== 'undefined' && diff --git a/formulus-formplayer/src/utils/validationNavigation.test.ts b/formulus-formplayer/src/utils/validationNavigation.test.ts index c3b2d5525..1123decbb 100644 --- a/formulus-formplayer/src/utils/validationNavigation.test.ts +++ b/formulus-formplayer/src/utils/validationNavigation.test.ts @@ -4,9 +4,7 @@ import { coerceSchemaRootIntegers, prepareRootObservationData, } from './formObservationData'; -import { - firstBlockingErrorInstancePath, -} from './validationNavigation'; +import { firstBlockingErrorInstancePath } from './validationNavigation'; describe('coerceSchemaIntegerValue', () => { it('coerces numeric strings to integers', () => { @@ -46,10 +44,7 @@ describe('prepareRootObservationData', () => { }, }; expect( - prepareRootObservationData( - { quarto_num: '1', theme: 'dark' }, - schema, - ), + prepareRootObservationData({ quarto_num: '1', theme: 'dark' }, schema), ).toEqual({ quarto_num: 1 }); }); }); @@ -63,9 +58,7 @@ describe('firstBlockingErrorInstancePath', () => { it('falls back to custom validator path', () => { expect( - firstBlockingErrorInstancePath([ - { path: '#/properties/validar_cama' }, - ]), + firstBlockingErrorInstancePath([{ path: '#/properties/validar_cama' }]), ).toBe('#/properties/validar_cama'); }); }); From 43ecf5b62b3f7394420e5549133c468ecd3b6bfb Mon Sep 17 00:00:00 2001 From: Emil Rossing Date: Thu, 18 Jun 2026 10:19:47 +0000 Subject: [PATCH 09/46] chore: formatting --- desktop/src/store/useImportStagingStore.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/desktop/src/store/useImportStagingStore.ts b/desktop/src/store/useImportStagingStore.ts index f6323e5bd..ed2951cff 100644 --- a/desktop/src/store/useImportStagingStore.ts +++ b/desktop/src/store/useImportStagingStore.ts @@ -98,8 +98,7 @@ export const useImportStagingStore = create(set => ({ ), })), - clearStagedFiles: () => - set({ stagedJson: [], stagedAttachments: [] }), + clearStagedFiles: () => set({ stagedJson: [], stagedAttachments: [] }), clearStagingLists: () => set(emptyStagingState()), From 6865316789f727f58e2d2c4a5752d34fc69648c3 Mon Sep 17 00:00:00 2001 From: Emil Rossing Date: Thu, 18 Jun 2026 12:22:06 +0000 Subject: [PATCH 10/46] chore: formatting --- packages/tokens/dist/css/tokens.css | 2 +- packages/tokens/dist/js/tokens.d.ts | 2 +- packages/tokens/dist/js/tokens.js | 2 +- packages/tokens/dist/react-native/tokens.d.ts | 2 +- packages/tokens/dist/react-native/tokens.js | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/tokens/dist/css/tokens.css b/packages/tokens/dist/css/tokens.css index 0c64d16f0..3bc7eb40b 100644 --- a/packages/tokens/dist/css/tokens.css +++ b/packages/tokens/dist/css/tokens.css @@ -4,7 +4,7 @@ * This file is auto-generated by Style Dictionary. * Do not edit directly. Edit source files in src/tokens/ instead. * - * Generated: 2026-05-18T02:14:18.499Z + * Generated: 2026-06-18T10:31:25.525Z */ :root { diff --git a/packages/tokens/dist/js/tokens.d.ts b/packages/tokens/dist/js/tokens.d.ts index d618b9de4..0f4c29851 100644 --- a/packages/tokens/dist/js/tokens.d.ts +++ b/packages/tokens/dist/js/tokens.d.ts @@ -1,6 +1,6 @@ /** * Do not edit directly - * Generated on Mon, 18 May 2026 02:14:18 GMT + * Generated on Thu, 18 Jun 2026 10:31:25 GMT */ export const ContrastAaLarge : string; diff --git a/packages/tokens/dist/js/tokens.js b/packages/tokens/dist/js/tokens.js index 1c564e62c..751cf3659 100644 --- a/packages/tokens/dist/js/tokens.js +++ b/packages/tokens/dist/js/tokens.js @@ -1,6 +1,6 @@ /** * Do not edit directly - * Generated on Mon, 18 May 2026 02:14:18 GMT + * Generated on Thu, 18 Jun 2026 10:31:25 GMT */ export const ContrastAaLarge = "3:1"; diff --git a/packages/tokens/dist/react-native/tokens.d.ts b/packages/tokens/dist/react-native/tokens.d.ts index e9109fdbd..026c54ad6 100644 --- a/packages/tokens/dist/react-native/tokens.d.ts +++ b/packages/tokens/dist/react-native/tokens.d.ts @@ -1,6 +1,6 @@ /** * Do not edit directly - * Generated on Mon, 18 May 2026 02:14:18 GMT + * Generated on Thu, 18 Jun 2026 10:31:25 GMT */ export const contrastAaLarge : string; diff --git a/packages/tokens/dist/react-native/tokens.js b/packages/tokens/dist/react-native/tokens.js index 932f2ff13..39fb0b14c 100644 --- a/packages/tokens/dist/react-native/tokens.js +++ b/packages/tokens/dist/react-native/tokens.js @@ -1,6 +1,6 @@ /** * Do not edit directly - * Generated on Mon, 18 May 2026 02:14:18 GMT + * Generated on Thu, 18 Jun 2026 10:31:25 GMT */ module.exports = { From 76e7e9fb0ddb2b824894f298c51d1fe0eb41e741 Mon Sep 17 00:00:00 2001 From: Emil Rossing Date: Thu, 18 Jun 2026 12:34:25 +0000 Subject: [PATCH 11/46] fix(formulus): UX updates --- formulus-formplayer/README.md | 24 ++ formulus-formplayer/package.json | 1 + formulus-formplayer/pnpm-lock.yaml | 286 +++++++++++++++++- formulus-formplayer/src/App.tsx | 5 + .../src/components/FormLayout.tsx | 22 +- .../src/components/FormProgressBar.test.tsx | 66 ++++ .../src/components/FormProgressBar.tsx | 23 +- .../src/context/FormDensityContext.tsx | 3 + ...seKeyboardScrollClamp.integration.test.tsx | 42 +++ .../src/hooks/useKeyboardScrollClamp.test.ts | 5 +- .../src/hooks/useKeyboardScrollClamp.ts | 110 +++++-- .../src/renderers/FlatGroupLayout.test.tsx | 23 ++ .../src/renderers/FlatGroupLayout.tsx | 112 +++++++ .../src/renderers/SwipeLayoutRenderer.tsx | 37 +-- formulus-formplayer/src/setupTests.ts | 6 +- formulus-formplayer/src/theme/theme.ts | 18 ++ .../src/utils/keyboardScroll.test.ts | 125 ++++++++ .../src/utils/keyboardScroll.ts | 96 ++++++ formulus-formplayer/vite.config.ts | 1 + 19 files changed, 946 insertions(+), 59 deletions(-) create mode 100644 formulus-formplayer/src/components/FormProgressBar.test.tsx create mode 100644 formulus-formplayer/src/hooks/useKeyboardScrollClamp.integration.test.tsx create mode 100644 formulus-formplayer/src/renderers/FlatGroupLayout.test.tsx create mode 100644 formulus-formplayer/src/renderers/FlatGroupLayout.tsx create mode 100644 formulus-formplayer/src/utils/keyboardScroll.test.ts create mode 100644 formulus-formplayer/src/utils/keyboardScroll.ts diff --git a/formulus-formplayer/README.md b/formulus-formplayer/README.md index cc3bd826e..ca09fb679 100644 --- a/formulus-formplayer/README.md +++ b/formulus-formplayer/README.md @@ -180,6 +180,30 @@ Forms that use a root `SwipeLayout` in `ui.json` support these `options`: Per-control focus: set `options.autoFocus: true` on a `Control` to focus that field when its page is shown (takes precedence over `autoFocusFirstInput`). +### Header progress chevrons + +On multi-page SwipeLayout forms, **Previous** / **Next** chevron buttons flank the progress bar (disabled on first/last page). They use the same navigation callbacks as the bottom bar. + +### Group layout inside SwipeLayout + +`Group` pages inside SwipeLayout render **flat** (no card panel) by default. Opt back into a card: `"options": { "variant": "card" }` on the Group. + +Swipe gestures attach to the full scroll area (not only the question panel). + +### Manual device regression matrix + +After formplayer changes, verify on a phone WebView (or ODE Desktop form preview with keyboard): + +| Scenario | Pass criteria | +| -------- | ------------- | +| Multi-page form header | Both chevrons visible; disabled at first/last page | +| Group page (e.g. GBMIS Sticker / amostra) | Full-width layout; swipe works on background below questions | +| GBMIS `censo_milda_pessoa` → Anos | No white scroll gap when typing digits | +| GBMIS `censo` → IME Next | No gap when moving field-to-field via keyboard Next | +| `age_years` > 120 | Single validation error via QuestionShell | + +Rebuild formplayer (`pnpm run build:copy`) before testing in Formulus or Desktop developer mode. + ## Validation error display Built-in controls and custom question types wrapped by `CustomQuestionTypeAdapter` show validation messages **once** in `QuestionShell` (error alert with icon below the field). Child widgets should use `error` / `validation.error` for red borders only — do not also render `validation.message` as `helperText` or inline copy. diff --git a/formulus-formplayer/package.json b/formulus-formplayer/package.json index 6a15f0cac..5825656c8 100644 --- a/formulus-formplayer/package.json +++ b/formulus-formplayer/package.json @@ -66,6 +66,7 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-storybook": "10.2.19", "globals": "^17.1.0", + "jsdom": "^26.1.0", "prettier": "3.8.1", "react-refresh": "^0.18.0", "storybook": "^10.2.19", diff --git a/formulus-formplayer/pnpm-lock.yaml b/formulus-formplayer/pnpm-lock.yaml index 890493449..92889e372 100644 --- a/formulus-formplayer/pnpm-lock.yaml +++ b/formulus-formplayer/pnpm-lock.yaml @@ -129,6 +129,9 @@ importers: globals: specifier: ^17.1.0 version: 17.6.0 + jsdom: + specifier: ^26.1.0 + version: 26.1.0 prettier: specifier: 3.8.1 version: 3.8.1 @@ -146,13 +149,16 @@ importers: version: 6.4.2(@types/node@24.12.4)(terser@5.47.1)(yaml@2.9.0) vitest: specifier: ^3.2.4 - version: 3.2.4(@types/node@24.12.4)(terser@5.47.1)(yaml@2.9.0) + version: 3.2.4(@types/node@24.12.4)(jsdom@26.1.0)(terser@5.47.1)(yaml@2.9.0) packages: '@adobe/css-tools@4.4.4': resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} @@ -240,6 +246,34 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + '@date-io/core@3.2.0': resolution: {integrity: sha512-hqwXvY8/YBsT9RwQITG868ZNb1MVFFkF7W1Ecv4P472j/ZWa7EFcgSmxy8PUElNVZfvhdvfv+a8j6NWJqOX5mA==} @@ -1974,9 +2008,17 @@ packages: css.escape@1.5.1: resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + data-view-buffer@1.0.2: resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} engines: {node: '>= 0.4'} @@ -2009,6 +2051,9 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + deep-eql@5.0.2: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} @@ -2107,6 +2152,10 @@ packages: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} @@ -2473,14 +2522,26 @@ packages: hoist-non-react-statics@3.3.2: resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + https-proxy-agent@7.0.6: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -2602,6 +2663,9 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -2709,6 +2773,15 @@ packages: jsc-safe-url@0.2.4: resolution: {integrity: sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q==} + jsdom@26.1.0: + resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -2775,6 +2848,9 @@ packages: loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.3.6: resolution: {integrity: sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==} engines: {node: 20 || >=22} @@ -2940,6 +3016,9 @@ packages: nullthrows@1.1.1: resolution: {integrity: sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==} + nwsapi@2.2.24: + resolution: {integrity: sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==} + ob1@0.84.4: resolution: {integrity: sha512-eJXMpz4aQHXF/YBB9ddqZDIS+ooO91hObo9FoW/xBkr54/zCwYYCDqT/O54vNo8kOkWs5Ou/y28NgdrV0edQNA==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} @@ -3019,6 +3098,9 @@ packages: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -3235,6 +3317,9 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + run-applescript@7.1.0: resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} engines: {node: '>=18'} @@ -3251,6 +3336,13 @@ packages: resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} engines: {node: '>= 0.4'} + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -3445,6 +3537,9 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + synckit@0.11.12: resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==} engines: {node: ^14.18.0 || >=16.0.0} @@ -3482,6 +3577,13 @@ packages: resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} engines: {node: '>=14.0.0'} + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + tmpl@1.0.5: resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} @@ -3493,6 +3595,14 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + ts-api-utils@2.5.0: resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} @@ -3655,18 +3765,39 @@ packages: vlq@1.0.1: resolution: {integrity: sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==} + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + walker@1.0.8: resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} web-vitals@5.2.0: resolution: {integrity: sha512-i2z98bEmaCqSDiHEDu+gHl/dmR4Q+TxFmG3/13KkMO+o8UxQzCqWaDRCiLgEa41nlO4VpXSI0ASa1xWmO9sBlA==} + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + webpack-virtual-modules@0.6.2: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + whatwg-fetch@3.6.20: resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==} + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -3729,6 +3860,13 @@ packages: resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} engines: {node: '>=18'} + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -3770,6 +3908,14 @@ snapshots: '@adobe/css-tools@4.4.4': {} + '@asamuzakjp/css-color@3.2.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 + '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -3884,6 +4030,26 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-tokenizer@3.0.4': {} + '@date-io/core@3.2.0': {} '@date-io/dayjs@3.2.0(dayjs@1.10.7)': @@ -4456,7 +4622,9 @@ snapshots: react-native: 0.85.3(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.6) react-native-svg: 15.15.5(react-native@0.85.3(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) - '@ode/observation-query@file:../packages/observation-query': {} + '@ode/observation-query@file:../packages/observation-query': + dependencies: + '@babel/runtime': 7.29.2 '@ode/tokens@file:../packages/tokens': {} @@ -5436,8 +5604,18 @@ snapshots: css.escape@1.5.1: {} + cssstyle@4.6.0: + dependencies: + '@asamuzakjp/css-color': 3.2.0 + rrweb-cssom: 0.8.0 + csstype@3.2.3: {} + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + data-view-buffer@1.0.2: dependencies: call-bound: 1.0.4 @@ -5466,6 +5644,8 @@ snapshots: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + deep-eql@5.0.2: {} deep-is@0.1.4: {} @@ -5552,6 +5732,8 @@ snapshots: entities@4.5.0: {} + entities@6.0.1: {} + error-ex@1.3.4: dependencies: is-arrayish: 0.2.1 @@ -6054,6 +6236,10 @@ snapshots: dependencies: react-is: 16.13.1 + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -6062,6 +6248,13 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 @@ -6069,6 +6262,10 @@ snapshots: transitivePeerDependencies: - supports-color + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + ignore@5.3.2: {} ignore@7.0.5: {} @@ -6179,6 +6376,8 @@ snapshots: is-number@7.0.0: {} + is-potential-custom-element-name@1.0.1: {} + is-regex@1.2.1: dependencies: call-bound: 1.0.4 @@ -6320,6 +6519,33 @@ snapshots: jsc-safe-url@0.2.4: {} + jsdom@26.1.0: + dependencies: + cssstyle: 4.6.0 + data-urls: 5.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.24 + parse5: 7.3.0 + rrweb-cssom: 0.8.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.1.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.20.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -6377,6 +6603,8 @@ snapshots: loupe@3.2.1: {} + lru-cache@10.4.3: {} + lru-cache@11.3.6: {} lru-cache@5.1.1: @@ -6633,6 +6861,8 @@ snapshots: nullthrows@1.1.1: {} + nwsapi@2.2.24: {} + ob1@0.84.4: dependencies: flow-enums-runtime: 0.0.6 @@ -6778,6 +7008,10 @@ snapshots: json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 + parse5@7.3.0: + dependencies: + entities: 6.0.1 + parseurl@1.3.3: {} path-exists@4.0.0: {} @@ -7059,6 +7293,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.60.4 fsevents: 2.3.3 + rrweb-cssom@0.8.0: {} + run-applescript@7.1.0: {} safe-array-concat@1.1.4: @@ -7080,6 +7316,12 @@ snapshots: es-errors: 1.3.0 is-regex: 1.2.1 + safer-buffer@2.1.2: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.27.0: {} semver@6.3.1: {} @@ -7322,6 +7564,8 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + symbol-tree@3.2.4: {} + synckit@0.11.12: dependencies: '@pkgr/core': 0.2.9 @@ -7352,6 +7596,12 @@ snapshots: tinyspy@4.0.4: {} + tldts-core@6.1.86: {} + + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + tmpl@1.0.5: {} to-regex-range@5.0.1: @@ -7360,6 +7610,14 @@ snapshots: toidentifier@1.0.1: {} + tough-cookie@5.1.2: + dependencies: + tldts: 6.1.86 + + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + ts-api-utils@2.5.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -7495,7 +7753,7 @@ snapshots: terser: 5.47.1 yaml: 2.9.0 - vitest@3.2.4(@types/node@24.12.4)(terser@5.47.1)(yaml@2.9.0): + vitest@3.2.4(@types/node@24.12.4)(jsdom@26.1.0)(terser@5.47.1)(yaml@2.9.0): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 @@ -7522,6 +7780,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.12.4 + jsdom: 26.1.0 transitivePeerDependencies: - jiti - less @@ -7538,16 +7797,33 @@ snapshots: vlq@1.0.1: {} + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + walker@1.0.8: dependencies: makeerror: 1.0.12 web-vitals@5.2.0: {} + webidl-conversions@7.0.0: {} + webpack-virtual-modules@0.6.2: {} + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + whatwg-fetch@3.6.20: {} + whatwg-mimetype@4.0.0: {} + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 @@ -7614,6 +7890,10 @@ snapshots: dependencies: is-wsl: 3.1.1 + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + y18n@5.0.8: {} yallist@3.1.1: {} diff --git a/formulus-formplayer/src/App.tsx b/formulus-formplayer/src/App.tsx index bbe190a0e..a907dc796 100644 --- a/formulus-formplayer/src/App.tsx +++ b/formulus-formplayer/src/App.tsx @@ -51,6 +51,10 @@ import SwipeLayoutRenderer, { swipeLayoutTester, groupAsSwipeLayoutTester, } from './renderers/SwipeLayoutRenderer'; +import { + FlatGroupLayout, + flatGroupLayoutTester, +} from './renderers/FlatGroupLayout'; import { finalizeRenderer, finalizeTester } from './renderers/FinalizeRenderer'; import PhotoQuestionRenderer, { photoQuestionTester, @@ -265,6 +269,7 @@ export const customRenderers = [ tester: shellInputControlTester, renderer: ShellInputControl, }, + { tester: flatGroupLayoutTester, renderer: FlatGroupLayout }, { tester: swipeLayoutTester, renderer: SwipeLayoutRenderer }, { tester: groupAsSwipeLayoutTester, renderer: SwipeLayoutRenderer }, { tester: finalizeTester, renderer: finalizeRenderer.renderer }, diff --git a/formulus-formplayer/src/components/FormLayout.tsx b/formulus-formplayer/src/components/FormLayout.tsx index e93fbfe08..1c0d18f78 100644 --- a/formulus-formplayer/src/components/FormLayout.tsx +++ b/formulus-formplayer/src/components/FormLayout.tsx @@ -66,6 +66,15 @@ interface FormLayoutProps { onTrigger: () => void; disabled?: boolean; }; + + /** + * Optional ref callback merged with the internal keyboard scroll ref + * (e.g. react-swipeable on the scroll area). + */ + scrollRefMerge?: (el: HTMLDivElement | null) => void; + + /** Extra props spread onto the scroll area (e.g. swipe gesture handlers). */ + scrollHandlers?: Record; } /** @@ -92,9 +101,19 @@ const FormLayout: React.FC = ({ showNavigation = true, keyboardSubmitAction, contentBottomPadding = 0, + scrollRefMerge, + scrollHandlers, }) => { const scrollRef = useKeyboardScrollClamp(); + const mergedScrollRef = useCallback( + (el: HTMLDivElement | null) => { + scrollRef.current = el; + scrollRefMerge?.(el); + }, + [scrollRef, scrollRefMerge], + ); + const handleFormSubmit = useCallback( (event: React.FormEvent) => { event.preventDefault(); @@ -106,8 +125,9 @@ const FormLayout: React.FC = ({ const scrollArea = ( ({ flex: 1, minHeight: 0, diff --git a/formulus-formplayer/src/components/FormProgressBar.test.tsx b/formulus-formplayer/src/components/FormProgressBar.test.tsx new file mode 100644 index 000000000..bb7c7461c --- /dev/null +++ b/formulus-formplayer/src/components/FormProgressBar.test.tsx @@ -0,0 +1,66 @@ +// @vitest-environment jsdom +import React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import { ThemeProvider, createTheme } from '@mui/material/styles'; +import FormProgressBar from './FormProgressBar'; + +const theme = createTheme(); + +afterEach(() => cleanup()); + +const renderBar = (props: Partial> = {}) => + render( + + + , + ); + +describe('FormProgressBar header navigation', () => { + it('shows both chevrons on multi-page forms', () => { + renderBar(); + expect(screen.getByLabelText('Previous screen')).toBeInTheDocument(); + expect(screen.getByLabelText('Next screen')).toBeInTheDocument(); + }); + + it('disables previous chevron on first page', () => { + renderBar({ currentPage: 0, canNavigatePrevious: false, canNavigateNext: true }); + expect(screen.getByLabelText('Previous screen')).toBeDisabled(); + expect(screen.getByLabelText('Next screen')).not.toBeDisabled(); + }); + + it('disables next chevron on last page', () => { + renderBar({ currentPage: 2, canNavigatePrevious: true, canNavigateNext: false }); + expect(screen.getByLabelText('Previous screen')).not.toBeDisabled(); + expect(screen.getByLabelText('Next screen')).toBeDisabled(); + }); + + it('hides chevrons when only one screen', () => { + renderBar({ totalScreens: 1 }); + expect(screen.queryByLabelText('Previous screen')).toBeNull(); + expect(screen.queryByLabelText('Next screen')).toBeNull(); + }); + + it('calls navigation callbacks when enabled', () => { + const onNavigatePrevious = vi.fn(); + const onNavigateNext = vi.fn(); + renderBar({ + currentPage: 1, + canNavigatePrevious: true, + canNavigateNext: true, + onNavigatePrevious, + onNavigateNext, + }); + screen.getByLabelText('Previous screen').click(); + screen.getByLabelText('Next screen').click(); + expect(onNavigatePrevious).toHaveBeenCalledTimes(1); + expect(onNavigateNext).toHaveBeenCalledTimes(1); + }); +}); diff --git a/formulus-formplayer/src/components/FormProgressBar.tsx b/formulus-formplayer/src/components/FormProgressBar.tsx index 51fa0f35b..7de79d3b4 100644 --- a/formulus-formplayer/src/components/FormProgressBar.tsx +++ b/formulus-formplayer/src/components/FormProgressBar.tsx @@ -47,6 +47,10 @@ interface FormProgressBarProps { */ onNavigatePrevious?: () => void; onNavigateNext?: () => void; + /** When false, previous chevron is shown but disabled (multi-page forms). */ + canNavigatePrevious?: boolean; + /** When false, next chevron is shown but disabled (multi-page forms). */ + canNavigateNext?: boolean; /** When true, both header chevrons are disabled (e.g. navigation in flight). */ navigationDisabled?: boolean; } @@ -130,6 +134,7 @@ const countAnsweredQuestions = ( const navIconButtonSx = { flexShrink: 0, p: 0.25, + minWidth: 32, color: 'text.secondary', '&.Mui-disabled': { opacity: 0.35 }, } as const; @@ -143,6 +148,8 @@ const FormProgressBar: React.FC = ({ isOnFinalizePage = false, onNavigatePrevious, onNavigateNext, + canNavigatePrevious = false, + canNavigateNext = false, navigationDisabled = false, }) => { const progress = useMemo(() => { @@ -184,20 +191,22 @@ const FormProgressBar: React.FC = ({ }, [currentPage, totalScreens, data, schema, mode, isOnFinalizePage]); const handlePrev = useCallback(() => { - if (navigationDisabled || !onNavigatePrevious) return; + if (navigationDisabled || !canNavigatePrevious || !onNavigatePrevious) return; onNavigatePrevious(); - }, [navigationDisabled, onNavigatePrevious]); + }, [navigationDisabled, canNavigatePrevious, onNavigatePrevious]); const handleNext = useCallback(() => { - if (navigationDisabled || !onNavigateNext) return; + if (navigationDisabled || !canNavigateNext || !onNavigateNext) return; onNavigateNext(); - }, [navigationDisabled, onNavigateNext]); + }, [navigationDisabled, canNavigateNext, onNavigateNext]); if (totalScreens === 0) { return null; } - const showHeaderNav = onNavigatePrevious != null || onNavigateNext != null; + const showHeaderNav = totalScreens > 1; + const prevDisabled = navigationDisabled || !canNavigatePrevious; + const nextDisabled = navigationDisabled || !canNavigateNext; return ( = ({ size="small" aria-label="Previous screen" onClick={handlePrev} - disabled={navigationDisabled || !onNavigatePrevious} + disabled={prevDisabled} edge="start" sx={navIconButtonSx}> @@ -264,7 +273,7 @@ const FormProgressBar: React.FC = ({ size="small" aria-label="Next screen" onClick={handleNext} - disabled={navigationDisabled || !onNavigateNext} + disabled={nextDisabled} edge="end" sx={navIconButtonSx}> diff --git a/formulus-formplayer/src/context/FormDensityContext.tsx b/formulus-formplayer/src/context/FormDensityContext.tsx index 6dca609c1..d13225af9 100644 --- a/formulus-formplayer/src/context/FormDensityContext.tsx +++ b/formulus-formplayer/src/context/FormDensityContext.tsx @@ -1,13 +1,16 @@ import { createContext, useContext } from 'react'; export type LabelLayout = 'inline' | 'stacked'; +export type GroupVariant = 'flat' | 'card'; export interface FormDensityContextValue { labelLayout: LabelLayout; + groupVariant: GroupVariant; } export const FormDensityContext = createContext({ labelLayout: 'stacked', + groupVariant: 'card', }); export const useFormDensity = () => useContext(FormDensityContext); diff --git a/formulus-formplayer/src/hooks/useKeyboardScrollClamp.integration.test.tsx b/formulus-formplayer/src/hooks/useKeyboardScrollClamp.integration.test.tsx new file mode 100644 index 000000000..def9d1f90 --- /dev/null +++ b/formulus-formplayer/src/hooks/useKeyboardScrollClamp.integration.test.tsx @@ -0,0 +1,42 @@ +// @vitest-environment jsdom +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; +import FormLayout from '../components/FormLayout'; + +afterEach(() => cleanup()); + +describe('FormLayout keyboard scroll integration', () => { + it('clamps scrollTop after input without exceeding max scroll', async () => { + const { container } = render( + +
+ +
+
, + ); + + const scrollArea = screen.getByTestId('formplayer-scroll-area'); + Object.defineProperty(scrollArea, 'scrollHeight', { + configurable: true, + value: 1200, + }); + Object.defineProperty(scrollArea, 'clientHeight', { + configurable: true, + value: 400, + }); + scrollArea.scrollTop = 900; + + const input = screen.getByTestId('field'); + fireEvent.focusIn(input, { bubbles: true }); + fireEvent.input(input, { target: { value: 'x' }, bubbles: true }); + + await new Promise(resolve => setTimeout(resolve, 50)); + + const max = Math.max( + 0, + scrollArea.scrollHeight - scrollArea.clientHeight, + ); + expect(scrollArea.scrollTop).toBeLessThanOrEqual(max); + expect(container).toBeTruthy(); + }); +}); diff --git a/formulus-formplayer/src/hooks/useKeyboardScrollClamp.test.ts b/formulus-formplayer/src/hooks/useKeyboardScrollClamp.test.ts index 7e0759200..36dd1c415 100644 --- a/formulus-formplayer/src/hooks/useKeyboardScrollClamp.test.ts +++ b/formulus-formplayer/src/hooks/useKeyboardScrollClamp.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest'; -import { clampScrollTop, isClampableInputType } from './useKeyboardScrollClamp'; +import { + clampScrollTop, + isClampableInputType, +} from './useKeyboardScrollClamp'; describe('clampScrollTop', () => { it('does not change scrollTop when within range', () => { diff --git a/formulus-formplayer/src/hooks/useKeyboardScrollClamp.ts b/formulus-formplayer/src/hooks/useKeyboardScrollClamp.ts index 7cd19f98c..3858b2d28 100644 --- a/formulus-formplayer/src/hooks/useKeyboardScrollClamp.ts +++ b/formulus-formplayer/src/hooks/useKeyboardScrollClamp.ts @@ -1,4 +1,8 @@ import { useCallback, useEffect, useRef } from 'react'; +import { + clampScrollTop, + revealFieldIfNeeded, +} from '../utils/keyboardScroll'; /** Input types that should trigger scroll clamp on value change. */ export function isClampableInputType(type: string | undefined): boolean { @@ -28,26 +32,56 @@ export function isFormFieldForScrollClamp( return false; } -/** Prevent scrolling past the last real content row inside the form scroll area. */ -export function clampScrollTop(el: HTMLElement): void { - const max = Math.max(0, el.scrollHeight - el.clientHeight); - if (el.scrollTop > max) { - el.scrollTop = max; - } -} +export { clampScrollTop } from '../utils/keyboardScroll'; + +const KEYBOARD_REVEAL_DELAY_MS = 100; /** - * Clamps FormLayout scroll when the IME opens, on field focus, and after value - * changes (number stepper +/-, numeric keyboard input, layout reflow). + * Clamps FormLayout scroll when the IME opens and reveals focused fields only + * when obscured after keyboard animation — never scrollIntoView on value change. */ export function useKeyboardScrollClamp() { const scrollRef = useRef(null); + const focusedFieldRef = useRef(null); + const revealTimerRef = useRef | null>(null); const clamp = useCallback(() => { const el = scrollRef.current; if (el) clampScrollTop(el); }, []); + const tryRevealFocused = useCallback(() => { + const container = scrollRef.current; + const field = focusedFieldRef.current; + if (!container || !field || !container.contains(field)) return; + revealFieldIfNeeded(container, field, { marginBottom: 24, marginTop: 8 }); + clamp(); + }, [clamp]); + + const scheduleReveal = useCallback(() => { + if (revealTimerRef.current) { + clearTimeout(revealTimerRef.current); + revealTimerRef.current = null; + } + + requestAnimationFrame(() => { + requestAnimationFrame(() => { + tryRevealFocused(); + revealTimerRef.current = setTimeout(() => { + tryRevealFocused(); + revealTimerRef.current = null; + }, KEYBOARD_REVEAL_DELAY_MS); + }); + }); + }, [tryRevealFocused]); + + const runClampChain = useCallback(() => { + requestAnimationFrame(() => { + clamp(); + requestAnimationFrame(clamp); + }); + }, [clamp]); + useEffect(() => { const el = scrollRef.current; if (!el) return; @@ -55,30 +89,48 @@ export function useKeyboardScrollClamp() { const vv = window.visualViewport; const onViewportChange = () => { - requestAnimationFrame(clamp); + if (focusedFieldRef.current) { + scheduleReveal(); + } else { + requestAnimationFrame(clamp); + } }; const onFocusIn = (event: FocusEvent) => { const target = event.target; if (!isFormFieldForScrollClamp(target)) return; + if (!(target instanceof HTMLElement)) return; - requestAnimationFrame(() => { - requestAnimationFrame(() => { - if (target instanceof HTMLElement) { - try { - target.scrollIntoView({ block: 'nearest', behavior: 'instant' }); - } catch { - target.scrollIntoView({ block: 'nearest' }); - } - } - clamp(); - }); - }); + focusedFieldRef.current = target; + scheduleReveal(); + }; + + const onFocusOut = (event: FocusEvent) => { + const target = event.target; + if (!isFormFieldForScrollClamp(target)) return; + + const related = event.relatedTarget; + if ( + related instanceof HTMLElement && + isFormFieldForScrollClamp(related) && + el.contains(related) + ) { + return; + } + + if (focusedFieldRef.current === target) { + focusedFieldRef.current = null; + } + if (revealTimerRef.current) { + clearTimeout(revealTimerRef.current); + revealTimerRef.current = null; + } + runClampChain(); }; const onInputOrChange = (event: Event) => { if (!isFormFieldForScrollClamp(event.target)) return; - requestAnimationFrame(clamp); + runClampChain(); }; const resizeObserver = @@ -90,7 +142,10 @@ export function useKeyboardScrollClamp() { el.contains(active) && isFormFieldForScrollClamp(active) ) { - requestAnimationFrame(clamp); + focusedFieldRef.current = active; + tryRevealFocused(); + } else { + runClampChain(); } }) : null; @@ -100,6 +155,7 @@ export function useKeyboardScrollClamp() { vv?.addEventListener('resize', onViewportChange); vv?.addEventListener('scroll', onViewportChange); el.addEventListener('focusin', onFocusIn); + el.addEventListener('focusout', onFocusOut); el.addEventListener('input', onInputOrChange, true); el.addEventListener('change', onInputOrChange, true); @@ -108,10 +164,14 @@ export function useKeyboardScrollClamp() { vv?.removeEventListener('resize', onViewportChange); vv?.removeEventListener('scroll', onViewportChange); el.removeEventListener('focusin', onFocusIn); + el.removeEventListener('focusout', onFocusOut); el.removeEventListener('input', onInputOrChange, true); el.removeEventListener('change', onInputOrChange, true); + if (revealTimerRef.current) { + clearTimeout(revealTimerRef.current); + } }; - }, [clamp]); + }, [clamp, runClampChain, scheduleReveal, tryRevealFocused]); return scrollRef; } diff --git a/formulus-formplayer/src/renderers/FlatGroupLayout.test.tsx b/formulus-formplayer/src/renderers/FlatGroupLayout.test.tsx new file mode 100644 index 000000000..de4cd878d --- /dev/null +++ b/formulus-formplayer/src/renderers/FlatGroupLayout.test.tsx @@ -0,0 +1,23 @@ +// @vitest-environment jsdom +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import { ThemeProvider, createTheme } from '@mui/material/styles'; +import { CardGroupShell } from './FlatGroupLayout'; + +const theme = createTheme(); + +afterEach(() => cleanup()); + +describe('CardGroupShell', () => { + it('renders group title and children', () => { + render( + + +
Field
+
+
, + ); + expect(screen.getByText('Sticker / amostra')).toBeInTheDocument(); + expect(screen.getByTestId('child')).toBeInTheDocument(); + }); +}); diff --git a/formulus-formplayer/src/renderers/FlatGroupLayout.tsx b/formulus-formplayer/src/renderers/FlatGroupLayout.tsx new file mode 100644 index 000000000..a2e5c0b99 --- /dev/null +++ b/formulus-formplayer/src/renderers/FlatGroupLayout.tsx @@ -0,0 +1,112 @@ +import React from 'react'; +import { withJsonFormsLayoutProps } from '@jsonforms/react'; +import { + LayoutProps, + RankedTester, + rankWith, + uiTypeIs, + UISchemaElement, +} from '@jsonforms/core'; +import { MaterialLayoutRenderer } from '@jsonforms/material-renderers'; +import { Box, Card, CardContent, CardHeader, Typography } from '@mui/material'; +import { useFormDensity } from '../context/FormDensityContext'; + +const isEmpty = (value: string | undefined): boolean => + value === undefined || value === null || value.trim() === ''; + +/** @internal for tests — card shell without JSON Forms wrapper */ +export function CardGroupShell({ + label, + children, +}: { + label?: string; + children: React.ReactNode; +}) { + return ( + + {!isEmpty(label) && ( + + )} + + {children} + + + ); +} + +export const flatGroupLayoutTester: RankedTester = rankWith(2, uiTypeIs('Group')); + +const FlatGroupLayoutRenderer = ({ + uischema, + schema, + path, + visible, + enabled, + renderers, + cells, + direction, + label, +}: LayoutProps) => { + const { groupVariant } = useFormDensity(); + const groupLayout = uischema as { + elements: UISchemaElement[]; + options?: { variant?: string }; + }; + const useCard = + groupVariant === 'card' || groupLayout.options?.variant === 'card'; + + if (!visible) { + return null; + } + + const childProps = { + elements: groupLayout.elements, + schema, + path, + enabled, + direction, + visible, + renderers, + cells, + }; + + const layout = ( + + ); + + if (useCard) { + return {layout}; + } + + return ( + + {!isEmpty(label) && ( + + {label} + + )} + {layout} + + ); +}; + +export const FlatGroupLayout = withJsonFormsLayoutProps(FlatGroupLayoutRenderer); diff --git a/formulus-formplayer/src/renderers/SwipeLayoutRenderer.tsx b/formulus-formplayer/src/renderers/SwipeLayoutRenderer.tsx index 7c35ea742..cd942b01d 100644 --- a/formulus-formplayer/src/renderers/SwipeLayoutRenderer.tsx +++ b/formulus-formplayer/src/renderers/SwipeLayoutRenderer.tsx @@ -406,9 +406,8 @@ const SwipeLayoutRenderer = ({ const { ref: swipeableRef, ...swipeHandlers } = handlers; - const mergedSwipeScreenRef = useCallback( + const mergeScrollRef = useCallback( (el: HTMLDivElement | null) => { - swipeScreenRef.current = el; if (typeof swipeableRef === 'function') { swipeableRef(el); } @@ -416,6 +415,10 @@ const SwipeLayoutRenderer = ({ [swipeableRef], ); + const setSwipeScreenRef = useCallback((el: HTMLDivElement | null) => { + swipeScreenRef.current = el; + }, []); + const isOnFinalizePage = useMemo(() => { return layouts[currentPage]?.type === 'Finalize'; }, [layouts, currentPage]); @@ -526,7 +529,10 @@ const SwipeLayoutRenderer = ({ swipeOptions.headerTitle || (schema as any)?.title || undefined; const headerFields: string[] = (swipeOptions.headerFields || []).slice(0, 2); - const densityContextValue = useMemo(() => ({ labelLayout }), [labelLayout]); + const densityContextValue = useMemo( + () => ({ labelLayout, groupVariant: 'flat' as const }), + [labelLayout], + ); if (visible === false) { return null; @@ -539,6 +545,8 @@ const SwipeLayoutRenderer = ({ navigateToPage(prevVisiblePage) - : undefined - } - onNavigateNext={ - nextVisiblePage !== null - ? () => navigateToPage(nextVisiblePage) - : undefined - } + canNavigatePrevious={prevVisiblePage !== null} + canNavigateNext={nextVisiblePage !== null} + onNavigatePrevious={() => { + if (prevVisiblePage !== null) navigateToPage(prevVisiblePage); + }} + onNavigateNext={() => { + if (nextVisiblePage !== null) navigateToPage(nextVisiblePage); + }} navigationDisabled={isNavigating} /> {headerFields.length > 0 && ( @@ -642,10 +648,7 @@ const SwipeLayoutRenderer = ({ } contentBottomPadding={24} showNavigation={true}> -
+
{(uischema as any)?.label &&

{(uischema as any).label}

} {layouts.length > 0 && layouts[currentPage] && ( { + it('returns false when field is fully inside visible band', () => { + const container = { top: 0, bottom: 400 }; + const field = { top: 100, bottom: 150 }; + expect(isFieldObscuredInContainer(container, field)).toBe(false); + }); + + it('returns true when field bottom is below visible area', () => { + const container = { top: 0, bottom: 200 }; + const field = { top: 150, bottom: 250 }; + expect(isFieldObscuredInContainer(container, field)).toBe(true); + }); + + it('returns true when field top is above visible area', () => { + const container = { top: 100, bottom: 400 }; + const field = { top: 50, bottom: 120 }; + expect(isFieldObscuredInContainer(container, field)).toBe(true); + }); +}); + +describe('computeScrollDeltaForField', () => { + it('returns positive delta when field extends below container', () => { + const container = { top: 0, bottom: 200 }; + const field = { top: 150, bottom: 230 }; + expect(computeScrollDeltaForField(container, field, 8, 16)).toBe(46); + }); + + it('returns negative delta when field extends above container', () => { + const container = { top: 100, bottom: 400 }; + const field = { top: 90, bottom: 150 }; + expect(computeScrollDeltaForField(container, field, 8, 16)).toBe(-18); + }); + + it('returns zero when field is visible', () => { + const container = { top: 0, bottom: 300 }; + const field = { top: 50, bottom: 100 }; + expect(computeScrollDeltaForField(container, field)).toBe(0); + }); +}); + +describe('revealFieldIfNeeded', () => { + it('does not scroll when field is already visible', () => { + const container = document.createElement('div'); + Object.defineProperty(container, 'scrollTop', { value: 0, writable: true }); + Object.defineProperty(container, 'scrollHeight', { value: 500 }); + Object.defineProperty(container, 'clientHeight', { value: 300 }); + container.getBoundingClientRect = () => + ({ + top: 0, + bottom: 300, + left: 0, + right: 100, + width: 100, + height: 300, + }) as DOMRect; + + const field = document.createElement('input'); + container.appendChild(field); + field.getBoundingClientRect = () => + ({ + top: 50, + bottom: 80, + left: 0, + right: 100, + width: 100, + height: 30, + }) as DOMRect; + + const changed = revealFieldIfNeeded(container, field); + expect(changed).toBe(false); + expect(container.scrollTop).toBe(0); + }); + + it('scrolls down when field bottom is below container', () => { + const container = document.createElement('div'); + let scrollTop = 0; + Object.defineProperty(container, 'scrollTop', { + get: () => scrollTop, + set: (v: number) => { + scrollTop = v; + }, + }); + Object.defineProperty(container, 'scrollHeight', { value: 800 }); + Object.defineProperty(container, 'clientHeight', { value: 300 }); + container.getBoundingClientRect = () => + ({ + top: 0, + bottom: 300, + left: 0, + right: 100, + width: 100, + height: 300, + }) as DOMRect; + + const field = document.createElement('input'); + container.appendChild(field); + field.getBoundingClientRect = () => + ({ + top: 250, + bottom: 320, + left: 0, + right: 100, + width: 100, + height: 70, + }) as DOMRect; + + const changed = revealFieldIfNeeded(container, field, { + marginBottom: 16, + marginTop: 8, + }); + expect(changed).toBe(true); + expect(scrollTop).toBeGreaterThan(0); + clampScrollTop(container); + expect(scrollTop).toBeLessThanOrEqual(500); + }); +}); diff --git a/formulus-formplayer/src/utils/keyboardScroll.ts b/formulus-formplayer/src/utils/keyboardScroll.ts new file mode 100644 index 000000000..d70b615ca --- /dev/null +++ b/formulus-formplayer/src/utils/keyboardScroll.ts @@ -0,0 +1,96 @@ +/** DOM rect subset used for scroll calculations. */ +export interface FieldRect { + top: number; + bottom: number; +} + +/** Prevent scrolling past the last real content row inside the form scroll area. */ +export function clampScrollTop(el: HTMLElement): void { + const max = Math.max(0, el.scrollHeight - el.clientHeight); + if (el.scrollTop > max) { + el.scrollTop = max; + } +} + +/** + * True when the field is not fully visible inside the scroll container's client + * area (with optional margins for sticky header / nav bar). + */ +export function isFieldObscuredInContainer( + containerRect: FieldRect & { height?: number }, + fieldRect: FieldRect, + marginTop = 8, + marginBottom = 16, +): boolean { + const visibleTop = containerRect.top + marginTop; + const visibleBottom = + containerRect.bottom !== undefined + ? containerRect.bottom - marginBottom + : containerRect.top + (containerRect.height ?? 0) - marginBottom; + + return fieldRect.bottom > visibleBottom || fieldRect.top < visibleTop; +} + +/** + * Minimal scroll delta (px) to bring `fieldRect` into the container's visible band. + * Positive = scroll down, negative = scroll up. Zero when already visible. + */ +export function computeScrollDeltaForField( + containerRect: FieldRect, + fieldRect: FieldRect, + marginTop = 8, + marginBottom = 16, +): number { + const visibleTop = containerRect.top + marginTop; + const visibleBottom = containerRect.bottom - marginBottom; + + if (fieldRect.bottom > visibleBottom) { + return fieldRect.bottom - visibleBottom; + } + if (fieldRect.top < visibleTop) { + return fieldRect.top - visibleTop; + } + return 0; +} + +export interface RevealFieldOptions { + marginTop?: number; + marginBottom?: number; +} + +/** + * Scroll the container only enough to reveal `field` when obscured. + * Uses manual scrollTop adjustment (not scrollIntoView) to avoid WebView gaps. + * @returns true when scroll position changed + */ +export function revealFieldIfNeeded( + container: HTMLElement, + field: HTMLElement, + options: RevealFieldOptions = {}, +): boolean { + const marginTop = options.marginTop ?? 8; + const marginBottom = options.marginBottom ?? 16; + + const containerRect = container.getBoundingClientRect(); + const fieldRect = field.getBoundingClientRect(); + + if ( + !isFieldObscuredInContainer(containerRect, fieldRect, marginTop, marginBottom) + ) { + return false; + } + + const delta = computeScrollDeltaForField( + containerRect, + fieldRect, + marginTop, + marginBottom, + ); + + if (delta !== 0) { + container.scrollTop += delta; + } + + clampScrollTop(container); + return delta !== 0; +} diff --git a/formulus-formplayer/vite.config.ts b/formulus-formplayer/vite.config.ts index 002ceea53..043c3525c 100644 --- a/formulus-formplayer/vite.config.ts +++ b/formulus-formplayer/vite.config.ts @@ -75,5 +75,6 @@ export default defineConfig({ test: { environment: 'node', + setupFiles: ['./src/setupTests.ts'], }, }); From 9b6a7d810f48af394662779fef86fd71901c4244 Mon Sep 17 00:00:00 2001 From: Emil Rossing Date: Thu, 18 Jun 2026 12:41:03 +0000 Subject: [PATCH 12/46] chore: formatting --- formulus-formplayer/README.md | 12 ++++++------ .../src/components/FormProgressBar.test.tsx | 16 +++++++++++++--- .../src/components/FormProgressBar.tsx | 3 ++- .../useKeyboardScrollClamp.integration.test.tsx | 5 +---- .../src/hooks/useKeyboardScrollClamp.test.ts | 5 +---- .../src/hooks/useKeyboardScrollClamp.ts | 5 +---- .../src/renderers/FlatGroupLayout.tsx | 9 +++++++-- formulus-formplayer/src/utils/keyboardScroll.ts | 7 ++++++- 8 files changed, 37 insertions(+), 25 deletions(-) diff --git a/formulus-formplayer/README.md b/formulus-formplayer/README.md index ca09fb679..d25e8e7e8 100644 --- a/formulus-formplayer/README.md +++ b/formulus-formplayer/README.md @@ -194,13 +194,13 @@ Swipe gestures attach to the full scroll area (not only the question panel). After formplayer changes, verify on a phone WebView (or ODE Desktop form preview with keyboard): -| Scenario | Pass criteria | -| -------- | ------------- | -| Multi-page form header | Both chevrons visible; disabled at first/last page | +| Scenario | Pass criteria | +| ----------------------------------------- | ------------------------------------------------------------ | +| Multi-page form header | Both chevrons visible; disabled at first/last page | | Group page (e.g. GBMIS Sticker / amostra) | Full-width layout; swipe works on background below questions | -| GBMIS `censo_milda_pessoa` → Anos | No white scroll gap when typing digits | -| GBMIS `censo` → IME Next | No gap when moving field-to-field via keyboard Next | -| `age_years` > 120 | Single validation error via QuestionShell | +| GBMIS `censo_milda_pessoa` → Anos | No white scroll gap when typing digits | +| GBMIS `censo` → IME Next | No gap when moving field-to-field via keyboard Next | +| `age_years` > 120 | Single validation error via QuestionShell | Rebuild formplayer (`pnpm run build:copy`) before testing in Formulus or Desktop developer mode. diff --git a/formulus-formplayer/src/components/FormProgressBar.test.tsx b/formulus-formplayer/src/components/FormProgressBar.test.tsx index bb7c7461c..1c50d9a53 100644 --- a/formulus-formplayer/src/components/FormProgressBar.test.tsx +++ b/formulus-formplayer/src/components/FormProgressBar.test.tsx @@ -9,7 +9,9 @@ const theme = createTheme(); afterEach(() => cleanup()); -const renderBar = (props: Partial> = {}) => +const renderBar = ( + props: Partial> = {}, +) => render( { }); it('disables previous chevron on first page', () => { - renderBar({ currentPage: 0, canNavigatePrevious: false, canNavigateNext: true }); + renderBar({ + currentPage: 0, + canNavigatePrevious: false, + canNavigateNext: true, + }); expect(screen.getByLabelText('Previous screen')).toBeDisabled(); expect(screen.getByLabelText('Next screen')).not.toBeDisabled(); }); it('disables next chevron on last page', () => { - renderBar({ currentPage: 2, canNavigatePrevious: true, canNavigateNext: false }); + renderBar({ + currentPage: 2, + canNavigatePrevious: true, + canNavigateNext: false, + }); expect(screen.getByLabelText('Previous screen')).not.toBeDisabled(); expect(screen.getByLabelText('Next screen')).toBeDisabled(); }); diff --git a/formulus-formplayer/src/components/FormProgressBar.tsx b/formulus-formplayer/src/components/FormProgressBar.tsx index 7de79d3b4..0a90c109f 100644 --- a/formulus-formplayer/src/components/FormProgressBar.tsx +++ b/formulus-formplayer/src/components/FormProgressBar.tsx @@ -191,7 +191,8 @@ const FormProgressBar: React.FC = ({ }, [currentPage, totalScreens, data, schema, mode, isOnFinalizePage]); const handlePrev = useCallback(() => { - if (navigationDisabled || !canNavigatePrevious || !onNavigatePrevious) return; + if (navigationDisabled || !canNavigatePrevious || !onNavigatePrevious) + return; onNavigatePrevious(); }, [navigationDisabled, canNavigatePrevious, onNavigatePrevious]); diff --git a/formulus-formplayer/src/hooks/useKeyboardScrollClamp.integration.test.tsx b/formulus-formplayer/src/hooks/useKeyboardScrollClamp.integration.test.tsx index def9d1f90..46cf18140 100644 --- a/formulus-formplayer/src/hooks/useKeyboardScrollClamp.integration.test.tsx +++ b/formulus-formplayer/src/hooks/useKeyboardScrollClamp.integration.test.tsx @@ -32,10 +32,7 @@ describe('FormLayout keyboard scroll integration', () => { await new Promise(resolve => setTimeout(resolve, 50)); - const max = Math.max( - 0, - scrollArea.scrollHeight - scrollArea.clientHeight, - ); + const max = Math.max(0, scrollArea.scrollHeight - scrollArea.clientHeight); expect(scrollArea.scrollTop).toBeLessThanOrEqual(max); expect(container).toBeTruthy(); }); diff --git a/formulus-formplayer/src/hooks/useKeyboardScrollClamp.test.ts b/formulus-formplayer/src/hooks/useKeyboardScrollClamp.test.ts index 36dd1c415..7e0759200 100644 --- a/formulus-formplayer/src/hooks/useKeyboardScrollClamp.test.ts +++ b/formulus-formplayer/src/hooks/useKeyboardScrollClamp.test.ts @@ -1,8 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { - clampScrollTop, - isClampableInputType, -} from './useKeyboardScrollClamp'; +import { clampScrollTop, isClampableInputType } from './useKeyboardScrollClamp'; describe('clampScrollTop', () => { it('does not change scrollTop when within range', () => { diff --git a/formulus-formplayer/src/hooks/useKeyboardScrollClamp.ts b/formulus-formplayer/src/hooks/useKeyboardScrollClamp.ts index 3858b2d28..a2cca80ee 100644 --- a/formulus-formplayer/src/hooks/useKeyboardScrollClamp.ts +++ b/formulus-formplayer/src/hooks/useKeyboardScrollClamp.ts @@ -1,8 +1,5 @@ import { useCallback, useEffect, useRef } from 'react'; -import { - clampScrollTop, - revealFieldIfNeeded, -} from '../utils/keyboardScroll'; +import { clampScrollTop, revealFieldIfNeeded } from '../utils/keyboardScroll'; /** Input types that should trigger scroll clamp on value change. */ export function isClampableInputType(type: string | undefined): boolean { diff --git a/formulus-formplayer/src/renderers/FlatGroupLayout.tsx b/formulus-formplayer/src/renderers/FlatGroupLayout.tsx index a2e5c0b99..99ed0466a 100644 --- a/formulus-formplayer/src/renderers/FlatGroupLayout.tsx +++ b/formulus-formplayer/src/renderers/FlatGroupLayout.tsx @@ -37,7 +37,10 @@ export function CardGroupShell({ ); } -export const flatGroupLayoutTester: RankedTester = rankWith(2, uiTypeIs('Group')); +export const flatGroupLayoutTester: RankedTester = rankWith( + 2, + uiTypeIs('Group'), +); const FlatGroupLayoutRenderer = ({ uischema, @@ -109,4 +112,6 @@ const FlatGroupLayoutRenderer = ({ ); }; -export const FlatGroupLayout = withJsonFormsLayoutProps(FlatGroupLayoutRenderer); +export const FlatGroupLayout = withJsonFormsLayoutProps( + FlatGroupLayoutRenderer, +); diff --git a/formulus-formplayer/src/utils/keyboardScroll.ts b/formulus-formplayer/src/utils/keyboardScroll.ts index d70b615ca..9da148575 100644 --- a/formulus-formplayer/src/utils/keyboardScroll.ts +++ b/formulus-formplayer/src/utils/keyboardScroll.ts @@ -75,7 +75,12 @@ export function revealFieldIfNeeded( const fieldRect = field.getBoundingClientRect(); if ( - !isFieldObscuredInContainer(containerRect, fieldRect, marginTop, marginBottom) + !isFieldObscuredInContainer( + containerRect, + fieldRect, + marginTop, + marginBottom, + ) ) { return false; } From a70b71d8ea51b6d30c1809de89566d092b3bba06 Mon Sep 17 00:00:00 2001 From: Emil Rossing Date: Thu, 18 Jun 2026 13:34:45 +0000 Subject: [PATCH 13/46] feat(formulus): various UX improvement re. sub-observations and more efficient API location access --- formulus-formplayer/src/App.tsx | 79 ++++-- .../src/jsonforms/ShellInputControl.tsx | 20 ++ .../src/renderers/QrcodeQuestionRenderer.tsx | 5 + .../SubObservationQuestionRenderer.tsx | 34 ++- .../src/renderers/SwipeLayoutRenderer.tsx | 15 +- .../src/renderers/subObservationHelpers.ts | 60 +++++ .../src/types/FormulusInterfaceDefinition.ts | 17 ++ .../src/utils/autoSequence.test.ts | 77 ++++++ formulus-formplayer/src/utils/autoSequence.ts | 244 ++++++++++++++++++ .../src/utils/errorPageNavigation.test.ts | 115 +++++++++ .../src/utils/errorPageNavigation.ts | 225 ++++++++++++++++ formulus/App.tsx | 2 + .../assets/webview/FormulusInjectionScript.js | 176 +++++++++++++ formulus/src/components/CustomAppWebView.tsx | 28 ++ .../src/components/QRScannerModalImpl.tsx | 6 +- formulus/src/services/GeolocationService.ts | 73 ++++++ .../src/services/QrcodeRequestCoordinator.ts | 63 +++++ .../QrcodeRequestCoordinator.test.ts | 42 +++ .../webview/FormulusInterfaceDefinition.ts | 17 ++ .../src/webview/FormulusMessageHandlers.ts | 109 ++++++-- 20 files changed, 1349 insertions(+), 58 deletions(-) create mode 100644 formulus-formplayer/src/utils/autoSequence.test.ts create mode 100644 formulus-formplayer/src/utils/autoSequence.ts create mode 100644 formulus-formplayer/src/utils/errorPageNavigation.test.ts create mode 100644 formulus-formplayer/src/utils/errorPageNavigation.ts create mode 100644 formulus/src/services/QrcodeRequestCoordinator.ts create mode 100644 formulus/src/services/__tests__/QrcodeRequestCoordinator.test.ts diff --git a/formulus-formplayer/src/App.tsx b/formulus-formplayer/src/App.tsx index a907dc796..e68abb16f 100644 --- a/formulus-formplayer/src/App.tsx +++ b/formulus-formplayer/src/App.tsx @@ -104,6 +104,8 @@ import { loadCustomQuestionTypes } from './services/CustomQuestionTypeLoader'; import { loadCustomValidators } from './services/CustomValidatorLoader'; import { customValidatorRegistry } from './services/CustomValidatorRegistry'; import { runCustomValidatorsAndRefreshData } from './services/customValidatorDataRefresh'; +import { resolveErrorPageIndex } from './utils/errorPageNavigation'; +import { applyAutoSequences } from './utils/autoSequence'; import { newDraftSessionKey } from './utils/draftSessionKey'; /** Embedded sub-observation session (also accepts legacy `returnOnly` from older hosts). */ @@ -584,6 +586,12 @@ function App() { ]; (window as unknown as Record).formulusSessionContext = sessionContext ?? null; + (window as unknown as Record).formulusSubObservationContext = + sessionContext && + typeof sessionContext === 'object' && + 'subObservation' in (sessionContext as Record) + ? (sessionContext as Record).subObservation + : null; if (savedData && Object.keys(savedData).length > 0) { console.log('Preloading saved data:', savedData); @@ -927,29 +935,31 @@ function App() { const handleNavigateToError = (event: CustomEvent) => { if (!uischema) return; - const path = event.detail.path; - const field = path.split('/').pop(); - const screens = uischema.elements; - - for (let i = 0; i < screens.length; i++) { - const screen = screens[i]; - // Skip the Finalize screen - if (screen.type === 'Finalize') continue; - - // Type guard to ensure elements exists - if ('elements' in screen && screen.elements) { - if (screen.elements.some((el: any) => el.scope?.includes(field))) { - // Dispatch a custom event that SwipeLayoutWrapper will listen for - const navigateEvent = new CustomEvent('navigateToPage', { - detail: { page: i }, - }); - window.dispatchEvent(navigateEvent); - break; - } - } + const path = event.detail?.path; + if (!path || typeof path !== 'string') return; + + const pageIndex = resolveErrorPageIndex(uischema, path); + if (pageIndex !== null) { + window.dispatchEvent( + new CustomEvent('navigateToPage', { + detail: { page: pageIndex }, + }), + ); } }; + const handleRevalidate = () => { + if (!data) return; + const { errors, data: refreshedData } = runCustomValidatorsAndRefreshData( + uischema ?? undefined, + schema ?? undefined, + data as Record, + ajv, + ); + setData(structuredClone(refreshedData)); + setCustomValidatorErrors(errors); + }; + const handleShowValidation = () => { // Idempotent: once shown, stays shown for the session. setValidationMode(prev => @@ -1045,6 +1055,10 @@ function App() { 'formShowValidation', handleShowValidation as EventListener, ); + window.addEventListener( + 'formRevalidate', + handleRevalidate as EventListener, + ); return () => { window.removeEventListener( @@ -1059,6 +1073,10 @@ function App() { 'formShowValidation', handleShowValidation as EventListener, ); + window.removeEventListener( + 'formRevalidate', + handleRevalidate as EventListener, + ); }; }, [data, formInitData, draftSessionKey, uischema, schema, ajv]); // Include all dependencies @@ -1101,14 +1119,31 @@ function App() { const handleDataChange = useCallback( ({ data: newData }: { data: FormData }) => { + const autoRuntime = { + subObservationContext: ( + window as unknown as { + formulusSubObservationContext?: Record | null; + } + ).formulusSubObservationContext, + sessionContext: ( + window as unknown as { + formulusSessionContext?: Record | null; + } + ).formulusSessionContext, + }; + const { data: sequencedData, mutated: seqMutated } = applyAutoSequences( + newData as Record, + schema ?? undefined, + autoRuntime, + ); const { errors, data: refreshedData } = runCustomValidatorsAndRefreshData( uischema ?? undefined, schema ?? undefined, - newData as Record, + sequencedData, ajv, ); - setData(refreshedData); + setData(seqMutated ? refreshedData : refreshedData); setCustomValidatorErrors(errors); // Save draft data whenever form data changes (skip embedded sub-observation sessions) diff --git a/formulus-formplayer/src/jsonforms/ShellInputControl.tsx b/formulus-formplayer/src/jsonforms/ShellInputControl.tsx index c8c0dbe39..20073e0ff 100644 --- a/formulus-formplayer/src/jsonforms/ShellInputControl.tsx +++ b/formulus-formplayer/src/jsonforms/ShellInputControl.tsx @@ -35,6 +35,22 @@ const ShellInputControl = (props: ControlProps) => { const autoFocus = (uischema as { options?: { autoFocus?: boolean } })?.options?.autoFocus === true; + const uiOptions = ( + uischema as { + options?: { + multi?: boolean; + minRows?: number; + rows?: number; + maxRows?: number; + }; + } + )?.options; + const multiline = uiOptions?.multi === true; + const minRows = + typeof uiOptions?.minRows === 'number' ? uiOptions.minRows : undefined; + const rows = typeof uiOptions?.rows === 'number' ? uiOptions.rows : undefined; + const maxRows = + typeof uiOptions?.maxRows === 'number' ? uiOptions.maxRows : undefined; const cellProps = { ...(props as React.ComponentProps), @@ -52,6 +68,10 @@ const ShellInputControl = (props: ControlProps) => { label={undefined} isValid={isValid} muiInputProps={{ + ...(multiline ? { multiline: true } : {}), + ...(minRows != null ? { minRows } : {}), + ...(rows != null ? { rows } : {}), + ...(maxRows != null ? { maxRows } : {}), ...(keyboardEnterKeyHint ? { enterKeyHint: keyboardEnterKeyHint } : {}), diff --git a/formulus-formplayer/src/renderers/QrcodeQuestionRenderer.tsx b/formulus-formplayer/src/renderers/QrcodeQuestionRenderer.tsx index bb122a634..1a2635e1c 100644 --- a/formulus-formplayer/src/renderers/QrcodeQuestionRenderer.tsx +++ b/formulus-formplayer/src/renderers/QrcodeQuestionRenderer.tsx @@ -95,6 +95,11 @@ const QrcodeQuestionRenderer: React.FC = ({ if (qrError.status === 'cancelled') { // User cancelled — don't show error console.log('QR scan cancelled by user'); + } else if ( + qrError.status === 'error' && + qrError.message === 'QR scanner is already open' + ) { + setError('Scanner is already open. Close it first, then try again.'); } else if (qrError.status === 'error') { setError(qrError.message || 'QR scanner error'); } else { diff --git a/formulus-formplayer/src/renderers/SubObservationQuestionRenderer.tsx b/formulus-formplayer/src/renderers/SubObservationQuestionRenderer.tsx index 2accb85e5..bd015a231 100644 --- a/formulus-formplayer/src/renderers/SubObservationQuestionRenderer.tsx +++ b/formulus-formplayer/src/renderers/SubObservationQuestionRenderer.tsx @@ -19,13 +19,13 @@ import { coerceSubObservationRows, optionalRecordMap, readSubObservationField, - resolveInitialValues, sortRows, readDataPath, resolveItemLabel, resolveAddButtonLabel, resolveEmptyLabel, resolveDeleteFallbackLabel, + buildSubObservationOpenParams, type OrderBySpec, } from './subObservationHelpers'; @@ -227,14 +227,23 @@ const SubObservationQuestionRendererInner: React.FC = ({ [itemLabel], ); + const requestFormRevalidation = useCallback(() => { + if (typeof window !== 'undefined') { + window.setTimeout(() => { + window.dispatchEvent(new CustomEvent('formRevalidate')); + }, 0); + } + }, []); + const pushSorted = useCallback( (next: Record[]) => { const sorted = sortRows(next, config.orderBy as OrderBySpec); setRows(sorted); setPrevSortedFromProps(sorted); handleChange(path, sorted); + requestFormRevalidation(); }, - [config.orderBy, handleChange, path], + [config.orderBy, handleChange, path, requestFormRevalidation], ); const refreshRowsFromFormData = useCallback(() => { @@ -249,17 +258,18 @@ const SubObservationQuestionRendererInner: React.FC = ({ try { setBusyId('add'); const pv = resolveParentValue(formData, parentValuePath); - let baseValues = resolveInitialValues( - optionalRecordMap(config.subObservationInitValues), + const openParams = buildSubObservationOpenParams( formData, + config, pv, + optionalRecordMap(config.subObservationInitValues), ); - if (parentKey && pv != null && baseValues[parentKey] == null) { - baseValues = { ...baseValues, [parentKey]: pv }; + if (parentKey && pv != null && openParams[parentKey] == null) { + openParams[parentKey] = pv; } const result: FormCompletionResult = await client.openFormplayer( childFormType, - baseValues, + openParams, {}, { subObservationMode: true, @@ -298,21 +308,23 @@ const SubObservationQuestionRendererInner: React.FC = ({ try { setBusyId(`edit_${index}`); const pv = resolveParentValue(formData, parentValuePath); - const openValues = resolveInitialValues( - optionalRecordMap(config.subObservationEditInitValues), + const openParams = buildSubObservationOpenParams( formData, + config, pv, + optionalRecordMap(config.subObservationEditInitValues), ); + const { context: _ctx, ...paramDefaults } = openParams; const rowData = isObservationWrappedRow(row) ? ((row.data ?? {}) as Record) : row; const savedData: Record = { ...rowData, - ...openValues, + ...paramDefaults, }; const result: FormCompletionResult = await client.openFormplayer( childFormType, - {}, + openParams, savedData, { subObservationMode: true, diff --git a/formulus-formplayer/src/renderers/SwipeLayoutRenderer.tsx b/formulus-formplayer/src/renderers/SwipeLayoutRenderer.tsx index cd942b01d..4244bc9e6 100644 --- a/formulus-formplayer/src/renderers/SwipeLayoutRenderer.tsx +++ b/formulus-formplayer/src/renderers/SwipeLayoutRenderer.tsx @@ -18,6 +18,7 @@ import { rankWith, uiTypeIs, RankedTester, + JsonSchema7, } from '@jsonforms/core'; import { useSwipeable } from 'react-swipeable'; import { Box, Typography, useTheme } from '@mui/material'; @@ -42,6 +43,7 @@ import { focusFirstEnabledTextInput, } from '../utils/autofocusHelpers'; import { navigateToFirstBlockingError } from '../utils/validationNavigation'; +import { formatBlockingErrorSummary } from '../utils/errorPageNavigation'; // --------------------------------------------------------------------------- // Testers @@ -427,6 +429,15 @@ const SwipeLayoutRenderer = ({ const validationErrorCount = core?.errors?.length ?? 0; + const validationAlertMessage = useMemo(() => { + const errors = core?.errors ?? []; + if (errors.length === 0) return ''; + return formatBlockingErrorSummary( + errors, + (core?.schema ?? schema) as JsonSchema7, + ); + }, [core?.errors, core?.schema, schema]); + const trySubmitForm = useCallback(() => { if (!formInitData) return; const errors = core?.errors ?? []; @@ -668,9 +679,7 @@ const SwipeLayoutRenderer = ({ color="error" role="alert" sx={{ px: { xs: 1, sm: 1.5 }, pt: 1, pb: 0.5 }}> - {validationErrorCount}{' '} - {validationErrorCount === 1 ? 'field needs' : 'fields need'}{' '} - attention. Tap Done to review. + {validationAlertMessage} )} diff --git a/formulus-formplayer/src/renderers/subObservationHelpers.ts b/formulus-formplayer/src/renderers/subObservationHelpers.ts index be39092e5..2765b01ad 100644 --- a/formulus-formplayer/src/renderers/subObservationHelpers.ts +++ b/formulus-formplayer/src/renderers/subObservationHelpers.ts @@ -136,6 +136,66 @@ export function resolveInitialValues( return out; } +/** Resolve `subObservationContext` templates from the opening form's data. */ +export function resolveSubObservationContext( + mapObj: Record | null | undefined, + formData: Record, + parentValue: string | null, +): Record { + return resolveInitialValues(mapObj, formData, parentValue); +} + +export function buildSubObservationOpenParams( + formData: Record, + config: Record, + parentValue: string | null, + initMap?: Record | null, +): Record { + const parentSessionContext = + typeof window !== 'undefined' + ? ( + window as unknown as { + formulusSessionContext?: Record | null; + } + ).formulusSessionContext + : null; + + const resolved = resolveSubObservationContext( + optionalRecordMap(config.subObservationContext), + formData, + parentValue, + ); + const inheritedSubObservation = + parentSessionContext && + typeof parentSessionContext === 'object' && + parentSessionContext.subObservation && + typeof parentSessionContext.subObservation === 'object' + ? (parentSessionContext.subObservation as Record) + : {}; + + const subObservation: Record = { ...inheritedSubObservation }; + for (const [key, value] of Object.entries(resolved)) { + if (value !== '' && value != null) { + subObservation[key] = value; + } + } + + const initValues = resolveInitialValues(initMap, formData, parentValue); + const { household_quartos: _legacySnapshot, ...restInit } = initValues; + + const context = { + ...(parentSessionContext && typeof parentSessionContext === 'object' + ? parentSessionContext + : {}), + ...(Object.keys(subObservation).length > 0 ? { subObservation } : {}), + }; + + return { + ...restInit, + ...(Object.keys(context).length > 0 ? { context } : {}), + }; +} + export function formatCellValue(value: unknown): string { if (value == null) return ''; if (typeof value === 'object') return JSON.stringify(value); diff --git a/formulus-formplayer/src/types/FormulusInterfaceDefinition.ts b/formulus-formplayer/src/types/FormulusInterfaceDefinition.ts index 7495db379..5dfa73e94 100644 --- a/formulus-formplayer/src/types/FormulusInterfaceDefinition.ts +++ b/formulus-formplayer/src/types/FormulusInterfaceDefinition.ts @@ -478,6 +478,23 @@ export interface FormulusInterface { */ requestLocation(fieldId: string): Promise; + /** + * Return the best cached device fix (if any) without forcing a new GPS read. + * @param fieldId Optional field id for parity with `requestLocation` + */ + getCachedLocation(fieldId?: string): Promise; + + /** + * Subscribe to location updates while the custom app WebView is active. + * Updates arrive as `locationWatchUpdate` window messages. + */ + watchLocation( + fieldId: string, + ): Promise<{ status: 'started' | 'error'; message?: string }>; + + /** Stop a prior `watchLocation` subscription for `fieldId`. */ + stopWatchLocation(fieldId: string): Promise; + /** * Request file selection for a field * @param {string} fieldId - The ID of the field diff --git a/formulus-formplayer/src/utils/autoSequence.test.ts b/formulus-formplayer/src/utils/autoSequence.test.ts new file mode 100644 index 000000000..3b5c3bf5b --- /dev/null +++ b/formulus-formplayer/src/utils/autoSequence.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; +import { applyAutoSequences } from './autoSequence'; + +describe('applyAutoSequences', () => { + it('assigns sibling max+1 in an array', () => { + const schema = { + properties: { + camas: { + type: 'array', + items: { + type: 'object', + properties: { + cama_num: { + type: 'integer', + 'x-autoSequence': { scope: 'sibling' }, + }, + }, + }, + }, + }, + }; + + const data = { + camas: [{ cama_num: 1 }, { cama_num: null }, {}], + }; + + const { data: next, mutated } = applyAutoSequences(data, schema); + expect(mutated).toBe(true); + expect(next.camas[1].cama_num).toBe(2); + expect(next.camas[2].cama_num).toBe(3); + }); + + it('does not overwrite existing values when immutable', () => { + const schema = { + properties: { + quarto_num: { + type: 'integer', + 'x-autoSequence': { scope: 'tree' }, + }, + }, + }; + + const data = { quarto_num: 5 }; + const { data: next, mutated } = applyAutoSequences(data, schema); + expect(mutated).toBe(false); + expect(next.quarto_num).toBe(5); + }); + + it('uses contextTree for household numbering', () => { + const schema = { + properties: { + nopessoa: { + type: 'string', + 'x-autoSequence': { + scope: 'contextTree', + contextKey: 'quartos', + field: 'nopessoa', + }, + }, + }, + }; + + const data = { nopessoa: '' }; + const { data: next } = applyAutoSequences(data, schema, { + subObservationContext: { + quartos: [ + { + quarto_num: 1, + camas: [{ cama_num: 1, pessoas: [{ nopessoa: '3' }] }], + }, + ], + }, + }); + + expect(next.nopessoa).toBe(4); + }); +}); diff --git a/formulus-formplayer/src/utils/autoSequence.ts b/formulus-formplayer/src/utils/autoSequence.ts new file mode 100644 index 000000000..295afd475 --- /dev/null +++ b/formulus-formplayer/src/utils/autoSequence.ts @@ -0,0 +1,244 @@ +/** + * Platform `x-autoSequence` — assign max+1 sequence integers when blank; optional immutability. + */ + +import type { JsonSchema7 } from '@jsonforms/core'; + +export type AutoSequenceConfig = { + assign?: 'max+1'; + immutable?: boolean; + scope?: 'sibling' | 'tree' | 'contextTree'; + contextKey?: string; + contextFilter?: Record; + field?: string; +}; + +export type AutoSequenceRuntimeContext = { + subObservationContext?: Record | null; + sessionContext?: Record | null; +}; + +type Binding = { + field: string; + config: AutoSequenceConfig; + /** Path to parent object; `*` = each array index */ + parentSegments: string[]; +}; + +function isBlankSequenceValue(value: unknown): boolean { + if (value === undefined || value === null || value === '') return true; + const n = Number(value); + return !Number.isFinite(n) || n <= 0; +} + +function toPosInt(value: unknown): number | null { + const n = Number(value); + return Number.isFinite(n) && n > 0 ? Math.floor(n) : null; +} + +function resolveFilterToken( + token: string, + data: Record, +): unknown { + if (token.startsWith('$data.')) { + return data[token.slice('$data.'.length)]; + } + return data[token]; +} + +function nodeMatchesFilter( + node: Record, + filter: Record | undefined, + data: Record, +): boolean { + if (!filter) return true; + for (const [nodeKey, token] of Object.entries(filter)) { + if (node[nodeKey] !== resolveFilterToken(token, data)) return false; + } + return true; +} + +function collectFieldValues( + node: unknown, + fieldName: string, + filter: Record | undefined, + data: Record, + out: number[], +): void { + if (node == null) return; + if (Array.isArray(node)) { + for (const item of node) { + collectFieldValues(item, fieldName, filter, data, out); + } + return; + } + if (typeof node !== 'object') return; + + const obj = node as Record; + if (!nodeMatchesFilter(obj, filter, data)) return; + + if (Object.prototype.hasOwnProperty.call(obj, fieldName)) { + const n = toPosInt(obj[fieldName]); + if (n != null) out.push(n); + } + + for (const value of Object.values(obj)) { + if (value && typeof value === 'object') { + collectFieldValues(value, fieldName, undefined, data, out); + } + } +} + +function maxFromValues(values: number[]): number { + return values.length === 0 ? 0 : Math.max(...values); +} + +function computeNextValue( + binding: Binding, + data: Record, + runtime: AutoSequenceRuntimeContext, +): number { + const fieldName = binding.config.field ?? binding.field; + const values: number[] = []; + const scope = binding.config.scope ?? 'sibling'; + + if (scope === 'sibling') { + const parent = resolveParentObject(data, binding.parentSegments); + if (Array.isArray(parent)) { + for (const item of parent) { + if (item && typeof item === 'object') { + const n = toPosInt((item as Record)[fieldName]); + if (n != null) values.push(n); + } + } + } + } else if (scope === 'contextTree') { + const contextKey = binding.config.contextKey ?? 'quartos'; + const subCtx = + runtime.subObservationContext ?? + (runtime.sessionContext?.subObservation as Record | null); + collectFieldValues( + subCtx?.[contextKey], + fieldName, + binding.config.contextFilter, + data, + values, + ); + collectFieldValues(data, fieldName, binding.config.contextFilter, data, values); + } else { + collectFieldValues(data, fieldName, binding.config.contextFilter, data, values); + } + + return maxFromValues(values) + 1; +} + +function collectBindings( + schema: JsonSchema7 | undefined, + parentSegments: string[] = [], +): Binding[] { + if (!schema || typeof schema !== 'object') return []; + + const bindings: Binding[] = []; + const props = schema.properties; + if (props) { + for (const [name, childSchema] of Object.entries(props)) { + const child = childSchema as JsonSchema7; + const seq = child['x-autoSequence'] as AutoSequenceConfig | undefined; + if (seq && typeof seq === 'object') { + bindings.push({ + field: name, + parentSegments, + config: { + assign: 'max+1', + immutable: true, + scope: 'sibling', + ...seq, + field: seq.field ?? name, + }, + }); + } + bindings.push(...collectBindings(child, [...parentSegments, name])); + } + } + + if (schema.items && typeof schema.items === 'object') { + bindings.push( + ...collectBindings(schema.items as JsonSchema7, [...parentSegments, '*']), + ); + } + + return bindings; +} + +function resolveParentObject( + data: Record, + segments: string[], +): unknown { + let cur: unknown = data; + for (const segment of segments) { + if (segment === '*') return cur; + if (cur == null || typeof cur !== 'object') return undefined; + cur = (cur as Record)[segment]; + } + return cur; +} + +type ParentRef = { parent: Record }; + +function enumerateParents( + data: Record, + segments: string[], +): ParentRef[] { + if (segments.length === 0) return [{ parent: data }]; + + const [head, ...tail] = segments; + if (head === '*') { + if (!Array.isArray(data)) return []; + const out: ParentRef[] = []; + for (const item of data) { + if (item && typeof item === 'object') { + out.push(...enumerateParents(item as Record, tail)); + } + } + return out; + } + + const next = data[head]; + if (next == null || typeof next !== 'object') return []; + if (tail.length === 0) { + return [{ parent: next as Record }]; + } + return enumerateParents(next as Record, tail); +} + +/** Apply all `x-autoSequence` rules in `schema` to a shallow-cloned `data` when needed. */ +export function applyAutoSequences( + data: Record, + schema: JsonSchema7 | undefined, + runtime: AutoSequenceRuntimeContext = {}, +): { data: Record; mutated: boolean } { + if (!schema) return { data, mutated: false }; + + const bindings = collectBindings(schema, []); + if (bindings.length === 0) return { data, mutated: false }; + + const working = structuredClone(data); + let mutated = false; + + for (const binding of bindings) { + const immutable = binding.config.immutable !== false; + const fieldName = binding.field; + const parentRefs = enumerateParents(working, binding.parentSegments); + + for (const { parent } of parentRefs) { + const current = parent[fieldName]; + if (immutable && !isBlankSequenceValue(current)) continue; + if (!isBlankSequenceValue(current)) continue; + + parent[fieldName] = computeNextValue(binding, working, runtime); + mutated = true; + } + } + + return { data: mutated ? working : data, mutated }; +} diff --git a/formulus-formplayer/src/utils/errorPageNavigation.test.ts b/formulus-formplayer/src/utils/errorPageNavigation.test.ts new file mode 100644 index 000000000..2a93b1cc0 --- /dev/null +++ b/formulus-formplayer/src/utils/errorPageNavigation.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest'; +import { + findSwipePageIndexForInstancePath, + formatBlockingErrorSummary, + instancePathMatchesControlScope, + normalizeErrorInstancePath, + resolveErrorPageIndex, +} from './errorPageNavigation'; + +const nestedGroupLayout = { + type: 'SwipeLayout', + options: { headerFields: ['cama_num'] }, + elements: [ + { + type: 'Group', + label: 'Page 1', + elements: [ + { + type: 'Control', + scope: '#/properties/validar_cama', + label: 'A cama é válida', + }, + ], + }, + { + type: 'Group', + label: 'Page 2', + elements: [ + { + type: 'Control', + scope: '#/properties/viutenda', + label: 'Viu/tem tenda?', + }, + ], + }, + { type: 'Finalize' }, + ], +}; + +describe('normalizeErrorInstancePath', () => { + it('converts custom validator JSON pointer paths', () => { + expect(normalizeErrorInstancePath('#/properties/validar_cama')).toBe( + '/validar_cama', + ); + }); + + it('leaves AJV instance paths unchanged', () => { + expect(normalizeErrorInstancePath('/pessoas/0/sexo')).toBe( + '/pessoas/0/sexo', + ); + }); +}); + +describe('instancePathMatchesControlScope', () => { + it('matches root property scopes', () => { + expect( + instancePathMatchesControlScope( + '/validar_cama', + '#/properties/validar_cama', + ), + ).toBe(true); + }); + + it('matches nested array item scopes', () => { + expect( + instancePathMatchesControlScope( + '/pessoas/0/sexo', + '#/properties/pessoas/items/properties/sexo', + ), + ).toBe(true); + }); +}); + +describe('findSwipePageIndexForInstancePath', () => { + const layouts = nestedGroupLayout.elements; + + it('finds controls nested inside Group pages', () => { + expect( + findSwipePageIndexForInstancePath(layouts, '/validar_cama', []), + ).toBe(0); + expect(findSwipePageIndexForInstancePath(layouts, '/viutenda', [])).toBe(1); + }); + + it('routes header-only fields to the first content page', () => { + expect( + findSwipePageIndexForInstancePath(layouts, '/cama_num', ['cama_num']), + ).toBe(0); + }); +}); + +describe('resolveErrorPageIndex', () => { + it('resolves page index from SwipeLayout uischema', () => { + expect( + resolveErrorPageIndex(nestedGroupLayout as never, '/validar_cama'), + ).toBe(0); + }); +}); + +describe('formatBlockingErrorSummary', () => { + const schema = { + properties: { + validar_cama: { type: 'string', title: 'A cama é válida' }, + viutenda: { type: 'string', title: 'Viu/tem tenda?' }, + }, + }; + + it('includes field titles in the alert copy', () => { + const message = formatBlockingErrorSummary( + [{ instancePath: '/validar_cama' }], + schema, + ); + expect(message).toContain('A cama é válida'); + expect(message).toContain('Tap Done to review'); + }); +}); diff --git a/formulus-formplayer/src/utils/errorPageNavigation.ts b/formulus-formplayer/src/utils/errorPageNavigation.ts new file mode 100644 index 000000000..333b41187 --- /dev/null +++ b/formulus-formplayer/src/utils/errorPageNavigation.ts @@ -0,0 +1,225 @@ +/** + * Map AJV / custom-validator instance paths to SwipeLayout page indices. + */ + +import { + isControl, + type JsonSchema7, + type UISchemaElement, +} from '@jsonforms/core'; +import type { BlockingValidationError } from './validationNavigation'; + +function escapeRegex(segment: string): string { + return segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** `#/properties/a/items/properties/b` → regex matching `/a/0/b`. */ +export function scopeToInstancePathRegex(scope: string): RegExp | null { + if (!scope?.startsWith('#/')) return null; + + const parts = scope.replace(/^#\//, '').split('/'); + const segments: string[] = []; + + for (const part of parts) { + if (part === 'properties') continue; + if (part === 'items') { + segments.push('\\d+'); + continue; + } + segments.push(escapeRegex(part)); + } + + if (segments.length === 0) return null; + return new RegExp(`^/${segments.join('/')}(/|$)`); +} + +/** Convert `#/properties/foo` or AJV `/foo` to a comparable instance path. */ +export function normalizeErrorInstancePath(path: string): string { + if (!path) return ''; + if (path.startsWith('#/properties/')) { + const tail = path + .replace(/^#\/properties\//, '') + .replace(/\/items\/properties\//g, '/') + .replace(/\/items$/, ''); + return `/${tail}`; + } + return path.startsWith('/') ? path : `/${path}`; +} + +export function instancePathMatchesControlScope( + instancePath: string, + scope: string | undefined, +): boolean { + if (!scope) return false; + const normalized = normalizeErrorInstancePath(instancePath); + const regex = scopeToInstancePathRegex(scope); + if (regex?.test(normalized)) return true; + + const leaf = normalized.split('/').filter(Boolean).pop(); + if (!leaf || /^\d+$/.test(leaf)) return false; + return scope.includes(leaf); +} + +export function collectControlsInSubtree( + root: UISchemaElement, +): Array<{ scope: string }> { + const controls: Array<{ scope: string }> = []; + + function walk(el: UISchemaElement) { + if (isControl(el)) { + const scope = (el as { scope?: string }).scope; + if (scope) controls.push({ scope }); + return; + } + const children = (el as { elements?: UISchemaElement[] }).elements; + if (!Array.isArray(children)) return; + for (const child of children) walk(child); + } + + walk(root); + return controls; +} + +export function getSwipeLayoutPages( + uischema: UISchemaElement | null | undefined, +): { layouts: UISchemaElement[]; headerFields: string[] } { + if (!uischema) return { layouts: [], headerFields: [] }; + + const typed = uischema as { + type?: string; + elements?: UISchemaElement[]; + options?: { headerFields?: string[] }; + }; + + if (typed.type === 'SwipeLayout' && Array.isArray(typed.elements)) { + return { + layouts: typed.elements, + headerFields: (typed.options?.headerFields ?? []).slice(0, 2), + }; + } + + if (typed.type === 'Group') { + return { layouts: [uischema], headerFields: [] }; + } + + return { layouts: [], headerFields: [] }; +} + +/** + * Shallowest swipe page index containing a control for `instancePath`, or null. + */ +export function findSwipePageIndexForInstancePath( + layouts: UISchemaElement[], + instancePath: string, + headerFields: string[] = [], +): number | null { + const normalized = normalizeErrorInstancePath(instancePath); + const topLevelField = normalized + .replace(/^\//, '') + .split('/') + .find(segment => segment && !/^\d+$/.test(segment)); + + if (topLevelField && headerFields.includes(topLevelField)) { + const firstContent = layouts.findIndex( + page => (page as { type?: string }).type !== 'Finalize', + ); + return firstContent >= 0 ? firstContent : null; + } + + for (let i = 0; i < layouts.length; i++) { + const page = layouts[i]; + if ((page as { type?: string }).type === 'Finalize') continue; + + const controls = collectControlsInSubtree(page); + if ( + controls.some(({ scope }) => + instancePathMatchesControlScope(normalized, scope), + ) + ) { + return i; + } + } + + return null; +} + +export function resolveErrorPageIndex( + uischema: UISchemaElement | null | undefined, + errorPath: string, +): number | null { + const { layouts, headerFields } = getSwipeLayoutPages(uischema); + if (layouts.length === 0) return null; + return findSwipePageIndexForInstancePath(layouts, errorPath, headerFields); +} + +function titleAtSchemaPath( + schema: JsonSchema7 | undefined, + propertyPath: string[], +): string | null { + if (!schema || propertyPath.length === 0) return null; + + let current: JsonSchema7 | undefined = schema; + for (const part of propertyPath) { + if (!current) return null; + if (current.properties?.[part]) { + current = current.properties[part] as JsonSchema7; + continue; + } + if (current.items && typeof current.items === 'object') { + current = current.items as JsonSchema7; + if (current.properties?.[part]) { + current = current.properties[part] as JsonSchema7; + continue; + } + } + return part; + } + + return ( + (current as { title?: string })?.title ?? + propertyPath[propertyPath.length - 1] ?? + null + ); +} + +export function titleForErrorPath( + errorPath: string, + schema: JsonSchema7 | undefined, +): string | null { + const normalized = normalizeErrorInstancePath(errorPath); + const propertyPath = normalized + .split('/') + .filter(segment => segment && !/^\d+$/.test(segment)); + return titleAtSchemaPath(schema, propertyPath); +} + +/** Human-readable summary for skipFinalize Done alert (field titles, not count only). */ +export function formatBlockingErrorSummary( + errors: ReadonlyArray, + schema: JsonSchema7 | undefined, + maxTitles = 3, +): string { + if (errors.length === 0) return ''; + + const titles: string[] = []; + for (const err of errors) { + const path = + err.instancePath ?? (typeof err.path === 'string' ? err.path : undefined); + const title = path ? titleForErrorPath(path, schema) : null; + const label = title || err.message; + if (label && !titles.includes(label)) titles.push(label); + if (titles.length >= maxTitles) break; + } + + const remaining = errors.length - titles.length; + const joined = titles.join(', '); + const suffix = remaining > 0 ? ` (+${remaining} more)` : ''; + const countPhrase = + errors.length === 1 ? '1 field needs' : `${errors.length} fields need`; + + if (joined) { + return `${countPhrase} attention: ${joined}${suffix}. Tap Done to review.`; + } + + return `${countPhrase} attention. Tap Done to review.`; +} diff --git a/formulus/App.tsx b/formulus/App.tsx index db38f8280..58fdafba2 100644 --- a/formulus/App.tsx +++ b/formulus/App.tsx @@ -20,6 +20,7 @@ import FormplayerModal, { FormplayerModalHandle, } from './src/components/FormplayerModal'; import QRScannerModal from './src/components/QRScannerModal'; +import { qrcodeRequestCoordinator } from './src/services/QrcodeRequestCoordinator'; import SignatureCaptureModal from './src/components/SignatureCaptureModal'; import MainAppNavigator from './src/navigation/MainAppNavigator'; import { FormInitData } from './src/webview/FormulusInterfaceDefinition.ts'; @@ -286,6 +287,7 @@ function AppInner(): React.JSX.Element { { + qrcodeRequestCoordinator.cancel(qrScannerData?.fieldId); setQrScannerVisible(false); setQrScannerData(null); }} diff --git a/formulus/assets/webview/FormulusInjectionScript.js b/formulus/assets/webview/FormulusInjectionScript.js index e4453f216..295b421a1 100644 --- a/formulus/assets/webview/FormulusInjectionScript.js +++ b/formulus/assets/webview/FormulusInjectionScript.js @@ -664,6 +664,182 @@ }); }, + // getCachedLocation: fieldId?: string => Promise + getCachedLocation: function (fieldId) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; + } else { + window.removeEventListener('message', callback); + reject(new Error('getCachedLocation: unexpected response')); + return; + } + if ( + data.type === 'getCachedLocation_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } + } + } catch (e) { + window.removeEventListener('message', callback); + reject(e); + } + }; + window.addEventListener('message', callback); + + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ + type: 'getCachedLocation', + messageId, + fieldId: fieldId, + }), + ); + }); + }, + + // watchLocation: fieldId => Promise<{ status, message? }> + watchLocation: function (fieldId) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + const updateHandler = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; + } else { + return; + } + if ( + data.type === 'locationWatchUpdate' && + data.fieldId === fieldId + ) { + window.dispatchEvent( + new CustomEvent('formulusLocationUpdate', { + detail: { fieldId, result: data.result }, + }), + ); + } + } catch { + /* ignore malformed push */ + } + }; + window.addEventListener('message', updateHandler); + if (!globalThis.__formulusLocationWatchHandlers) { + globalThis.__formulusLocationWatchHandlers = {}; + } + globalThis.__formulusLocationWatchHandlers[fieldId] = updateHandler; + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; + } else { + window.removeEventListener('message', callback); + reject(new Error('watchLocation: unexpected response')); + return; + } + if ( + data.type === 'watchLocation_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + window.removeEventListener('message', updateHandler); + delete globalThis.__formulusLocationWatchHandlers[fieldId]; + reject(new Error(data.error)); + } else { + resolve(data.result); + } + } + } catch (e) { + window.removeEventListener('message', callback); + reject(e); + } + }; + window.addEventListener('message', callback); + + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ + type: 'watchLocation', + messageId, + fieldId: fieldId, + }), + ); + }); + }, + + // stopWatchLocation: fieldId => Promise + stopWatchLocation: function (fieldId) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; + } else { + window.removeEventListener('message', callback); + reject(new Error('stopWatchLocation: unexpected response')); + return; + } + if ( + data.type === 'stopWatchLocation_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + const handler = + globalThis.__formulusLocationWatchHandlers?.[fieldId]; + if (handler) { + window.removeEventListener('message', handler); + delete globalThis.__formulusLocationWatchHandlers[fieldId]; + } + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } + } + } catch (e) { + window.removeEventListener('message', callback); + reject(e); + } + }; + window.addEventListener('message', callback); + + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ + type: 'stopWatchLocation', + messageId, + fieldId: fieldId, + }), + ); + }); + }, + // requestFile: fieldId: string => Promise requestFile: function (fieldId) { return new Promise((resolve, reject) => { diff --git a/formulus/src/components/CustomAppWebView.tsx b/formulus/src/components/CustomAppWebView.tsx index af2bc2285..3ace6dfa6 100644 --- a/formulus/src/components/CustomAppWebView.tsx +++ b/formulus/src/components/CustomAppWebView.tsx @@ -14,6 +14,7 @@ import { useIsFocused } from '@react-navigation/native'; import { Platform } from 'react-native'; import { readFileAssets, MainBundlePath, readFile } from 'react-native-fs'; import { FormulusWebViewMessageManager } from '../webview/FormulusWebViewHandler'; +import { appEvents, Listener } from '../webview/FormulusMessageHandlers'; import { FormInitData } from '../webview/FormulusInterfaceDefinition'; import { colors } from '../theme/colors'; import { loadSettingsHydrationFromStorage } from '../services/SettingsHydrationCache'; @@ -319,6 +320,33 @@ const CustomAppWebView = forwardRef< return manager; }, [appName]); + useEffect(() => { + const onLocationUpdate = (payload: { + fieldId: string; + location: Record; + }) => { + if (!webViewRef.current) return; + webViewRef.current.postMessage( + JSON.stringify({ + type: 'locationWatchUpdate', + fieldId: payload.fieldId, + result: { + fieldId: payload.fieldId, + status: 'success', + data: payload.location, + }, + }), + ); + }; + appEvents.addListener('locationWatchUpdate', onLocationUpdate as Listener); + return () => { + appEvents.removeListener( + 'locationWatchUpdate', + onLocationUpdate as Listener, + ); + }; + }, []); + const handleNavigationStateChange = useCallback( (navState: WebViewNavigation) => { const newCanGoBack = navState.canGoBack; diff --git a/formulus/src/components/QRScannerModalImpl.tsx b/formulus/src/components/QRScannerModalImpl.tsx index 3e40d9fb7..6ec97166a 100644 --- a/formulus/src/components/QRScannerModalImpl.tsx +++ b/formulus/src/components/QRScannerModalImpl.tsx @@ -208,7 +208,11 @@ const QRScannerModalImpl: React.FC = ({ } return ( - + void) | null = null; + /** Custom-app watch (map tab) — separate from formplayer observation session. */ + private appWatchFieldId: string | null = null; + private appWatchCleanup: (() => void) | null = null; + private constructor() {} public static getInstance(): GeolocationService { @@ -139,6 +144,74 @@ export class GeolocationService { this.cachedAt = 0; } + private emitAppWatchUpdate(fieldId: string, location: ObservationGeolocation): void { + appEvents.emit('locationWatchUpdate', { + fieldId, + location: { + type: 'location' as const, + latitude: location.latitude || 0, + longitude: location.longitude || 0, + accuracy: location.accuracy, + altitude: location.altitude, + altitudeAccuracy: location.altitude_accuracy, + timestamp: location.timestamp ?? new Date().toISOString(), + }, + }); + } + + /** Start battery-conscious watch for custom apps; returns cleanup. */ + public startAppLocationWatch(fieldId: string): () => void { + this.stopAppLocationWatch(); + this.appWatchFieldId = fieldId; + + let cancelled = false; + let watchId: number | null = null; + + const push = (loc: ObservationGeolocation | null) => { + if (!loc || cancelled || this.appWatchFieldId !== fieldId) return; + this.mergeBestCandidate(loc); + this.emitAppWatchUpdate(fieldId, loc); + }; + + void this.getPositionOnce(this.freshConfig).then(push); + + void (async () => { + const ok = await hasLocationPermission(); + if (cancelled || !ok) return; + watchId = Geolocation.watchPosition( + position => push(this.convertToObservationGeolocation(position)), + error => console.warn('App location watch error:', error), + { enableHighAccuracy: true, distanceFilter: 20 }, + ); + })(); + + const cleanup = () => { + cancelled = true; + if (watchId != null) { + Geolocation.clearWatch(watchId); + watchId = null; + } + if (this.appWatchFieldId === fieldId) { + this.appWatchFieldId = null; + } + if (this.appWatchCleanup === cleanup) { + this.appWatchCleanup = null; + } + }; + + this.appWatchCleanup = cleanup; + return cleanup; + } + + public stopAppLocationWatch(fieldId?: string): void { + if (!this.appWatchCleanup) return; + if (fieldId && this.appWatchFieldId !== fieldId) return; + const run = this.appWatchCleanup; + this.appWatchCleanup = null; + this.appWatchFieldId = null; + run(); + } + private async getPositionOnce( config: GeolocationConfig, ): Promise { diff --git a/formulus/src/services/QrcodeRequestCoordinator.ts b/formulus/src/services/QrcodeRequestCoordinator.ts new file mode 100644 index 000000000..7f32d015b --- /dev/null +++ b/formulus/src/services/QrcodeRequestCoordinator.ts @@ -0,0 +1,63 @@ +/** + * Ensures at most one in-flight `requestQrcode` bridge call; settles on cancel. + */ + +export type QrcodeBridgeResult = { + fieldId?: string; + status: string; + message?: string; + data?: unknown; +}; + +type ActiveRequest = { + fieldId: string; + resolve: (result: unknown) => void; + settled: boolean; +}; + +export class QrcodeRequestCoordinator { + private active: ActiveRequest | null = null; + + /** Start a QR request, or reject immediately if the scanner is already open. */ + request(fieldId: string): Promise { + if (this.active && !this.active.settled) { + return Promise.resolve({ + fieldId, + status: 'error', + message: 'QR scanner is already open', + } satisfies QrcodeBridgeResult); + } + + return new Promise(resolve => { + this.active = { fieldId, resolve, settled: false }; + }); + } + + /** Resolve the active request (idempotent). */ + settle(result: unknown): void { + if (!this.active || this.active.settled) return; + this.active.settled = true; + const { resolve } = this.active; + this.active = null; + resolve(result); + } + + /** Cancel the active request if it matches `fieldId` (or any if omitted). */ + cancel(fieldId?: string): void { + if (!this.active || this.active.settled) return; + if (fieldId && this.active.fieldId !== fieldId) return; + + this.settle({ + fieldId: this.active.fieldId, + status: 'cancelled', + message: 'QR scan cancelled', + } satisfies QrcodeBridgeResult); + } + + getActiveFieldId(): string | null { + if (!this.active || this.active.settled) return null; + return this.active.fieldId; + } +} + +export const qrcodeRequestCoordinator = new QrcodeRequestCoordinator(); diff --git a/formulus/src/services/__tests__/QrcodeRequestCoordinator.test.ts b/formulus/src/services/__tests__/QrcodeRequestCoordinator.test.ts new file mode 100644 index 000000000..6a404ef7c --- /dev/null +++ b/formulus/src/services/__tests__/QrcodeRequestCoordinator.test.ts @@ -0,0 +1,42 @@ +import { QrcodeRequestCoordinator } from '../QrcodeRequestCoordinator'; + +describe('QrcodeRequestCoordinator', () => { + it('rejects a second request while the scanner is open', async () => { + const coordinator = new QrcodeRequestCoordinator(); + void coordinator.request('field-a'); + + const second = await coordinator.request('field-b'); + expect(second).toEqual({ + fieldId: 'field-b', + status: 'error', + message: 'QR scanner is already open', + }); + }); + + it('settles the active request once', async () => { + const coordinator = new QrcodeRequestCoordinator(); + const promise = coordinator.request('field-a'); + + coordinator.settle({ fieldId: 'field-a', status: 'success', data: 'x' }); + coordinator.settle({ fieldId: 'field-a', status: 'cancelled' }); + + await expect(promise).resolves.toEqual({ + fieldId: 'field-a', + status: 'success', + data: 'x', + }); + }); + + it('cancels with a cancelled status', async () => { + const coordinator = new QrcodeRequestCoordinator(); + const promise = coordinator.request('field-a'); + + coordinator.cancel('field-a'); + + await expect(promise).resolves.toEqual({ + fieldId: 'field-a', + status: 'cancelled', + message: 'QR scan cancelled', + }); + }); +}); diff --git a/formulus/src/webview/FormulusInterfaceDefinition.ts b/formulus/src/webview/FormulusInterfaceDefinition.ts index 7495db379..5dfa73e94 100644 --- a/formulus/src/webview/FormulusInterfaceDefinition.ts +++ b/formulus/src/webview/FormulusInterfaceDefinition.ts @@ -478,6 +478,23 @@ export interface FormulusInterface { */ requestLocation(fieldId: string): Promise; + /** + * Return the best cached device fix (if any) without forcing a new GPS read. + * @param fieldId Optional field id for parity with `requestLocation` + */ + getCachedLocation(fieldId?: string): Promise; + + /** + * Subscribe to location updates while the custom app WebView is active. + * Updates arrive as `locationWatchUpdate` window messages. + */ + watchLocation( + fieldId: string, + ): Promise<{ status: 'started' | 'error'; message?: string }>; + + /** Stop a prior `watchLocation` subscription for `fieldId`. */ + stopWatchLocation(fieldId: string): Promise; + /** * Request file selection for a field * @param {string} fieldId - The ID of the field diff --git a/formulus/src/webview/FormulusMessageHandlers.ts b/formulus/src/webview/FormulusMessageHandlers.ts index cffcc592d..d70eeaf00 100644 --- a/formulus/src/webview/FormulusMessageHandlers.ts +++ b/formulus/src/webview/FormulusMessageHandlers.ts @@ -3,6 +3,7 @@ This is where the actual implementation of the methods happens on the React Nati It handles the messages received from the WebView and executes the corresponding native functionality. */ import { GeolocationService } from '../services/GeolocationService'; +import { qrcodeRequestCoordinator } from '../services/QrcodeRequestCoordinator'; import { WebViewMessageEvent, WebView } from 'react-native-webview'; import RNFS from 'react-native-fs'; import * as Keychain from 'react-native-keychain'; @@ -529,27 +530,28 @@ export function createFormulusMessageHandlers(): FormulusMessageHandlers { onRequestQrcode: async (fieldId: string): Promise => { console.log('Request QR code handler called', fieldId); - return new Promise(resolve => { - try { - // Emit event to open QR scanner modal - appEvents.emit('openQRScanner', { - fieldId, - onResult: (result: unknown) => { - console.log('QR scan result received:', result); - resolve(result); - }, - }); - } catch (error) { - console.error('Error in QR code handler:', error); - const errorMessage = - error instanceof Error ? error.message : 'Unknown error'; - resolve({ - fieldId, - status: 'error', - message: `QR code error: ${errorMessage}`, - }); - } - }); + const promise = qrcodeRequestCoordinator.request(fieldId); + + try { + appEvents.emit('openQRScanner', { + fieldId, + onResult: (result: unknown) => { + console.log('QR scan result received:', result); + qrcodeRequestCoordinator.settle(result); + }, + }); + } catch (error) { + console.error('Error in QR code handler:', error); + const errorMessage = + error instanceof Error ? error.message : 'Unknown error'; + qrcodeRequestCoordinator.settle({ + fieldId, + status: 'error', + message: `QR code error: ${errorMessage}`, + }); + } + + return promise; }, onRequestSignature: async (fieldId: string): Promise => { console.log('Request signature handler called', fieldId); @@ -686,6 +688,71 @@ export function createFormulusMessageHandlers(): FormulusMessageHandlers { } }); }, + + onGetCachedLocation: async ( + payload: string | { fieldId?: string }, + ): Promise => { + const fieldId = + typeof payload === 'string' + ? payload + : typeof payload?.fieldId === 'string' + ? payload.fieldId + : ''; + const geolocationService = GeolocationService.getInstance(); + const position = geolocationService.getCachedLocation(); + if (!position) return null; + return { + fieldId, + status: 'success' as const, + data: { + type: 'location' as const, + latitude: position.latitude || 0, + longitude: position.longitude || 0, + accuracy: position.accuracy, + altitude: position.altitude, + altitudeAccuracy: position.altitude_accuracy, + timestamp: position.timestamp ?? new Date().toISOString(), + }, + }; + }, + + onWatchLocation: async ( + payload: string | { fieldId?: string }, + ): Promise => { + const fieldId = + typeof payload === 'string' + ? payload + : typeof payload?.fieldId === 'string' + ? payload.fieldId + : ''; + if (!fieldId) { + return { status: 'error', message: 'fieldId is required' }; + } + try { + GeolocationService.getInstance().startAppLocationWatch(fieldId); + return { status: 'started' }; + } catch (error) { + const message = + error instanceof Error ? error.message : 'watchLocation failed'; + return { status: 'error', message }; + } + }, + + onStopWatchLocation: async ( + payload: string | { fieldId?: string }, + ): Promise => { + const fieldId = + typeof payload === 'string' + ? payload + : typeof payload?.fieldId === 'string' + ? payload.fieldId + : ''; + GeolocationService.getInstance().stopAppLocationWatch( + fieldId || undefined, + ); + return { status: 'stopped' }; + }, + onRequestVideo: async (fieldId: string): Promise => { return new Promise((resolve, reject) => { try { From 31ca78019b0e771ac09917e62c4eb7a38f5e3f91 Mon Sep 17 00:00:00 2001 From: Emil Rossing Date: Thu, 18 Jun 2026 14:00:23 +0000 Subject: [PATCH 14/46] fix(formulus): sub-observation regression fix --- formulus-formplayer/src/App.tsx | 38 +++++++++++++++++-- .../SubObservationQuestionRenderer.tsx | 14 ++++++- .../src/renderers/subObservationHelpers.ts | 4 +- formulus-formplayer/src/utils/autoSequence.ts | 25 ++++++++++-- formulus/src/components/CustomAppWebView.tsx | 5 ++- formulus/src/services/GeolocationService.ts | 5 ++- 6 files changed, 78 insertions(+), 13 deletions(-) diff --git a/formulus-formplayer/src/App.tsx b/formulus-formplayer/src/App.tsx index e68abb16f..350d1c1ff 100644 --- a/formulus-formplayer/src/App.tsx +++ b/formulus-formplayer/src/App.tsx @@ -114,6 +114,31 @@ function isSubObservationSession(init: FormInitData): boolean { return Boolean(i.subObservationMode || i.returnOnly); } +function readAutoSequenceRuntime() { + const w = window as unknown as { + formulusSubObservationContext?: Record | null; + formulusSessionContext?: Record | null; + }; + return { + subObservationContext: w.formulusSubObservationContext ?? null, + sessionContext: w.formulusSessionContext ?? null, + }; +} + +/** Root observation data aligned to schema, with platform `x-autoSequence` applied on open. */ +function prepareInitialFormData( + raw: Record, + formSchema: unknown, +): Record { + const root = prepareRootObservationData(raw, formSchema); + const { data } = applyAutoSequences( + root, + formSchema as JsonSchema7 | undefined, + readAutoSequenceRuntime(), + ); + return data; +} + // Mock and DevTestbed are loaded only in development via dynamic import (see index.tsx). // This keeps ~2000+ lines of mock code out of production bundles. function isMockActive(): boolean { @@ -586,7 +611,9 @@ function App() { ]; (window as unknown as Record).formulusSessionContext = sessionContext ?? null; - (window as unknown as Record).formulusSubObservationContext = + ( + window as unknown as Record + ).formulusSubObservationContext = sessionContext && typeof sessionContext === 'object' && 'subObservation' in (sessionContext as Record) @@ -596,7 +623,10 @@ function App() { if (savedData && Object.keys(savedData).length > 0) { console.log('Preloading saved data:', savedData); setData( - prepareRootObservationData(savedData as FormData, formSchemaTyped), + prepareInitialFormData( + savedData as Record, + formSchemaTyped, + ), ); } else if (!isSubObservationSession(initData)) { const formVersion = (formSchemaTyped as { version?: string }) @@ -617,14 +647,14 @@ function App() { ); const withSticky = applyStickyDefaults(withTokens, relevantSticky); console.log('Preloading initialization form values:', withSticky); - setData(prepareRootObservationData(withSticky, formSchemaTyped)); + setData(prepareInitialFormData(withSticky, formSchemaTyped)); } else { const defaultData = applySchemaDefaultTokens( initialFormDataFromParams(params), formSchemaTyped, ); console.log('Preloading initialization form values:', defaultData); - setData(prepareRootObservationData(defaultData, formSchemaTyped)); + setData(prepareInitialFormData(defaultData, formSchemaTyped)); } console.log('Form params (if any, beyond schemas/data):', params); diff --git a/formulus-formplayer/src/renderers/SubObservationQuestionRenderer.tsx b/formulus-formplayer/src/renderers/SubObservationQuestionRenderer.tsx index bd015a231..7f6f12be5 100644 --- a/formulus-formplayer/src/renderers/SubObservationQuestionRenderer.tsx +++ b/formulus-formplayer/src/renderers/SubObservationQuestionRenderer.tsx @@ -255,6 +255,7 @@ const SubObservationQuestionRendererInner: React.FC = ({ const handleAdd = useCallback(async () => { if (!enabled || missingKeys.length || !childFormType) return; const client = FormulusClient.getInstance(); + let merged = false; try { setBusyId('add'); const pv = resolveParentValue(formData, parentValuePath); @@ -279,6 +280,7 @@ const SubObservationQuestionRendererInner: React.FC = ({ if (result?.status === 'form_submitted' && result.formData) { const row = result.formData as Record; pushSorted([...rows, row]); + merged = true; } } catch (e) { setError( @@ -286,7 +288,11 @@ const SubObservationQuestionRendererInner: React.FC = ({ ); } finally { setBusyId(null); - window.setTimeout(() => refreshRowsFromFormData(), 0); + // After a successful merge, pushSorted already updated local state and + // JsonForms data; refreshing from props races and drops the new row. + if (!merged) { + window.setTimeout(() => refreshRowsFromFormData(), 0); + } } }, [ enabled, @@ -305,6 +311,7 @@ const SubObservationQuestionRendererInner: React.FC = ({ async (row: Record, index: number) => { if (!enabled || !childFormType || missingKeys.length) return; const client = FormulusClient.getInstance(); + let merged = false; try { setBusyId(`edit_${index}`); const pv = resolveParentValue(formData, parentValuePath); @@ -341,6 +348,7 @@ const SubObservationQuestionRendererInner: React.FC = ({ i === index ? (result.formData as Record) : r, ); pushSorted(updated); + merged = true; } } catch (e) { setError( @@ -348,7 +356,9 @@ const SubObservationQuestionRendererInner: React.FC = ({ ); } finally { setBusyId(null); - window.setTimeout(() => refreshRowsFromFormData(), 0); + if (!merged) { + window.setTimeout(() => refreshRowsFromFormData(), 0); + } } }, [ diff --git a/formulus-formplayer/src/renderers/subObservationHelpers.ts b/formulus-formplayer/src/renderers/subObservationHelpers.ts index 2765b01ad..779ebab94 100644 --- a/formulus-formplayer/src/renderers/subObservationHelpers.ts +++ b/formulus-formplayer/src/renderers/subObservationHelpers.ts @@ -173,7 +173,9 @@ export function buildSubObservationOpenParams( ? (parentSessionContext.subObservation as Record) : {}; - const subObservation: Record = { ...inheritedSubObservation }; + const subObservation: Record = { + ...inheritedSubObservation, + }; for (const [key, value] of Object.entries(resolved)) { if (value !== '' && value != null) { subObservation[key] = value; diff --git a/formulus-formplayer/src/utils/autoSequence.ts b/formulus-formplayer/src/utils/autoSequence.ts index 295afd475..2a333aeda 100644 --- a/formulus-formplayer/src/utils/autoSequence.ts +++ b/formulus-formplayer/src/utils/autoSequence.ts @@ -116,7 +116,10 @@ function computeNextValue( const contextKey = binding.config.contextKey ?? 'quartos'; const subCtx = runtime.subObservationContext ?? - (runtime.sessionContext?.subObservation as Record | null); + (runtime.sessionContext?.subObservation as Record< + string, + unknown + > | null); collectFieldValues( subCtx?.[contextKey], fieldName, @@ -124,9 +127,21 @@ function computeNextValue( data, values, ); - collectFieldValues(data, fieldName, binding.config.contextFilter, data, values); + collectFieldValues( + data, + fieldName, + binding.config.contextFilter, + data, + values, + ); } else { - collectFieldValues(data, fieldName, binding.config.contextFilter, data, values); + collectFieldValues( + data, + fieldName, + binding.config.contextFilter, + data, + values, + ); } return maxFromValues(values) + 1; @@ -143,7 +158,9 @@ function collectBindings( if (props) { for (const [name, childSchema] of Object.entries(props)) { const child = childSchema as JsonSchema7; - const seq = child['x-autoSequence'] as AutoSequenceConfig | undefined; + const seq = (child as Record)['x-autoSequence'] as + | AutoSequenceConfig + | undefined; if (seq && typeof seq === 'object') { bindings.push({ field: name, diff --git a/formulus/src/components/CustomAppWebView.tsx b/formulus/src/components/CustomAppWebView.tsx index 3ace6dfa6..f298471e8 100644 --- a/formulus/src/components/CustomAppWebView.tsx +++ b/formulus/src/components/CustomAppWebView.tsx @@ -338,7 +338,10 @@ const CustomAppWebView = forwardRef< }), ); }; - appEvents.addListener('locationWatchUpdate', onLocationUpdate as Listener); + appEvents.addListener( + 'locationWatchUpdate', + onLocationUpdate as Listener, + ); return () => { appEvents.removeListener( 'locationWatchUpdate', diff --git a/formulus/src/services/GeolocationService.ts b/formulus/src/services/GeolocationService.ts index 6fe34da15..2196e0114 100644 --- a/formulus/src/services/GeolocationService.ts +++ b/formulus/src/services/GeolocationService.ts @@ -144,7 +144,10 @@ export class GeolocationService { this.cachedAt = 0; } - private emitAppWatchUpdate(fieldId: string, location: ObservationGeolocation): void { + private emitAppWatchUpdate( + fieldId: string, + location: ObservationGeolocation, + ): void { appEvents.emit('locationWatchUpdate', { fieldId, location: { From 5b7df081c82d4ad485d25f51f0ed344802023a1f Mon Sep 17 00:00:00 2001 From: Emil Rossing Date: Thu, 18 Jun 2026 14:16:06 +0000 Subject: [PATCH 15/46] wip --- .../SubObservationQuestionRenderer.tsx | 93 +++++-------------- .../src/renderers/subObservationHelpers.ts | 11 ++- 2 files changed, 29 insertions(+), 75 deletions(-) diff --git a/formulus-formplayer/src/renderers/SubObservationQuestionRenderer.tsx b/formulus-formplayer/src/renderers/SubObservationQuestionRenderer.tsx index 7f6f12be5..cfbc052bc 100644 --- a/formulus-formplayer/src/renderers/SubObservationQuestionRenderer.tsx +++ b/formulus-formplayer/src/renderers/SubObservationQuestionRenderer.tsx @@ -4,7 +4,7 @@ * `openFormplayer` with `subObservationMode`. */ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { withJsonFormsControlProps, useJsonForms } from '@jsonforms/react'; import { ControlProps, rankWith, schemaMatches } from '@jsonforms/core'; import { Box, Typography, Button, IconButton, Tooltip } from '@mui/material'; @@ -153,52 +153,23 @@ const SubObservationQuestionRendererInner: React.FC = ({ [valueRows, missingKeys, config.orderBy], ); - const [rows, setRows] = useState[]>(sortedFromProps); + // JsonForms `data` is the source of truth; pushSorted writes via handleChange only. + const rows = sortedFromProps; + const [error, setError] = useState(null); const [busyId, setBusyId] = useState(null); - const [prevSortedFromProps, setPrevSortedFromProps] = - useState(sortedFromProps); - if (sortedFromProps !== prevSortedFromProps) { - setPrevSortedFromProps(sortedFromProps); - setRows(sortedFromProps); - } - - useEffect(() => { - function refresh() { - setRows(prev => sortRows(prev, config.orderBy as OrderBySpec)); - } - function onVisibility() { - if (document.visibilityState === 'visible') refresh(); - } - document.addEventListener('visibilitychange', onVisibility); - window.addEventListener('focus', refresh); - return () => { - document.removeEventListener('visibilitychange', onVisibility); - window.removeEventListener('focus', refresh); - }; - }, [config.orderBy]); - - useEffect(() => { - const prev = - typeof window !== 'undefined' - ? (window as Window & { onReceiveFocus?: () => void }).onReceiveFocus - : undefined; - const wrapped = () => { - if (typeof prev === 'function') prev(); - setRows(r => sortRows(r, config.orderBy as OrderBySpec)); - }; - if (typeof window !== 'undefined') { - (window as Window & { onReceiveFocus?: () => void }).onReceiveFocus = - wrapped; - } - return () => { - if (typeof window !== 'undefined') { - (window as Window & { onReceiveFocus?: () => void }).onReceiveFocus = - prev; - } - }; - }, [config.orderBy]); + const getCurrentRows = useCallback((): Record[] => { + const root = jsonForms.core?.data; + const raw = + root && typeof root === 'object' && path + ? readDataPath(root as Record, path) + : data; + return sortRows( + coerceSubObservationRows(raw), + config.orderBy as OrderBySpec, + ) as Record[]; + }, [jsonForms.core?.data, data, path, config.orderBy]); const columns = useMemo(() => buildColumns(config, rows), [config, rows]); @@ -238,24 +209,15 @@ const SubObservationQuestionRendererInner: React.FC = ({ const pushSorted = useCallback( (next: Record[]) => { const sorted = sortRows(next, config.orderBy as OrderBySpec); - setRows(sorted); - setPrevSortedFromProps(sorted); handleChange(path, sorted); requestFormRevalidation(); }, [config.orderBy, handleChange, path, requestFormRevalidation], ); - const refreshRowsFromFormData = useCallback(() => { - const sorted = sortRows(valueRows, config.orderBy as OrderBySpec); - setPrevSortedFromProps(sorted); - setRows(sorted as Record[]); - }, [valueRows, config.orderBy]); - const handleAdd = useCallback(async () => { if (!enabled || missingKeys.length || !childFormType) return; const client = FormulusClient.getInstance(); - let merged = false; try { setBusyId('add'); const pv = resolveParentValue(formData, parentValuePath); @@ -279,8 +241,7 @@ const SubObservationQuestionRendererInner: React.FC = ({ ); if (result?.status === 'form_submitted' && result.formData) { const row = result.formData as Record; - pushSorted([...rows, row]); - merged = true; + pushSorted([...getCurrentRows(), row]); } } catch (e) { setError( @@ -288,11 +249,6 @@ const SubObservationQuestionRendererInner: React.FC = ({ ); } finally { setBusyId(null); - // After a successful merge, pushSorted already updated local state and - // JsonForms data; refreshing from props races and drops the new row. - if (!merged) { - window.setTimeout(() => refreshRowsFromFormData(), 0); - } } }, [ enabled, @@ -302,16 +258,14 @@ const SubObservationQuestionRendererInner: React.FC = ({ parentValuePath, parentKey, config, - rows, + getCurrentRows, pushSorted, - refreshRowsFromFormData, ]); const handleEdit = useCallback( async (row: Record, index: number) => { if (!enabled || !childFormType || missingKeys.length) return; const client = FormulusClient.getInstance(); - let merged = false; try { setBusyId(`edit_${index}`); const pv = resolveParentValue(formData, parentValuePath); @@ -344,11 +298,10 @@ const SubObservationQuestionRendererInner: React.FC = ({ result.status === 'form_updated') && result.formData ) { - const updated = rows.map((r, i) => + const updated = getCurrentRows().map((r, i) => i === index ? (result.formData as Record) : r, ); pushSorted(updated); - merged = true; } } catch (e) { setError( @@ -356,9 +309,6 @@ const SubObservationQuestionRendererInner: React.FC = ({ ); } finally { setBusyId(null); - if (!merged) { - window.setTimeout(() => refreshRowsFromFormData(), 0); - } } }, [ @@ -368,9 +318,8 @@ const SubObservationQuestionRendererInner: React.FC = ({ formData, parentValuePath, config, - rows, + getCurrentRows, pushSorted, - refreshRowsFromFormData, ], ); @@ -393,7 +342,7 @@ const SubObservationQuestionRendererInner: React.FC = ({ return; } try { - pushSorted(rows.filter((_, i) => i !== index)); + pushSorted(getCurrentRows().filter((_, i) => i !== index)); } catch (e) { setError( e instanceof Error ? e.message : 'Unable to delete sub-observation', @@ -404,7 +353,7 @@ const SubObservationQuestionRendererInner: React.FC = ({ enabled, allowDelete, config.displayField, - rows, + getCurrentRows, pushSorted, deleteFallbackLabel, ], diff --git a/formulus-formplayer/src/renderers/subObservationHelpers.ts b/formulus-formplayer/src/renderers/subObservationHelpers.ts index 779ebab94..c35812b2a 100644 --- a/formulus-formplayer/src/renderers/subObservationHelpers.ts +++ b/formulus-formplayer/src/renderers/subObservationHelpers.ts @@ -204,11 +204,14 @@ export function formatCellValue(value: unknown): string { return String(value); } +/** Stable empty reference — avoid retriggering sub-observation row sync every render. */ +const EMPTY_SUB_OBSERVATION_ROWS: unknown[] = []; + /** Normalizes JsonForms control data into an array of row payloads. */ export function coerceSubObservationRows(value: unknown): unknown[] { - if (value == null) return []; + if (value == null) return EMPTY_SUB_OBSERVATION_ROWS; if (Array.isArray(value)) return value; - return []; + return EMPTY_SUB_OBSERVATION_ROWS; } export function readSubObservationField( @@ -274,7 +277,9 @@ export function sortRows( r && typeof r === 'object' ? (r as Record) : {}, ); - if (!asRecords.length) return asRecords; + if (!asRecords.length) { + return EMPTY_SUB_OBSERVATION_ROWS as Record[]; + } let key: string | null = null; let direction = 'desc'; From 59f9f06bc299803b3fca64e3e21476043a6db19e Mon Sep 17 00:00:00 2001 From: Emil Rossing Date: Thu, 18 Jun 2026 14:32:10 +0000 Subject: [PATCH 16/46] fix(desktop): sub-observation regression fix and lint issues addressed --- desktop/src/components/FormplayerEmbed.tsx | 6 ++- desktop/src/lib/formPreviewBridge.ts | 10 ++++- desktop/src/pages/FormPreviewPage.tsx | 44 +++++++++++++++++-- formulus/package.json | 18 -------- formulus/pnpm-workspace.yaml | 17 +++++++ formulus/src/components/CustomAppWebView.tsx | 4 +- formulus/src/components/MenuDrawer.tsx | 2 +- formulus/src/navigation/MainAppNavigator.tsx | 3 +- formulus/src/navigation/MainTabNavigator.tsx | 2 +- formulus/src/screens/AboutScreen.tsx | 11 +++-- formulus/src/screens/FormsScreen.tsx | 17 +++++-- formulus/src/screens/HelpScreen.tsx | 11 +++-- formulus/src/screens/HomeScreen.tsx | 12 ++--- formulus/src/screens/MoreScreen.tsx | 3 +- .../src/screens/ObservationDetailScreen.tsx | 15 +++++-- formulus/src/screens/ObservationsScreen.tsx | 17 +++++-- formulus/src/screens/SettingsScreen.tsx | 13 +++--- 17 files changed, 145 insertions(+), 60 deletions(-) create mode 100644 formulus/pnpm-workspace.yaml diff --git a/desktop/src/components/FormplayerEmbed.tsx b/desktop/src/components/FormplayerEmbed.tsx index e655d2a4d..b5fed5097 100644 --- a/desktop/src/components/FormplayerEmbed.tsx +++ b/desktop/src/components/FormplayerEmbed.tsx @@ -40,6 +40,8 @@ export type FormplayerEmbedProps = { /** Full `FormInitData` for the embedded formplayer; `null` shows `emptyMessage` only. */ formInitData: FormInitData | null; emptyMessage?: string; + /** Fired when the iframe document loads (used to register `contentWindow` for bridge routing). */ + onContentWindowReady?: (contentWindow: Window | null) => void; }; /** @@ -54,6 +56,7 @@ export const FormplayerEmbed = forwardRef< { formInitData, emptyMessage = 'Select a form type and apply params/saved JSON to load the preview.', + onContentWindowReady, }, ref, ) { @@ -122,6 +125,7 @@ export const FormplayerEmbed = forwardRef< window.clearTimeout(timeoutRef.current); timeoutRef.current = null; } + onContentWindowReady?.(el.contentWindow); setLoading(false); }; // WebView2 can behave inconsistently with blob: + module scripts in packaged apps. @@ -135,7 +139,7 @@ export const FormplayerEmbed = forwardRef< setError(e instanceof Error ? e.message : String(e)); setLoading(false); } - }, [formInitData]); + }, [formInitData, onContentWindowReady]); useEffect(() => { void mountBlob(); diff --git a/desktop/src/lib/formPreviewBridge.ts b/desktop/src/lib/formPreviewBridge.ts index 60be09a23..42ba2aa9a 100644 --- a/desktop/src/lib/formPreviewBridge.ts +++ b/desktop/src/lib/formPreviewBridge.ts @@ -337,8 +337,7 @@ export async function handleFormPreviewBridgeMessage( const subObservationMode = Boolean(options?.subObservationMode); if (subObservationMode && ctx.onDeferOpenSubObservation) { - const parentIframe = - resolveBridgeReplyIframe(eventSource, ctx) ?? ctx.iframe; + const parentIframe = resolveBridgeReplyIframe(eventSource, ctx); if (parentIframe) { ctx.onDeferOpenSubObservation({ parentIframe, @@ -351,6 +350,13 @@ export async function handleFormPreviewBridgeMessage( }); return; } + reply( + 'openFormplayer', + stubReason( + 'Could not identify the parent formplayer iframe for nested sub-observation open.', + ), + ); + return; } if (ctx.onOpenFormplayerNavigate) { diff --git a/desktop/src/pages/FormPreviewPage.tsx b/desktop/src/pages/FormPreviewPage.tsx index c1be7171c..bc67b35ad 100644 --- a/desktop/src/pages/FormPreviewPage.tsx +++ b/desktop/src/pages/FormPreviewPage.tsx @@ -26,11 +26,16 @@ const DEFAULT_JSON = '{}'; * WebKit / WCO (Tauri): `MessageEvent.source` may not be strictly `===` to * `iframe.contentWindow`, and `instanceof Window` can be false for iframe globals. * `window.frameElement === iframe` identifies the embedding element reliably for same-origin frames. + * Callers may also pass a `contentWindow` captured on iframe `load` (srcdoc embeds). */ function messageSourceMatchesIframe( source: Window, iframe: HTMLIFrameElement | null | undefined, + registeredContentWindow?: Window | null, ): boolean { + if (registeredContentWindow && source === registeredContentWindow) { + return true; + } if (!iframe) { return false; } @@ -82,6 +87,7 @@ export function FormPreviewPage() { const [formInitData, setFormInitData] = useState(null); const iframeRef = useRef(null); + const rootContentWindowRef = useRef(null); const finalizeResolverRef = useRef< ((v: { result?: string; error?: string }) => void) | null >(null); @@ -95,6 +101,9 @@ export function FormPreviewPage() { const nestedIframeByMessageIdRef = useRef< Map >(new Map()); + const nestedContentWindowByMessageIdRef = useRef>( + new Map(), + ); useEffect(() => { nestedSessionsRef.current = nestedSessions; @@ -279,6 +288,7 @@ export function FormPreviewPage() { }, ); nestedIframeByMessageIdRef.current.delete(top.parentMessageId); + nestedContentWindowByMessageIdRef.current.delete(top.parentMessageId); return prev.slice(0, -1); }); }, []); @@ -338,6 +348,7 @@ export function FormPreviewPage() { }, }); nestedIframeByMessageIdRef.current.delete(messageId); + nestedContentWindowByMessageIdRef.current.delete(messageId); setNestedSessions(prev => prev.filter(sess => sess.parentMessageId !== messageId), ); @@ -358,7 +369,10 @@ export function FormPreviewPage() { return null; } const topEl = nestedIframeByMessageIdRef.current.get(top.parentMessageId); - if (!messageSourceMatchesIframe(eventSource, topEl)) { + const topCw = nestedContentWindowByMessageIdRef.current.get( + top.parentMessageId, + ); + if (!messageSourceMatchesIframe(eventSource, topEl, topCw)) { return null; } @@ -387,6 +401,7 @@ export function FormPreviewPage() { ); nestedIframeByMessageIdRef.current.delete(top.parentMessageId); + nestedContentWindowByMessageIdRef.current.delete(top.parentMessageId); setNestedSessions(prev => prev.slice(0, -1)); return { result: syntheticResult }; @@ -395,12 +410,21 @@ export function FormPreviewPage() { ); const resolveReplyIframe = useCallback((source: Window) => { - if (messageSourceMatchesIframe(source, iframeRef.current)) { + if ( + messageSourceMatchesIframe( + source, + iframeRef.current, + rootContentWindowRef.current, + ) + ) { return iframeRef.current; } for (const s of nestedSessionsRef.current) { const el = nestedIframeByMessageIdRef.current.get(s.parentMessageId); - if (messageSourceMatchesIframe(source, el)) { + const cw = nestedContentWindowByMessageIdRef.current.get( + s.parentMessageId, + ); + if (messageSourceMatchesIframe(source, el, cw)) { return el ?? null; } } @@ -477,6 +501,17 @@ export function FormPreviewPage() { const map = nestedIframeByMessageIdRef.current; if (el) { map.set(session.parentMessageId, el); + } else { + map.delete(session.parentMessageId); + nestedContentWindowByMessageIdRef.current.delete( + session.parentMessageId, + ); + } + }} + onContentWindowReady={cw => { + const map = nestedContentWindowByMessageIdRef.current; + if (cw) { + map.set(session.parentMessageId, cw); } else { map.delete(session.parentMessageId); } @@ -571,6 +606,9 @@ export function FormPreviewPage() {
{ + rootContentWindowRef.current = cw; + }} formInitData={formInitData} emptyMessage="Choose a form type to load schema and ui from the active bundle, then adjust params / saved JSON and click Apply." /> diff --git a/formulus/package.json b/formulus/package.json index 9ce911323..88b0fd84f 100644 --- a/formulus/package.json +++ b/formulus/package.json @@ -99,23 +99,5 @@ }, "engines": { "node": ">=20" - }, - "pnpm": { - "onlyBuiltDependencies": [ - "@nestjs/core", - "@openapitools/openapi-generator-cli", - "esbuild", - "unrs-resolver" - ], - "overrides": { - "jest-environment-node": "^30.4.1", - "jest-haste-map": "^30.4.1", - "jest-message-util": "^30.4.1", - "jest-mock": "^30.4.1", - "jest-regex-util": "^30.4.0", - "jest-util": "^30.4.1", - "jest-validate": "^30.4.1", - "jest-worker": "^30.4.1" - } } } diff --git a/formulus/pnpm-workspace.yaml b/formulus/pnpm-workspace.yaml new file mode 100644 index 000000000..4535ae0a6 --- /dev/null +++ b/formulus/pnpm-workspace.yaml @@ -0,0 +1,17 @@ +# pnpm 10+ reads project settings here (not package.json#pnpm). +# Allow lifecycle scripts only for these native/tooling deps. +onlyBuiltDependencies: + - '@nestjs/core' + - '@openapitools/openapi-generator-cli' + - esbuild + - unrs-resolver + +overrides: + jest-environment-node: ^30.4.1 + jest-haste-map: ^30.4.1 + jest-message-util: ^30.4.1 + jest-mock: ^30.4.1 + jest-regex-util: ^30.4.0 + jest-util: ^30.4.1 + jest-validate: ^30.4.1 + jest-worker: ^30.4.1 diff --git a/formulus/src/components/CustomAppWebView.tsx b/formulus/src/components/CustomAppWebView.tsx index f298471e8..02cc3e4fc 100644 --- a/formulus/src/components/CustomAppWebView.tsx +++ b/formulus/src/components/CustomAppWebView.tsx @@ -549,10 +549,10 @@ const styles = StyleSheet.create({ flex: 1, }, webViewTransparent: { - backgroundColor: 'transparent', + backgroundColor: colors.neutral.transparent, }, webViewContainerTransparent: { - backgroundColor: 'transparent', + backgroundColor: colors.neutral.transparent, flex: 1, }, }); diff --git a/formulus/src/components/MenuDrawer.tsx b/formulus/src/components/MenuDrawer.tsx index 5f802b431..11a04c129 100644 --- a/formulus/src/components/MenuDrawer.tsx +++ b/formulus/src/components/MenuDrawer.tsx @@ -157,7 +157,7 @@ const MenuDrawer: React.FC = ({ styles.backdrop, { // Fully transparent backdrop: no visual dimming, but still closes on tap - backgroundColor: 'transparent', + backgroundColor: colors.neutral.transparent, marginBottom: bottomPadding, }, ]} diff --git a/formulus/src/navigation/MainAppNavigator.tsx b/formulus/src/navigation/MainAppNavigator.tsx index 54beac2a9..2dc7be990 100644 --- a/formulus/src/navigation/MainAppNavigator.tsx +++ b/formulus/src/navigation/MainAppNavigator.tsx @@ -17,6 +17,7 @@ import { odeTypography, odeScreenHeaderHeight, } from '../theme/odeDesign'; +import colors from '../theme/colors'; const Stack = createStackNavigator(); @@ -142,7 +143,7 @@ const MainAppNavigator: React.FC = () => { options={{ title: 'Observation Details', headerTransparent: true, - headerStyle: { backgroundColor: 'transparent' }, + headerStyle: { backgroundColor: colors.neutral.transparent }, header: props => ( { return ( { return ( + style={[ + styles.container, + { backgroundColor: colors.neutral.transparent }, + ]}> { return ( + style={[ + styles.container, + { backgroundColor: colors.neutral.transparent }, + ]}> { return ( { return ( { const styles = StyleSheet.create({ container: { flex: 1 }, - scrollTransparent: { backgroundColor: 'transparent' }, + scrollTransparent: { backgroundColor: colors.neutral.transparent }, header: { padding: odeSpacing.md, borderBottomWidth: odeBorderWidth.hairline, @@ -330,12 +333,12 @@ const styles = StyleSheet.create({ alignItems: 'center', marginRight: 12, overflow: 'hidden', - backgroundColor: 'transparent', + backgroundColor: colors.neutral.transparent, }, logo: { width: 40, height: 40, - backgroundColor: 'transparent', + backgroundColor: colors.neutral.transparent, }, content: { padding: odeSpacing.md, diff --git a/formulus/src/screens/HomeScreen.tsx b/formulus/src/screens/HomeScreen.tsx index 4e579aa0e..689e6a9b5 100644 --- a/formulus/src/screens/HomeScreen.tsx +++ b/formulus/src/screens/HomeScreen.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useRef } from 'react'; +import React, { useEffect, useState, useRef, useCallback } from 'react'; import { BackHandler, StyleSheet, @@ -65,7 +65,7 @@ const HomeScreen = ({ navigation }: { navigation: any }) => { }, [showConfirm]), ); - const checkAndSetAppUri = async () => { + const checkAndSetAppUri = useCallback(async () => { try { const filePath = `${RNFS.DocumentDirectoryPath}/app/index.html`; const fileExists = await RNFS.exists(filePath); @@ -85,13 +85,13 @@ const HomeScreen = ({ navigation }: { navigation: any }) => { } catch (err) { console.warn('[HomeScreen] Failed to setup app URI:', err); } - }; + }, [reloadTheme]); useEffect(() => { Promise.resolve().then(() => { checkAndSetAppUri(); }); - }, []); + }, [checkAndSetAppUri]); useEffect(() => { const unsubscribe = navigation.addListener('focus', () => { @@ -100,7 +100,7 @@ const HomeScreen = ({ navigation }: { navigation: any }) => { }); }); return unsubscribe; - }, [navigation]); + }, [navigation, checkAndSetAppUri]); useEffect(() => { const onBundleUpdated: Listener = () => { @@ -109,7 +109,7 @@ const HomeScreen = ({ navigation }: { navigation: any }) => { }; appEvents.addListener('bundleUpdated', onBundleUpdated); return () => appEvents.removeListener('bundleUpdated', onBundleUpdated); - }, []); + }, [checkAndSetAppUri]); useEffect(() => { if (localUri) { diff --git a/formulus/src/screens/MoreScreen.tsx b/formulus/src/screens/MoreScreen.tsx index d69efaa42..9c527718d 100644 --- a/formulus/src/screens/MoreScreen.tsx +++ b/formulus/src/screens/MoreScreen.tsx @@ -12,6 +12,7 @@ import { useScreenShellStyle } from '../hooks/useScreenShellStyle'; import MenuDrawer from '../components/MenuDrawer'; import { logout } from '../api/synkronus/Auth'; import { useConfirmModal } from '../contexts/ConfirmModalContext'; +import colors from '../theme/colors'; type MoreScreenNavigationProp = BottomTabNavigationProp< MainTabParamList, @@ -104,7 +105,7 @@ const MoreScreen: React.FC = () => { const styles = StyleSheet.create({ container: { flex: 1, - backgroundColor: 'transparent', + backgroundColor: colors.neutral.transparent, }, }); diff --git a/formulus/src/screens/ObservationDetailScreen.tsx b/formulus/src/screens/ObservationDetailScreen.tsx index 60cf1ee37..d41e622c0 100644 --- a/formulus/src/screens/ObservationDetailScreen.tsx +++ b/formulus/src/screens/ObservationDetailScreen.tsx @@ -237,7 +237,10 @@ const ObservationDetailScreen: React.FC = ({ return ( + style={[ + styles.container, + { backgroundColor: colors.neutral.transparent }, + ]}> = ({ return ( + style={[ + styles.container, + { backgroundColor: colors.neutral.transparent }, + ]}> Observation not found @@ -282,7 +288,10 @@ const ObservationDetailScreen: React.FC = ({ return ( + style={[ + styles.container, + { backgroundColor: colors.neutral.transparent }, + ]}> { return ( + style={[ + styles.container, + { backgroundColor: colors.neutral.transparent }, + ]}> { return ( + style={[ + styles.container, + { backgroundColor: colors.neutral.transparent }, + ]}> { return ( { return false; } }, - [initialServerUrl], + [initialServerUrl, showConfirm], ); const handleLogin = useCallback(async () => { @@ -411,7 +411,10 @@ const SettingsScreen = () => { return ( { @@ -621,12 +624,12 @@ const styles = StyleSheet.create({ alignItems: 'center', marginRight: 12, overflow: 'hidden', - backgroundColor: 'transparent', + backgroundColor: colors.neutral.transparent, }, logo: { width: 40, height: 40, - backgroundColor: 'transparent', + backgroundColor: colors.neutral.transparent, }, brandName: { fontSize: odeTypography.screenTitle, From 031b99f726d358779ad2ba38062442f7ca2e7eec Mon Sep 17 00:00:00 2001 From: Emil Rossing Date: Thu, 18 Jun 2026 16:56:15 +0000 Subject: [PATCH 17/46] fix(formplayer): datahandling on skipFinalize --- desktop/public/formplayer-host-stub.js | 53 +++ desktop/public/formulus-injection.js | 22 ++ desktop/src/components/CustomAppEmbed.tsx | 7 +- desktop/src/components/FormplayerEmbed.tsx | 55 +++- .../lib/__tests__/formPreviewBridge.test.ts | 27 ++ .../formPreviewSubObservationBridge.test.ts | 43 +++ desktop/src/lib/bundleExtensionLoader.ts | 25 +- desktop/src/lib/formPreviewBridge.ts | 70 +++- .../lib/formPreviewSubObservationBridge.ts | 104 ++++++ desktop/src/lib/iframeMessageSource.ts | 25 ++ desktop/src/lib/subObsDebug.ts | 27 ++ desktop/src/pages/FormPreviewPage.tsx | 242 ++++++++------ desktop/src/pages/WorkbenchCustomAppPage.tsx | 18 +- formulus-formplayer/src/App.tsx | 303 ++++++++++++------ .../SubObservationQuestionRenderer.tsx | 117 ++++++- .../src/renderers/SwipeLayoutRenderer.tsx | 28 +- .../renderers/subObservationHelpers.test.ts | 21 ++ .../src/renderers/subObservationHelpers.ts | 29 ++ formulus-formplayer/src/utils/subObsDebug.ts | 62 ++++ 19 files changed, 1050 insertions(+), 228 deletions(-) create mode 100644 desktop/src/lib/__tests__/formPreviewSubObservationBridge.test.ts create mode 100644 desktop/src/lib/formPreviewSubObservationBridge.ts create mode 100644 desktop/src/lib/iframeMessageSource.ts create mode 100644 desktop/src/lib/subObsDebug.ts create mode 100644 formulus-formplayer/src/utils/subObsDebug.ts diff --git a/desktop/public/formplayer-host-stub.js b/desktop/public/formplayer-host-stub.js index df3518a0a..b98b6390c 100644 --- a/desktop/public/formplayer-host-stub.js +++ b/desktop/public/formplayer-host-stub.js @@ -34,4 +34,57 @@ } }, }; + + /** + * ODE Desktop: deliver bridge *_response to pending Formulus promises without + * relying on postMessage from the outer shell (unreliable for srcdoc iframes in WebView2). + */ + function deliverBridgeResponseBody(body) { + if (!body || !body.type || !body.messageId) { + return; + } + window.dispatchEvent( + new MessageEvent('message', { + data: JSON.stringify(body), + }), + ); + } + + window.__odeFormplayerDeliverBridgeResponse = function ( + requestType, + messageId, + payload, + ) { + if (requestType === 'openFormplayer') { + var result = payload && payload.result; + console.warn('[SUBOBS_DEBUG] host-stub.deliverBridgeResponse', { + messageId: messageId, + status: result && result.status, + formType: result && result.formType, + formDataKeys: + result && + result.formData && + typeof result.formData === 'object' + ? Object.keys(result.formData) + : [], + }); + } + var responseType = requestType + '_response'; + var body = { type: responseType, messageId: messageId }; + if (payload && typeof payload === 'object') { + if ('result' in payload) body.result = payload.result; + if ('error' in payload) body.error = payload.error; + } + deliverBridgeResponseBody(body); + }; + + /** Same-origin broadcast fallback when the outer shell cannot postMessage into srcdoc iframes (WebView2). */ + if (typeof BroadcastChannel !== 'undefined') { + var bridgeResponseChannel = new BroadcastChannel( + 'ode-formplayer-bridge-response', + ); + bridgeResponseChannel.onmessage = function (event) { + deliverBridgeResponseBody(event.data); + }; + } })(); diff --git a/desktop/public/formulus-injection.js b/desktop/public/formulus-injection.js index 4a93337c0..5789a1016 100644 --- a/desktop/public/formulus-injection.js +++ b/desktop/public/formulus-injection.js @@ -235,6 +235,16 @@ const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + console.warn('[SUBOBS_DEBUG] injection.openFormplayer request', { + messageId, + formType, + subObservationMode: Boolean(options && options.subObservationMode), + savedDataKeys: + savedData && typeof savedData === 'object' + ? Object.keys(savedData) + : [], + }); + // Add response handler for methods that return values const callback = event => { @@ -260,6 +270,18 @@ data.messageId === messageId ) { window.removeEventListener('message', callback); + console.warn('[SUBOBS_DEBUG] injection.openFormplayer response', { + messageId, + error: data.error, + status: data.result && data.result.status, + formType: data.result && data.result.formType, + formDataKeys: + data.result && + data.result.formData && + typeof data.result.formData === 'object' + ? Object.keys(data.result.formData) + : [], + }); if (data.error) { reject(new Error(data.error)); } else { diff --git a/desktop/src/components/CustomAppEmbed.tsx b/desktop/src/components/CustomAppEmbed.tsx index ac1e5b3b4..9542d418e 100644 --- a/desktop/src/components/CustomAppEmbed.tsx +++ b/desktop/src/components/CustomAppEmbed.tsx @@ -72,6 +72,8 @@ export type CustomAppEmbedProps = { /** Workspace-relative path to `index.html`; defaults from {@link mode}. */ indexRelativePath?: string; loadingLabel?: string; + /** Fired when the iframe document loads (bridge routing in WebView2). */ + onContentWindowReady?: (contentWindow: Window | null) => void; }; function defaultIndexRelativePath(mode: CustomAppEmbedMode): string { @@ -93,10 +95,12 @@ export const CustomAppEmbed = forwardRef< HTMLIFrameElement, CustomAppEmbedProps >(function CustomAppEmbed( - { mountKey, mode, indexRelativePath, loadingLabel }, + { mountKey, mode, indexRelativePath, loadingLabel, onContentWindowReady }, ref, ) { const innerRef = useRef(null); + const onContentWindowReadyRef = useRef(onContentWindowReady); + onContentWindowReadyRef.current = onContentWindowReady; const setRefs = useCallback( (el: HTMLIFrameElement | null) => { (innerRef as MutableRefObject).current = el; @@ -149,6 +153,7 @@ export const CustomAppEmbed = forwardRef< // hash for routing (HashRouter or path), so `#ode-…` would break the initial route. const url = `${indexAssetUrl}?ode=${Date.now()}`; el.onload = () => { + onContentWindowReadyRef.current?.(el.contentWindow); setLoading(false); }; el.src = url; diff --git a/desktop/src/components/FormplayerEmbed.tsx b/desktop/src/components/FormplayerEmbed.tsx index b5fed5097..0b691fb4d 100644 --- a/desktop/src/components/FormplayerEmbed.tsx +++ b/desktop/src/components/FormplayerEmbed.tsx @@ -2,10 +2,11 @@ import { forwardRef, useCallback, useEffect, + useImperativeHandle, useRef, useState, - type MutableRefObject, } from 'react'; +import { postFormplayerBridgeReply } from '../lib/formPreviewBridge'; import type { FormInitData } from '../lib/formplayerHost'; const FORMSPLAYER_INDEX = `${import.meta.env.BASE_URL}formplayer_dist/index.html`; @@ -44,13 +45,23 @@ export type FormplayerEmbedProps = { onContentWindowReady?: (contentWindow: Window | null) => void; }; +/** Imperative handle for bridge delivery into the iframe document (WebView2-safe). */ +export type FormplayerEmbedHandle = { + getIframe: () => HTMLIFrameElement | null; + deliverBridgeResponse: ( + requestType: string, + messageId: string, + payload: { result?: unknown; error?: string }, + ) => void; +}; + /** * Embeds production formplayer in an iframe. Injects `ReactNativeWebView` + Formulus * injection script (same bridge as Formulus mobile) before the bundle runs (via blob + * base href) so Finalize / `submitObservation` and extension APIs work. */ export const FormplayerEmbed = forwardRef< - HTMLIFrameElement, + FormplayerEmbedHandle, FormplayerEmbedProps >(function FormplayerEmbed( { @@ -62,16 +73,28 @@ export const FormplayerEmbed = forwardRef< ) { const innerRef = useRef(null); const timeoutRef = useRef(null); - const setRefs = useCallback( - (el: HTMLIFrameElement | null) => { - (innerRef as MutableRefObject).current = el; - if (typeof ref === 'function') { - ref(el); - } else if (ref) { - (ref as MutableRefObject).current = el; - } - }, - [ref], + const onContentWindowReadyRef = useRef(onContentWindowReady); + onContentWindowReadyRef.current = onContentWindowReady; + + useImperativeHandle( + ref, + () => ({ + getIframe: () => innerRef.current, + deliverBridgeResponse: (requestType, messageId, payload) => { + const win = innerRef.current?.contentWindow ?? null; + if (!win) { + return; + } + postFormplayerBridgeReply( + innerRef.current, + requestType, + messageId, + payload, + win, + ); + }, + }), + [], ); const [error, setError] = useState(null); @@ -125,7 +148,7 @@ export const FormplayerEmbed = forwardRef< window.clearTimeout(timeoutRef.current); timeoutRef.current = null; } - onContentWindowReady?.(el.contentWindow); + onContentWindowReadyRef.current?.(el.contentWindow); setLoading(false); }; // WebView2 can behave inconsistently with blob: + module scripts in packaged apps. @@ -139,7 +162,7 @@ export const FormplayerEmbed = forwardRef< setError(e instanceof Error ? e.message : String(e)); setLoading(false); } - }, [formInitData, onContentWindowReady]); + }, [formInitData]); useEffect(() => { void mountBlob(); @@ -164,7 +187,9 @@ export const FormplayerEmbed = forwardRef< {error ?

{error}

: null} {loading && !error ?

Loading formplayer…

: null}