Add interactive questionnaire editor and typed save payloads

This commit is contained in:
Peter Stockings
2026-09-28 10:02:45 +10:00
parent 0879a3e786
commit 834d07d9bc
18 changed files with 687 additions and 54 deletions
+72 -1
View File
@@ -4,6 +4,39 @@ import { QuestionnaireEngine } from "../src/lib/questionnaire/questionnaire-engi
import { SaveEventRecorder, type SaveEvent } from "../src/lib/persistence/save-events";
import type { SaveRequest } from "../src/lib/persistence/types";
import example from "../src/demo/example-questionnaire.json";
import { serializeAnswers } from "../src/lib/persistence/typed-answers";
import { parseResumeDraft } from "../src/lib/questionnaire/resume-draft";
test("typed changes and resume drafts round-trip dates, falsy values, arrays and uploads", async () => {
const definition = {
schemaVersion: 1, id: "typed", version: "1", title: "Typed answers",
pages: [{ id: "first", title: "First", fields: [
{ id: "dateOfBirth", type: "date", label: "Date of birth" },
{ id: "consent", type: "boolean", label: "Consent" },
{ id: "count", type: "number", label: "Count" },
{ id: "notes", type: "text", label: "Notes" },
{ id: "choices", type: "multiselect", label: "Choices", options: [{ value: "a", label: "A" }] },
{ id: "attachment", type: "files", label: "Attachment" },
] }],
};
const engine = new QuestionnaireEngine(definition);
const answers = {
dateOfBirth: "1992-03-04", consent: false, count: 0, notes: null, choices: ["a"],
attachment: [{ uploadId: "upload-1", name: "proof.pdf", size: 10, type: "application/pdf" }],
};
const typed = serializeAnswers(engine, answers);
assert.deepEqual(typed.dateOfBirth, { type: "date", value: "1992-03-04" });
assert.deepEqual(parseResumeDraft(JSON.stringify({ answers: typed }), engine.definition).answers, answers);
const calls: SaveRequest[] = [];
const recorder = new SaveEventRecorder(engine, () => {}, {}, 0, undefined, request => {
calls.push(request); return { revision: request.revision + 1 };
});
await recorder.save(engine.createSession(answers), Object.keys(answers), "Continue", "first");
assert.deepEqual(calls[0].changes, typed);
assert.throws(() => parseResumeDraft(JSON.stringify({ answers: { dateOfBirth: { type: "text", value: "1992-03-04" } } }), engine.definition), /expected type date/);
assert.throws(() => parseResumeDraft(JSON.stringify({ answers: { dateOfBirth: { type: "date" } } }), engine.definition), /needs a value/);
assert.throws(() => parseResumeDraft(JSON.stringify({ answers: { consent: { type: "boolean", value: "yes" } } }), engine.definition), /valid boolean/);
});
test("file saves reject missing upload IDs and preserve real host references", async () => {
const fileEngine = new QuestionnaireEngine({
@@ -20,7 +53,8 @@ test("file saves reject missing upload IDs and preserve real host references", a
const uploaded = { ...metadata, uploadId: "host-upload-123" };
const session = fileEngine.createSession({ attachment: [uploaded] });
assert.equal(await recorder.filesSelected("attachment", [uploaded], "files", session), true);
assert.deepEqual(calls[0].changes.attachment, [uploaded]);
assert.equal(calls[0].kind, "changes");
assert.deepEqual(calls[0].changes?.attachment, { type: "files", value: [uploaded] });
assert.deepEqual(session.getEffectiveAnswers().attachment, [uploaded]);
});
@@ -73,3 +107,40 @@ test("in-memory changes never claim server persistence or advance revisions", as
await recorder.savePosition("registration", "attending");
assert.ok(events.every(event => event.status === "Changed" && event.data?.revision === 0));
});
test("answer and focus requests have separate payloads and share acknowledged revisions", async () => {
const engine = new QuestionnaireEngine(example);
const calls: SaveRequest[] = [];
const events: SaveEvent[] = [];
const recorder = new SaveEventRecorder(engine, event => events.push(event), {}, 4, undefined, request => {
calls.push(request);
return { revision: request.revision + 1 };
});
await recorder.savePosition("registration", "fullName");
await recorder.save(engine.createSession({ fullName: "Alex" }), ["fullName"], "Question blur", "registration", false, "fullName");
await recorder.savePosition("registration", "attending");
await recorder.savePosition("registration", "attending");
assert.deepEqual(calls, [
{ kind: "focus", resume: { nodeId: "registration", questionId: "fullName" }, revision: 4, trigger: "Question focus" },
{ kind: "changes", changes: { fullName: { type: "text", value: "Alex" } }, revision: 5, trigger: "Question blur" },
{ kind: "focus", resume: { nodeId: "registration", questionId: "attending" }, revision: 6, trigger: "Question focus" },
]);
assert.deepEqual(events.map(event => event.kind), ["focus", "changes", "focus"]);
});
test("failed focus after an answer save retries only the unacknowledged position", async () => {
const engine = new QuestionnaireEngine(example);
const calls: SaveRequest[] = [];
const events: SaveEvent[] = [];
let failFocus = true;
const recorder = new SaveEventRecorder(engine, event => events.push(event), {}, 0, undefined, request => {
calls.push(request);
if (request.kind === "focus" && failFocus) { failFocus = false; throw new Error("Offline"); }
return { revision: request.revision + 1 };
});
const session = engine.createSession({ fullName: "Alex" });
assert.equal(await recorder.save(session, ["fullName"], "Continue", "registration", false, "attending"), false);
assert.equal(await recorder.save(session, ["fullName"], "Continue", "registration", false, "attending"), true);
assert.deepEqual(calls.map(call => [call.kind, call.revision]), [["changes", 0], ["focus", 1], ["focus", 1]]);
assert.deepEqual(events.map(event => [event.kind, event.status]), [["changes", "Saved"], ["focus", "Blocked"], ["focus", "Saved"]]);
});
+14 -7
View File
@@ -174,8 +174,11 @@ test("draft saves clear inactive answers and submit only effective answers", asy
const recorder = new SaveEventRecorder(engine, event => events.push(event), { attending: true, supportRequired: true, supportDetails: "Ramp" });
const session = engine.createSession({ fullName: "Alex", attending: false, supportRequired: true, supportDetails: "Ramp" }, today);
await recorder.save(session, ["attending"], "blur", "registration");
const patch = events.at(-1)!.data! as { changes: Answers };
assert.deepEqual(patch.changes, { attending: false, supportRequired: null, supportDetails: null });
assert.deepEqual(events.at(-1)!.data!.changes, {
attending: { type: "boolean", value: false },
supportRequired: { type: "boolean", value: null },
supportDetails: { type: "text", value: null },
});
assert.deepEqual(session.getEffectiveAnswers(), { fullName: "Alex", attending: false });
});
test("employment changes clear occupation from live answers and draft patches", async () => {
@@ -186,12 +189,16 @@ test("employment changes clear occupation from live answers and draft patches",
const events: SaveEvent[] = [];
const recorder = new SaveEventRecorder(engine, event => events.push(event), initial);
await recorder.save(engine.createSession(updated, today), ["employmentStatus"], "Question blur", "claimantDetails");
assert.deepEqual((events.at(-1)!.data! as { changes: Answers }).changes, { ...updated, unableToWork: null });
assert.deepEqual(events.at(-1)!.data!.changes, {
employmentStatus: { type: "select", value: "UNEMPLOYED" },
occupation: { type: "text", value: null },
unableToWork: { type: "boolean", value: null },
});
const restored = engine.createSession({ ...updated, employmentStatus: "FULL_TIME" }, today);
assert.equal(restored.answers.occupation, null);
assert.ok(restored.validateQuestion("occupation").some(issue => issue.severity === "ERROR"));
await recorder.save(restored, ["employmentStatus"], "Question blur", "claimantDetails");
assert.deepEqual((events.at(-1)!.data! as { changes: Answers }).changes, { employmentStatus: "FULL_TIME" });
assert.deepEqual(events.at(-1)!.data!.changes, { employmentStatus: { type: "select", value: "FULL_TIME" } });
});
test("newly hidden occupation is explicitly reset even without a previously saved value", async () => {
@@ -203,9 +210,9 @@ test("newly hidden occupation is explicitly reset even without a previously save
const recorder = new SaveEventRecorder(engine, event => events.push(event), initial);
const session = engine.createSession({ employmentStatus: "UNEMPLOYED", occupation: null }, today);
await recorder.save(session, ["employmentStatus"], "Question blur", "claimantDetails");
const changes = (events.at(-1)!.data! as { changes: Answers }).changes;
assert.equal(changes.employmentStatus, "UNEMPLOYED");
assert.equal(changes.occupation, null);
const changes = events.at(-1)!.data!.changes!;
assert.deepEqual(changes.employmentStatus, { type: "select", value: "UNEMPLOYED" });
assert.deepEqual(changes.occupation, { type: "text", value: null });
}
});