Skip to content
Open
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
42 changes: 17 additions & 25 deletions desktop/src/components/FormFinalizeDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { tauriClient } from '../lib/tauriClient';
import type { FinalizeRequest } from '../lib/formPreviewBridge';
import type { ObservationExtras } from '../types/domain';

import { buildPreviewObservationSave } from '../lib/formPreviewObservation';

const TESTDATA_TAG = 'Testdata';

export type FormFinalizeDialogProps = {
Expand Down Expand Up @@ -46,11 +48,16 @@ export function FormFinalizeDialog({
setBusy(false);
return;
}
const id =
request.kind === 'update' ? request.observationId : crypto.randomUUID();
const existing =
request.kind === 'update'
? await tauriClient.getObservation(request.observationId)
: undefined;
const record = buildPreviewObservationSave(request, existing);
const id = record.id;
const envelope = {
observationId: id,
formType: request.formType,
formVersion: record.extras?.formVersion,
data: request.finalData,
};
await tauriClient.writeTextFile(path, JSON.stringify(envelope, null, 2));
Expand Down Expand Up @@ -89,29 +96,14 @@ export function FormFinalizeDialog({
setBusy(true);
setError(null);
try {
if (request.kind === 'submit') {
const id = crypto.randomUUID();
const extras = await buildExtrasWithTags(undefined);
await tauriClient.saveObservation({
id,
payload: request.finalData,
formType: request.formType,
extras,
});
onComplete({ result: id });
} else {
const existing = await tauriClient.getObservation(
request.observationId,
);
const extras = await buildExtrasWithTags(existing.extras);
await tauriClient.saveObservation({
id: request.observationId,
payload: request.finalData,
formType: request.formType,
extras,
});
onComplete({ result: request.observationId });
}
const existing =
request.kind === 'update'
? await tauriClient.getObservation(request.observationId)
: undefined;
const record = buildPreviewObservationSave(request, existing);
const extras = await buildExtrasWithTags(record.extras);
await tauriClient.saveObservation({ ...record, extras });
onComplete({ result: record.id });
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
Expand Down
106 changes: 106 additions & 0 deletions desktop/src/lib/__tests__/formPreviewBridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@ vi.mock('../tauriClient', () => ({
.fn()
.mockResolvedValue('file:///tmp/ws/attachments/synced/'),
workspaceAttachmentFileUrl: vi.fn().mockResolvedValue(null),
readBundleFormSpec: vi
.fn()
.mockResolvedValue({ formType: 'demo', formSchema: {}, uiSchema: {} }),
getObservation: vi
.fn()
.mockResolvedValue({ extras: { formVersion: '7', author: 'Alice' } }),
saveObservation: vi.fn().mockResolvedValue({ id: 'saved-1' }),
},
}));
Expand Down Expand Up @@ -226,6 +232,8 @@ describe('handleFormPreviewBridgeMessage', () => {
});

it('completes nested finalize without calling onFinalize when hook returns payload', async () => {
vi.mocked(tauriClient.saveObservation).mockClear();
vi.mocked(tauriClient.readBundleFormSpec).mockClear();
const postNested = vi.fn();
const nestedCw = { postMessage: postNested } as unknown as Window;
const nestedIframe = {
Expand Down Expand Up @@ -254,6 +262,8 @@ describe('handleFormPreviewBridgeMessage', () => {

expect(tryNested).toHaveBeenCalled();
expect(onFinalize).not.toHaveBeenCalled();
expect(tauriClient.saveObservation).not.toHaveBeenCalled();
expect(tauriClient.readBundleFormSpec).not.toHaveBeenCalled();
expect(postNested).toHaveBeenCalledTimes(1);
const payload = JSON.parse(postNested.mock.calls[0][0] as string);
expect(payload.type).toBe('submitObservation_response');
Expand Down Expand Up @@ -620,6 +630,7 @@ describe('postFormplayerBridgeReply', () => {

expect(tauriClient.saveObservation).toHaveBeenCalledWith({
id: 'existing-id-42',
extras: { formVersion: '7', author: 'Alice' },
formType: 'inclusion_decision',
payload: finalData,
});
Expand Down Expand Up @@ -676,3 +687,98 @@ describe('postFormplayerBridgeReply', () => {
expect(payload.messageId).toBe('msg-parent');
});
});

describe('headless host version provenance', () => {
it.each([
[{ version: '3' }, '3'],
[{ schemaVersion: '2', version: '3' }, '2'],
[{}, '1.0'],
[null, '1.0'],
])('resolves installed schema %j', async (schema, expected) => {
vi.mocked(tauriClient.readBundleFormSpec).mockReset();
if (schema === null)
vi.mocked(tauriClient.readBundleFormSpec).mockRejectedValueOnce(
new Error('missing'),
);
else
vi.mocked(tauriClient.readBundleFormSpec).mockResolvedValueOnce({
formType: 'demo',
formSchema: schema,
uiSchema: {},
});
const iframe = {
contentWindow: { postMessage: vi.fn() },
} as unknown as HTMLIFrameElement;
await handleFormPreviewBridgeMessage(
bridgeMessageFromIframe(iframe, {
type: 'persistObservation',
messageId: 'create-version',
input: { formType: 'demo', finalData: {}, formVersion: 'untrusted' },
}),
{ iframe, onFinalize: async () => ({}) },
);
expect(tauriClient.saveObservation).toHaveBeenLastCalledWith(
expect.objectContaining({ extras: { formVersion: expected } }),
);
});

it('uses a matching captured session without reading the bundle', async () => {
vi.mocked(tauriClient.readBundleFormSpec).mockClear();
const iframe = {
contentWindow: { postMessage: vi.fn() },
} as unknown as HTMLIFrameElement;
await handleFormPreviewBridgeMessage(
bridgeMessageFromIframe(iframe, {
type: 'persistObservation',
messageId: 'session-version',
input: { formType: 'demo', finalData: {}, formVersion: 'untrusted' },
}),
{
iframe,
onFinalize: async () => ({}),
getSessionFormVersion: formType =>
formType === 'demo' ? '2' : undefined,
},
);
expect(tauriClient.readBundleFormSpec).not.toHaveBeenCalled();
expect(tauriClient.saveObservation).toHaveBeenLastCalledWith(
expect.objectContaining({ extras: { formVersion: '2' } }),
);
});

it('preserves all update extras and never resolves a version', async () => {
const extras = {
formVersion: '7',
author: 'Alice',
tags: ['keep'],
deleted: false,
geolocation: { latitude: 1 },
};
vi.mocked(tauriClient.getObservation).mockResolvedValueOnce({
extras,
} as never);
vi.mocked(tauriClient.readBundleFormSpec).mockClear();
const getSessionFormVersion = vi.fn();
const iframe = {
contentWindow: { postMessage: vi.fn() },
} as unknown as HTMLIFrameElement;
await handleFormPreviewBridgeMessage(
bridgeMessageFromIframe(iframe, {
type: 'persistObservation',
messageId: 'edit-version',
input: {
formType: 'demo',
observationId: 'old',
finalData: {},
formVersion: 'untrusted',
},
}),
{ iframe, onFinalize: async () => ({}), getSessionFormVersion },
);
expect(tauriClient.saveObservation).toHaveBeenLastCalledWith(
expect.objectContaining({ id: 'old', extras }),
);
expect(getSessionFormVersion).not.toHaveBeenCalled();
expect(tauriClient.readBundleFormSpec).not.toHaveBeenCalled();
});
});
53 changes: 46 additions & 7 deletions desktop/src/lib/formPreviewBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@
import { convertFileSrc } from '@tauri-apps/api/core';
import { dirname, join } from '@tauri-apps/api/path';
import { tauriClient } from './tauriClient';
import { resolveFormSchemaVersion } from './formSchemaVersion';
import { buildPreviewObservationSave } from './formPreviewObservation';
import type { ObservationRecord } from '../types/domain';

/** Preview-local sequence counters (device id stub + app scope suffix). */
Expand Down Expand Up @@ -95,7 +97,12 @@ export const FORMULUS_INJECTION_REQUEST_TYPES = [
] as const;

export type FinalizeRequest =
| { kind: 'submit'; formType: string; finalData: Record<string, unknown> }
| {
kind: 'submit';
formType: string;
finalData: Record<string, unknown>;
hostFormVersion?: string;
}
| {
kind: 'update';
observationId: string;
Expand All @@ -118,6 +125,8 @@ export type FormPreviewDeferOpenSubObservationPayload = {

export type FormPreviewBridgeContext = {
iframe: HTMLIFrameElement | null;
/** Host-only snapshot for the currently opened preview; never read from WebView data. */
getSessionFormVersion?: (formType: string) => string | undefined;
/**
* Maps `postMessage` event source to the iframe that should receive `*_response`.
* When omitted, replies go to {@link iframe} (legacy single-embed hosts).
Expand Down Expand Up @@ -154,6 +163,21 @@ export type FormPreviewBridgeContext = {
) => Promise<{ result?: string; error?: string } | null>;
};

async function resolveHostFormVersion(
formType: string,
ctx: FormPreviewBridgeContext,
): Promise<string> {
const captured = ctx.getSessionFormVersion?.(formType);
if (captured !== undefined) return captured;
try {
const spec = await tauriClient.readBundleFormSpec(formType);
return resolveFormSchemaVersion(spec.formSchema);
} catch {
// Headless calls may refer to a form that is not installed locally.
return '1.0';
}
}

function resolveBridgeReplyIframe(
eventSource: Window | null | undefined,
ctx: FormPreviewBridgeContext,
Expand Down Expand Up @@ -543,6 +567,8 @@ export async function handleFormPreviewBridgeMessage(
return;
}
}
if (req.kind === 'submit')
req.hostFormVersion = await resolveHostFormVersion(formType, ctx);
const res = await ctx.onFinalize(req);
reply('submitObservation', res);
return;
Expand Down Expand Up @@ -806,12 +832,25 @@ export async function handleFormPreviewBridgeMessage(
typeof input.observationId === 'string'
? input.observationId.trim()
: '';
const id = existingId || crypto.randomUUID();
await tauriClient.saveObservation({
id,
formType,
payload: input.finalData,
});
const request: FinalizeRequest = existingId
? {
kind: 'update',
observationId: existingId,
formType,
finalData: input.finalData,
}
: {
kind: 'submit',
formType,
finalData: input.finalData,
hostFormVersion: await resolveHostFormVersion(formType, ctx),
};
const existing = existingId
? await tauriClient.getObservation(existingId)
: undefined;
const saveRequest = buildPreviewObservationSave(request, existing);
const id = saveRequest.id;
await tauriClient.saveObservation(saveRequest);
reply('persistObservation', {
result: { observationId: id, formData: input.finalData },
});
Expand Down
24 changes: 24 additions & 0 deletions desktop/src/lib/formPreviewObservation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { FinalizeRequest } from './formPreviewBridge';
import type {
ObservationRecord,
SaveObservationRequest,
} from '../types/domain';

/** Host-only request construction; edits retain the stored envelope metadata. */
export function buildPreviewObservationSave(
request: FinalizeRequest,
existing?: ObservationRecord,
): SaveObservationRequest {
if (request.kind === 'update' && !existing) {
throw new Error('Existing observation is required for preview updates');
}
return {
id: request.kind === 'update' ? request.observationId : crypto.randomUUID(),
formType: request.formType,
payload: request.finalData,
extras:
request.kind === 'update'
? existing!.extras
: { formVersion: request.hostFormVersion ?? '1.0' },
};
}
30 changes: 30 additions & 0 deletions desktop/src/lib/formSchemaVersion.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { expect, it } from 'vitest';
import { resolveFormSchemaVersion } from './formSchemaVersion';

const cases: [unknown, string][] = [
[{ schemaVersion: '2' }, '2'],
[{ version: '3' }, '3'],
[{ schemaVersion: '2', version: '3' }, '2'],
[{}, '1.0'],
[undefined, '1.0'],
[null, '1.0'],
[false, '1.0'],
[{ schemaVersion: '', version: '3' }, '3'],
[{ schemaVersion: ' \t\n', version: '3' }, '3'],
...[null, 2, false, {}, []].map<[unknown, string]>(schemaVersion => [
{ schemaVersion, version: '3' },
'3',
]),
...['', ' \t\n', null, 2, false, {}, []].map<[unknown, string]>(version => [
{ version },
'1.0',
]),
[{ schemaVersion: ' ', version: 2 }, '1.0'],
[{ schemaVersion: ' 2 ' }, ' 2 '],
[{ version: ' 3 ' }, ' 3 '],
[{ properties: { schemaVersion: '2', version: '3' } }, '1.0'],
];

it.each(cases)('resolves root metadata %j to %s', (schema, expected) => {
expect(resolveFormSchemaVersion(schema)).toBe(expected);
});
12 changes: 12 additions & 0 deletions desktop/src/lib/formSchemaVersion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/** Resolve root form metadata without changing valid authored version strings. */
export function resolveFormSchemaVersion(schema: unknown): string {
if (schema && typeof schema === 'object') {
const { schemaVersion, version } = schema as Record<string, unknown>;
for (const candidate of [schemaVersion, version]) {
if (typeof candidate === 'string' && candidate.trim().length > 0) {
return candidate;
}
}
}
return '1.0';
}
Loading
Loading