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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions desktop/src/lib/__tests__/formPreviewBridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,36 @@ describe('handleFormPreviewBridgeMessage', () => {
expect(payload.result).toBe('nested-synthetic-id');
});

it('allocateSequence honors peek (nested in options) without advancing the counter', async () => {
const postMessage = vi.fn();
const cw = { postMessage } as unknown as Window;
const iframe = { contentWindow: cw } as HTMLIFrameElement;

const allocate = async (
options: Record<string, unknown> | undefined,
messageId: string,
) => {
await handleFormPreviewBridgeMessage(
bridgeMessageFromIframe(iframe, {
type: 'allocateSequence',
messageId,
scopeKey: 'tb:peek-test:role:A:af',
options,
}),
{ iframe, onFinalize: async () => ({ error: 'no' }) },
);
const calls = postMessage.mock.calls;
const payload = JSON.parse(calls[calls.length - 1][0] as string);
return payload.result as number;
};

// Consume -> 1, peek -> 2 (no advance), consume -> 2.
expect(await allocate(undefined, 'seq1')).toBe(1);
expect(await allocate({ peek: true }, 'seq2')).toBe(2);
expect(await allocate({ peek: true }, 'seq3')).toBe(2);
expect(await allocate(undefined, 'seq4')).toBe(2);
});

it('stubs requestCamera with prefixed error', async () => {
const postMessage = vi.fn();
const cw = { postMessage } as unknown as Window;
Expand Down
24 changes: 21 additions & 3 deletions desktop/src/lib/formPreviewBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ export type FormPreviewBridgeContext = {
formType: string;
params: Record<string, unknown>;
savedData: Record<string, unknown>;
observationId?: string;
}) => void;
/**
* Form preview: defer `openFormplayer_response` until nested finalize/cancel.
Expand Down Expand Up @@ -386,9 +387,16 @@ export async function handleFormPreviewBridgeMessage(
: typeof data.payload === 'string'
? data.payload
: '';
// Injection sends options nested under `options`; fall back to flat
// top-level fields for back-compat with older callers.
const opts = (
data.options && typeof data.options === 'object'
? (data.options as Record<string, unknown>)
: data
) as { startAt?: unknown; peek?: unknown };
const startAt =
typeof data.startAt === 'number' ? data.startAt : undefined;
const peek = Boolean(data.peek);
typeof opts.startAt === 'number' ? opts.startAt : undefined;
const peek = Boolean(opts.peek);
const result = previewAllocateSequence(scopeKey, { startAt, peek });
reply('allocateSequence', { result });
return;
Expand Down Expand Up @@ -416,9 +424,14 @@ export async function handleFormPreviewBridgeMessage(
subObservationMode?: boolean;
skipFinalize?: boolean;
skipDraftSelection?: boolean;
observationId?: string | null;
}
| undefined;
const subObservationMode = Boolean(options?.subObservationMode);
const observationId =
typeof options?.observationId === 'string'
? options.observationId.trim()
: '';

if (subObservationMode && ctx.onDeferOpenSubObservation) {
const parentIframe = resolveBridgeReplyIframe(eventSource, ctx);
Expand Down Expand Up @@ -448,7 +461,12 @@ export async function handleFormPreviewBridgeMessage(
}

if (ctx.onOpenFormplayerNavigate) {
ctx.onOpenFormplayerNavigate({ formType, params, savedData });
ctx.onOpenFormplayerNavigate({
formType,
params,
savedData,
...(observationId ? { observationId } : {}),
});
reply('openFormplayer', {
result: {
status: 'cancelled',
Expand Down
10 changes: 8 additions & 2 deletions desktop/src/pages/WorkbenchCustomAppPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,14 +57,20 @@ export function WorkbenchCustomAppPage() {
formType: string;
params: Record<string, unknown>;
savedData: Record<string, unknown>;
observationId?: string;
}) => {
const explicitId =
typeof payload.observationId === 'string'
? payload.observationId.trim()
: '';
const sd = payload.savedData;
const observationId =
typeof sd.observationId === 'string'
explicitId ||
(typeof sd.observationId === 'string'
? sd.observationId
: typeof sd.id === 'string'
? sd.id
: '';
: '');
navigate('/workbench/form-preview', {
state: {
formPreviewEdit: {
Expand Down
100 changes: 40 additions & 60 deletions formulus-formplayer/src/renderers/SubObservationQuestionRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,42 @@ const SubObservationQuestionRendererInner: React.FC<ControlProps> = ({
],
);

const refreshSubObservationWindowContext = useCallback(
(rows: Record<string, unknown>[]) => {
const root = jsonForms.core?.data;
if (!root || typeof root !== 'object' || !path) return;
const parentData = writeDataPath(
root as Record<string, unknown>,
path,
rows,
);
const pv = resolveParentValue(formData, parentValuePath);
const contextTemplate = optionalRecordMap(config.subObservationContext);
const mergeConfigRaw = config.subObservationContextMerge;
const mergeConfig =
mergeConfigRaw && typeof mergeConfigRaw === 'object'
? mergeConfigRaw
: undefined;
if (contextTemplate || mergeConfig) {
const refreshed = refreshSubObservationContextFromFormData(
parentData,
contextTemplate,
pv,
mergeConfig as SubObservationContextMergeConfig,
);
writeSubObservationContextToWindow(refreshed);
}
},
[
jsonForms.core?.data,
path,
formData,
parentValuePath,
config.subObservationContext,
config.subObservationContextMerge,
],
);

const mergeSubmittedRow = useCallback(
(result: FormCompletionResult) => {
if (
Expand All @@ -249,43 +285,10 @@ const SubObservationQuestionRendererInner: React.FC<ControlProps> = ({
const row = result.formData as Record<string, unknown>;
const next = [...getCurrentRows(), row];
pushSorted(next);

const root = jsonForms.core?.data;
if (root && typeof root === 'object' && path) {
const parentData = writeDataPath(
root as Record<string, unknown>,
path,
next,
);
const pv = resolveParentValue(formData, parentValuePath);
const contextTemplate = optionalRecordMap(config.subObservationContext);
const mergeConfigRaw = config.subObservationContextMerge;
const mergeConfig =
mergeConfigRaw && typeof mergeConfigRaw === 'object'
? mergeConfigRaw
: undefined;
if (contextTemplate || mergeConfig) {
const refreshed = refreshSubObservationContextFromFormData(
parentData,
contextTemplate,
pv,
mergeConfig as SubObservationContextMergeConfig,
);
writeSubObservationContextToWindow(refreshed);
}
}
refreshSubObservationWindowContext(next);
return true;
},
[
getCurrentRows,
pushSorted,
jsonForms.core?.data,
path,
formData,
parentValuePath,
config.subObservationContext,
config.subObservationContextMerge,
],
[getCurrentRows, pushSorted, refreshSubObservationWindowContext],
);

const handleAdd = useCallback(async () => {
Expand Down Expand Up @@ -371,31 +374,7 @@ const SubObservationQuestionRendererInner: React.FC<ControlProps> = ({
i === index ? (result.formData as Record<string, unknown>) : r,
);
pushSorted(updated);
const root = jsonForms.core?.data;
if (root && typeof root === 'object' && path) {
const parentData = writeDataPath(
root as Record<string, unknown>,
path,
updated,
);
const contextTemplate = optionalRecordMap(
config.subObservationContext,
);
const mergeConfigRaw = config.subObservationContextMerge;
const mergeConfig =
mergeConfigRaw && typeof mergeConfigRaw === 'object'
? mergeConfigRaw
: undefined;
if (contextTemplate || mergeConfig) {
const refreshed = refreshSubObservationContextFromFormData(
parentData,
contextTemplate,
pv,
mergeConfig as SubObservationContextMergeConfig,
);
writeSubObservationContextToWindow(refreshed);
}
}
refreshSubObservationWindowContext(updated);
}
} catch (e) {
setError(
Expand All @@ -414,6 +393,7 @@ const SubObservationQuestionRendererInner: React.FC<ControlProps> = ({
config,
getCurrentRows,
pushSorted,
refreshSubObservationWindowContext,
],
);

Expand Down
29 changes: 29 additions & 0 deletions formulus-formplayer/src/renderers/subObservationHelpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,35 @@ describe('subObservationHelpers', () => {
expect(merged.person_codigos).toEqual([]);
});

it('mergePreservingSubObsArrays keeps off-page scalar prefills and autoSequence fields', () => {
// SwipeLayout partial payload: only the current page's field is present.
const baseline = {
obsdate: '2026-06-19',
region: 'Bafata',
tb: 'TB01',
device_role: 'A',
num: 7,
};
const incoming = { nome_chefe: 'Maria' };
const merged = mergePreservingSubObsArrays(baseline, incoming);
expect(merged.nome_chefe).toBe('Maria');
expect(merged.obsdate).toBe('2026-06-19');
expect(merged.region).toBe('Bafata');
expect(merged.tb).toBe('TB01');
expect(merged.device_role).toBe('A');
// Critical: a directly-used x-autoSequence value must survive partial
// payloads so applyAutoSequences does not re-allocate it.
expect(merged.num).toBe(7);
});

it('mergePreservingSubObsArrays lets on-page edits override baseline', () => {
const baseline = { nome_chefe: 'Maria', num: 7 };
const incoming = { nome_chefe: 'João' };
const merged = mergePreservingSubObsArrays(baseline, incoming);
expect(merged.nome_chefe).toBe('João');
expect(merged.num).toBe(7);
});

it('formDataJsonEqual compares stable JSON snapshots', () => {
expect(formDataJsonEqual({ a: 1 }, { a: 1 })).toBe(true);
expect(formDataJsonEqual({ a: 1 }, { a: 2 })).toBe(false);
Expand Down
15 changes: 10 additions & 5 deletions formulus-formplayer/src/renderers/subObservationHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,16 +132,21 @@ export function formDataJsonEqual(
}

/**
* Keep embedded sub-obs arrays when JsonForms emits partial onChange payloads.
* SwipeLayout only mounts the current page, so controls on other pages (e.g.
* `quartos`) are absent from `incoming` even though they still live in our
* baseline draft state.
* Keep baseline values when JsonForms emits partial onChange payloads.
* SwipeLayout only mounts the current page, so controls on other pages are
* absent from `incoming` even though they still live in our baseline draft
* state. Starting from `{ ...baseline, ...incoming }` preserves off-page
* scalars (host prefills like cluster stamps / obsdate, and directly-used
* `x-autoSequence` fields) so they are not dropped and re-allocated. On-page
* edits (including clearing a field) still override baseline via `incoming`.
* Sub-observation arrays get extra protection below against shorter/partial
* payloads.
*/
export function mergePreservingSubObsArrays(
baseline: Record<string, unknown>,
incoming: Record<string, unknown>,
): Record<string, unknown> {
const merged = { ...incoming };
const merged = { ...baseline, ...incoming };
for (const key of PRESERVED_ARRAY_KEYS) {
const baseArr = baseline[key];
if (!(key in incoming)) {
Expand Down
4 changes: 4 additions & 0 deletions formulus-formplayer/src/types/FormulusInterfaceDefinition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,9 @@ export interface FormulusInterface {
* `window.formulusSessionContext`. Draft bypass is not a param key — use
* `options.skipDraftSelection` on {@link openFormplayer} (same as `skipFinalize`).
* @param {Object} savedData - Previously saved form data (for editing)
* @param {Object} [options] - Session options (not persisted as observation data)
* @param {string|null} [options.observationId] - When set, finalize updates this
* observation instead of creating a new one
* @returns {Promise<FormCompletionResult>} Promise that resolves when the form is completed/closed with result details
*/
openFormplayer(
Expand All @@ -404,6 +407,7 @@ export interface FormulusInterface {
subObservationMode?: boolean;
skipFinalize?: boolean;
skipDraftSelection?: boolean;
observationId?: string | null;
},
): Promise<FormCompletionResult>;

Expand Down
Loading
Loading