diff --git a/builder-frontend/README.md b/builder-frontend/README.md
index 5cd0aa6f..c0b63f7d 100644
--- a/builder-frontend/README.md
+++ b/builder-frontend/README.md
@@ -74,11 +74,12 @@ on the service; `subscribe()` returns an unsubscribe function and updates fire
## Testing with a real form-js form
-Tests that type into a rendered form-js form use `src/testing/formJs.ts`. form-js
-redraws a form about 60ms after an import, wiping anything typed before then, and
-passes typed text on only after a 300ms pause or when the field loses focus
-(`focusout`, which Preact uses for `onBlur`). The helpers wait out the redraw and
-send `focusout` so each answer reaches the form at once.
+Tests that type into a rendered form-js form use `answer()` from
+`src/testing/formJs.ts`. form-js passes typed text on only after a 300ms pause or
+when the field loses focus (`focusout`, which Preact uses for `onBlur`), and it
+redraws a form about 60ms after an import, resetting any field whose text it
+hasn't received yet. `answer()` sends `focusout`, so each answer reaches the form
+at once and survives the redraw.
## Print summary
diff --git a/builder-frontend/src/components/screener/Screener.test.tsx b/builder-frontend/src/components/screener/Screener.test.tsx
index fb12b19f..fe8c4959 100644
--- a/builder-frontend/src/components/screener/Screener.test.tsx
+++ b/builder-frontend/src/components/screener/Screener.test.tsx
@@ -7,7 +7,7 @@ import {
fetchPublishedScreener,
} from "@/api/publishedScreener";
import type { ScreenerResult } from "@/types";
-import { input, settle, type, until } from "@/testing/formJs";
+import { answer, input, until } from "@/testing/formJs";
const integration = vi.hoisted(() => ({
enabled: true,
@@ -98,8 +98,7 @@ describe("published screener integration lifecycle", () => {
);
expect(input("City")!.value).toBe("Philadelphia");
- await settle();
- type("City", "Pittsburgh");
+ answer("City", "Pittsburgh");
await until(() => expect(integration.result).toHaveBeenCalledTimes(2));
expect(integration.result).toHaveBeenLastCalledWith(
{ custom: { city: "Pittsburgh" } },
@@ -173,7 +172,7 @@ describe("published screener integration lifecycle", () => {
expect(integration.fail).toHaveBeenCalledExactlyOnceWith(
"ORIGIN_NOT_ALLOWED",
);
- await until(() => expect(input("City")).toBeDefined());
+ await until(() => expect(input("City")).toBeTruthy());
expect(container.textContent).toContain(
"isn’t set up to send results to https://crm.example",
);
@@ -188,7 +187,7 @@ describe("published screener integration lifecycle", () => {
dispose = render(() => , container);
await vi.waitFor(() => expect(integration.start).toHaveBeenCalledOnce());
integration.disconnect!("timeout");
- await until(() => expect(input("City")).toBeDefined());
+ await until(() => expect(input("City")).toBeTruthy());
expect(container.textContent).not.toContain("Waiting for information");
expect(container.textContent).toContain("couldn’t connect");
expect(evaluatePublishedScreener).not.toHaveBeenCalled();
@@ -205,7 +204,7 @@ describe("published screener integration lifecycle", () => {
await vi.waitFor(() =>
expect(container.textContent).toContain("no longer connected"),
);
- expect(input("City")).toBeDefined();
+ expect(input("City")).toBeTruthy();
expect(evaluatePublishedScreener).toHaveBeenCalledOnce();
});
@@ -213,11 +212,10 @@ describe("published screener integration lifecycle", () => {
integration.enabled = false;
vi.mocked(evaluatePublishedScreener).mockResolvedValue(result("TRUE"));
dispose = render(() => , container);
- await until(() => expect(input("City")).toBeDefined());
+ await until(() => expect(input("City")).toBeTruthy());
expect(container.textContent).not.toContain("Waiting for information");
expect(evaluatePublishedScreener).not.toHaveBeenCalled();
- await settle();
- type("City", "Pittsburgh");
+ answer("City", "Pittsburgh");
await until(() => expect(container.textContent).toContain("Eligible"));
expect(integration.result).not.toHaveBeenCalled();
});
diff --git a/builder-frontend/src/components/screener/Screener.tsx b/builder-frontend/src/components/screener/Screener.tsx
index c009b7c4..2748f144 100644
--- a/builder-frontend/src/components/screener/Screener.tsx
+++ b/builder-frontend/src/components/screener/Screener.tsx
@@ -17,12 +17,9 @@ import {
import HiddenQuestionsNotice from "@/components/shared/HiddenQuestionsNotice";
import ScreeningComplete from "@/components/shared/ScreeningComplete";
-import {
- createScreeningSession,
- type ScreeningAnswers,
-} from "@/components/shared/screeningSession/screeningSession";
+import { createScreeningSession } from "@/components/shared/screeningSession/screeningSession";
-import type { PublishedScreener } from "@/types";
+import type { PublishedScreener, ScreeningAnswers } from "@/types";
import {
createScreeningBridge,
type ScreeningBridge,
diff --git a/builder-frontend/src/components/screenerEditor/preview/Preview.test.tsx b/builder-frontend/src/components/screenerEditor/preview/Preview.test.tsx
index f8bd835b..acebf87c 100644
--- a/builder-frontend/src/components/screenerEditor/preview/Preview.test.tsx
+++ b/builder-frontend/src/components/screenerEditor/preview/Preview.test.tsx
@@ -3,7 +3,7 @@ import { render } from "solid-js/web";
import { afterEach, expect, it, vi } from "vitest";
import type { ScreenerResult } from "@/types";
-import { input, settle, type, until } from "@/testing/formJs";
+import { answer, input, until } from "@/testing/formJs";
const previewResults = vi.hoisted(() => ({ props: undefined as any }));
vi.mock("@/api/screener", () => ({ evaluateScreener: vi.fn() }));
@@ -50,15 +50,14 @@ it("evaluates the working screener on open and keeps the last results when an ev
),
container,
);
- await until(() => expect(input("Answer")).toBeDefined());
+ await until(() => expect(input("Answer")).toBeTruthy());
await until(() =>
expect(evaluateScreener).toHaveBeenCalledExactlyOnceWith("working", {
answer: "",
}),
);
- await settle();
- type("Answer", "first");
+ answer("Answer", "first");
await until(() => expect(evaluateScreener).toHaveBeenCalledTimes(2));
await until(() =>
expect(container.querySelector('[role="status"]')?.textContent).toContain(
@@ -74,10 +73,14 @@ it("evaluates the working screener on open and keeps the last results when an ev
expect.objectContaining({ answer: "first" }),
);
- type("Answer", "second");
+ expect(previewResults.props.resultsLoading()).toBe(false);
+
+ answer("Answer", "second");
+ expect(previewResults.props.resultsLoading()).toBe(true);
await until(() =>
expect(container.querySelector('[role="alert"]')).not.toBeNull(),
);
expect(container.querySelector('[role="status"]')).toBeNull();
expect(previewResults.props.results()).toEqual(decided);
+ expect(previewResults.props.resultsLoading()).toBe(false);
});
diff --git a/builder-frontend/src/components/screenerEditor/preview/Preview.tsx b/builder-frontend/src/components/screenerEditor/preview/Preview.tsx
index 5fa8ebdf..7f8e9fbb 100644
--- a/builder-frontend/src/components/screenerEditor/preview/Preview.tsx
+++ b/builder-frontend/src/components/screenerEditor/preview/Preview.tsx
@@ -76,7 +76,9 @@ const Preview = ({ screener, formSchema }) => {
session.stale() && !session.failed()}
/>
diff --git a/builder-frontend/src/components/screenerEditor/preview/Results.tsx b/builder-frontend/src/components/screenerEditor/preview/Results.tsx
index 281659b5..8e6c8cee 100644
--- a/builder-frontend/src/components/screenerEditor/preview/Results.tsx
+++ b/builder-frontend/src/components/screenerEditor/preview/Results.tsx
@@ -1,7 +1,10 @@
import { Accessor, For, Match, Show, Switch } from "solid-js";
-import { PreviewFormData, ScreenerResult } from "./types";
-import type { ParameterValues } from "@/types";
+import type {
+ ParameterValues,
+ ScreenerResult,
+ ScreeningAnswers,
+} from "@/types";
import { checkDisplayName } from "@/utils/checkDisplayName";
import { createReconciledResults } from "@/utils/reconciledResults";
@@ -30,7 +33,7 @@ export default function Results({
results,
resultsLoading,
}: {
- inputData: Accessor;
+ inputData: Accessor;
results: Accessor;
resultsLoading: Accessor;
}) {
diff --git a/builder-frontend/src/components/screenerEditor/preview/types.ts b/builder-frontend/src/components/screenerEditor/preview/types.ts
deleted file mode 100644
index 829af2d5..00000000
--- a/builder-frontend/src/components/screenerEditor/preview/types.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-export type { ScreenerResult } from "@/types";
-
-/* Form Data for Preview */
-export interface PreviewFormData {
- [key: string]: any;
-}
diff --git a/builder-frontend/src/components/shared/screeningSession/screeningSession.test.tsx b/builder-frontend/src/components/shared/screeningSession/screeningSession.test.tsx
index a12e905e..2b7b3949 100644
--- a/builder-frontend/src/components/shared/screeningSession/screeningSession.test.tsx
+++ b/builder-frontend/src/components/shared/screeningSession/screeningSession.test.tsx
@@ -3,13 +3,12 @@ import { createRoot, createSignal } from "solid-js";
import { insert } from "solid-js/web";
import { afterEach, describe, expect, it, vi } from "vitest";
-import type { ScreenerResult } from "@/types";
+import type { ScreenerResult, ScreeningAnswers } from "@/types";
import {
createScreeningSession,
- type ScreeningAnswers,
type ScreeningSessionOptions,
} from "./screeningSession";
-import { input, settle, type, until } from "@/testing/formJs";
+import { answer, input, until } from "@/testing/formJs";
const schema = {
type: "default",
@@ -74,7 +73,6 @@ async function start(
await until(() =>
expect(container.querySelector(".fjs-form")).not.toBeNull(),
);
- await settle();
return { session, container, dispose, setSchema };
}
@@ -84,9 +82,9 @@ describe("screening session", () => {
const { session } = await start({ evaluate });
expect(evaluate).not.toHaveBeenCalled();
- type("Answer", "fir");
+ answer("Answer", "fir");
await until(() => expect(session.stale()).toBe(true));
- type("Answer", "first");
+ answer("Answer", "first");
expect(session.loading()).toBe(false);
await until(() => expect(evaluate).toHaveBeenCalledOnce());
@@ -110,10 +108,10 @@ describe("screening session", () => {
.mockResolvedValueOnce(result("FALSE"));
const { session } = await start({ evaluate, onResults });
- type("Answer", "older");
+ answer("Answer", "older");
await until(() => expect(evaluate).toHaveBeenCalledOnce());
expect(session.loading()).toBe(true);
- type("Answer", "newer");
+ answer("Answer", "newer");
await until(() => expect(session.results()).toEqual(result("FALSE")));
older.resolve(result("TRUE"));
await older.promise;
@@ -132,18 +130,21 @@ describe("screening session", () => {
const evaluate = vi.fn(() => inFlight.promise);
const { session } = await start({ evaluate, onResults });
- type("Answer", "first");
+ answer("Answer", "first");
await until(() => expect(evaluate).toHaveBeenCalledOnce());
- type("Answer", "second");
+ expect(session.loading()).toBe(true);
+ answer("Answer", "second");
+ expect(session.loading()).toBe(false);
inFlight.resolve(result("TRUE"));
await inFlight.promise;
expect(session.results()).toBeUndefined();
+ expect(session.loading()).toBe(false);
expect(session.stale()).toBe(true);
expect(onResults).not.toHaveBeenCalled();
});
- it("keeps the last results when an evaluation fails, until one succeeds", async () => {
+ it("keeps the last results when an evaluation fails, until one succeeds, and clears the failure on an edit", async () => {
vi.spyOn(console, "error").mockImplementation(() => {});
const onEvaluationFailed = vi.fn();
const evaluate = vi
@@ -153,16 +154,17 @@ describe("screening session", () => {
.mockResolvedValueOnce(result("FALSE"));
const { session } = await start({ evaluate, onEvaluationFailed });
- type("Answer", "first");
+ answer("Answer", "first");
await until(() => expect(session.results()).toEqual(result("TRUE")));
- type("Answer", "second");
+ answer("Answer", "second");
await until(() => expect(session.failed()).toBe(true));
expect(session.results()).toEqual(result("TRUE"));
expect(session.stale()).toBe(true);
expect(session.loading()).toBe(false);
expect(onEvaluationFailed).toHaveBeenCalledOnce();
- type("Answer", "third");
+ answer("Answer", "third");
+ expect(session.failed()).toBe(false);
await until(() => expect(session.results()).toEqual(result("FALSE")));
expect(session.failed()).toBe(false);
});
@@ -177,9 +179,9 @@ describe("screening session", () => {
.mockResolvedValueOnce(result("TRUE"));
const { session } = await start({ evaluate, onEvaluationFailed });
- type("Answer", "older");
+ answer("Answer", "older");
await until(() => expect(evaluate).toHaveBeenCalledOnce());
- type("Answer", "newer");
+ answer("Answer", "newer");
older.reject(new Error("Unavailable"));
await until(() => expect(session.results()).toEqual(result("TRUE")));
@@ -191,13 +193,13 @@ describe("screening session", () => {
const evaluate = vi.fn(async () => result("FALSE", ["income"]));
const { session } = await start({ evaluate });
- type("Answer", "kept");
+ answer("Answer", "kept");
await until(() => expect(input("Income")).toBeUndefined());
expect(session.unneededQuestionCount()).toBe(1);
expect(input("Answer")!.value).toBe("kept");
session.toggleShowAllQuestions();
- await until(() => expect(input("Income")).toBeDefined());
+ await until(() => expect(input("Income")).toBeTruthy());
expect(session.showAllQuestions()).toBe(true);
expect(input("Answer")!.value).toBe("kept");
expect(evaluate).toHaveBeenCalledOnce();
@@ -207,7 +209,7 @@ describe("screening session", () => {
const evaluate = vi.fn(async () => result("TRUE"));
const { setSchema } = await start({ evaluate });
- type("Answer", "kept");
+ answer("Answer", "kept");
setSchema({
...schema,
components: [
@@ -215,7 +217,7 @@ describe("screening session", () => {
{ id: "age", type: "number", key: "age", label: "Age" },
],
});
- await until(() => expect(input("Age")).toBeDefined());
+ await until(() => expect(input("Age")).toBeTruthy());
expect(input("Answer")!.value).toBe("kept");
});
@@ -237,7 +239,7 @@ describe("screening session", () => {
expect.objectContaining({ married: null }),
);
- type("Answer", "Edited");
+ answer("Answer", "Edited");
await until(() => expect(evaluate).toHaveBeenCalledTimes(2));
expect(evaluate).toHaveBeenLastCalledWith(
expect.objectContaining({ answer: "Edited", married: null }),
@@ -294,7 +296,7 @@ describe("screening session", () => {
const evaluate = vi.fn(() => inFlight.promise);
const first = await start({ evaluate, onResults });
- type("Answer", "sent");
+ answer("Answer", "sent");
await until(() => expect(evaluate).toHaveBeenCalledOnce());
first.dispose();
inFlight.resolve(result("TRUE"));
@@ -303,7 +305,7 @@ describe("screening session", () => {
expect(first.session.results()).toBeUndefined();
const second = await start({ evaluate });
- type("Answer", "never sent");
+ answer("Answer", "never sent");
second.dispose();
await new Promise((resolve) => setTimeout(resolve, 1000));
expect(evaluate).toHaveBeenCalledOnce();
@@ -374,7 +376,7 @@ describe("next steps for CRM hosts", () => {
"Shown group",
);
- type("City", "Philadelphia");
+ answer("City", "Philadelphia");
await until(() => expect(projected).toHaveLength(1));
expect(input("City")).toBeUndefined();
expect(projected[0]).toEqual(session.projectNextSteps(decided));
diff --git a/builder-frontend/src/components/shared/screeningSession/screeningSession.tsx b/builder-frontend/src/components/shared/screeningSession/screeningSession.tsx
index 630d1ff3..f56463c8 100644
--- a/builder-frontend/src/components/shared/screeningSession/screeningSession.tsx
+++ b/builder-frontend/src/components/shared/screeningSession/screeningSession.tsx
@@ -24,7 +24,7 @@ import {
projectNextSteps as projectFieldNextSteps,
type CrmNextSteps,
} from "@/integrations/nextSteps";
-import type { ScreenerResult } from "@/types";
+import type { ScreenerResult, ScreeningAnswers } from "@/types";
import { normalizeArrayFieldData } from "@/utils/arrayFieldData";
import {
getUnneededQuestionPaths,
@@ -39,8 +39,6 @@ import {
import "@bpmn-io/form-js/dist/assets/form-js.css";
-export type ScreeningAnswers = Record;
-
/** How long answers must stay unchanged before they are evaluated. */
export const EVALUATION_DELAY_MS = 500;
@@ -66,6 +64,7 @@ export interface ScreeningSession {
results: Accessor;
/** True from an edit until results for the latest answers arrive. */
stale: Accessor;
+ /** True once evaluating the latest answers failed, until the next edit. */
failed: Accessor;
/** True only while the latest answers are being evaluated, not while waiting to send them. */
loading: Accessor;
@@ -91,6 +90,7 @@ export function createScreeningSession(
evaluate,
initialAnswers,
evaluateOnLoad,
+ screenerName,
onResults,
onEvaluationFailed,
} = options;
@@ -159,17 +159,21 @@ export function createScreeningSession(
const importVisibleSchema = async () => {
if (!form) return;
+ // form-js imports synchronously, so clearing this before awaiting ignores
+ // only the import's own changes, never an edit made while it settles.
importing = true;
+ let done: Promise;
try {
// Preserve the live form state, not the last submitted data: answers
// typed since the pending submit would otherwise be discarded.
- await form.importSchema(
+ done = form.importSchema(
hideQuestions(schema(), hiddenQuestionPaths()),
cloneDeep(currentData),
);
} finally {
importing = false;
}
+ await done;
};
createEffect(
@@ -191,7 +195,7 @@ export function createScreeningSession(
schema,
evaluatedAnswers: answersSent,
results,
- screenerName: options.screenerName,
+ screenerName,
pending: stale,
failed,
onNextStepsShownChange: setNextStepsShown,
@@ -221,6 +225,10 @@ export function createScreeningSession(
unknownPaths = keepUnansweredPaths(unknownPaths, event.data);
latestEvaluation++;
setStale(true);
+ // Nothing is evaluating the new answers until the delay ends, and
+ // no evaluation of them has failed.
+ setLoading(false);
+ setFailed(false);
debouncedSubmit(event.data);
});
})
diff --git a/builder-frontend/src/integrations/nextSteps.ts b/builder-frontend/src/integrations/nextSteps.ts
index 86edfde5..e36dc78d 100644
--- a/builder-frontend/src/integrations/nextSteps.ts
+++ b/builder-frontend/src/integrations/nextSteps.ts
@@ -7,9 +7,6 @@ import {
} from "@/components/screenerEditor/formJsExtensions/nextSteps/content";
export type CrmNextSteps = Record;
-export type NextStepsProvider = (
- results: ScreenerResult,
-) => CrmNextSteps | undefined;
/** Project the saved screener copy, never evaluation-provider content or generated HTML. */
export function projectNextSteps(
diff --git a/builder-frontend/src/testing/formJs.ts b/builder-frontend/src/testing/formJs.ts
index c97fe1b5..e7f05065 100644
--- a/builder-frontend/src/testing/formJs.ts
+++ b/builder-frontend/src/testing/formJs.ts
@@ -7,17 +7,16 @@ export const input = (label: string) =>
element.textContent?.trim().startsWith(label),
)?.control as HTMLInputElement | null | undefined;
-// form-js renders once more just after an import, resetting what was typed before it.
-export const settle = () => new Promise((resolve) => setTimeout(resolve, 100));
-
// The session waits for answers to stop changing.
export const until = (assertion: () => void) =>
vi.waitFor(assertion, { timeout: 3000 });
-export function type(label: string, value: string) {
- const field = input(label)!;
+export function answer(label: string, value: string) {
+ const field = input(label);
+ if (!field) throw new Error(`No form field labelled ${label}`);
field.value = value;
field.dispatchEvent(new Event("input", { bubbles: true }));
- // Leaving the field hands the answer to the form without form-js's typing delay.
+ // Leaving the field hands the answer to the form at once, before form-js's typing
+ // delay ends or its redraw after an import can reset the field.
field.dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
}
diff --git a/builder-frontend/src/types.ts b/builder-frontend/src/types.ts
index e19197f5..87f3dac1 100644
--- a/builder-frontend/src/types.ts
+++ b/builder-frontend/src/types.ts
@@ -121,6 +121,9 @@ export type ParameterDefinition = {
required: boolean;
};
+/* Answers a person gives a screener, keyed by form field path */
+export type ScreeningAnswers = Record;
+
/* Screener Evaluation Results */
export interface ScreenerResult {
[key: string]: BenefitResult;
@@ -148,11 +151,6 @@ export interface CheckResult {
}
export type OptionalBoolean = "TRUE" | "FALSE" | "UNABLE_TO_DETERMINE";
-/* Form Data for Preview */
-export interface PreviewFormData {
- [key: string]: any;
-}
-
// Published Screener Types
export interface PublishedScreener {
screenerName: string;
diff --git a/builder-frontend/typecheck-baseline.json b/builder-frontend/typecheck-baseline.json
index d0ce1c96..105139a2 100644
--- a/builder-frontend/typecheck-baseline.json
+++ b/builder-frontend/typecheck-baseline.json
@@ -25,7 +25,6 @@
"src/components/screenerEditor/manageBenefits/configureBenefit/SelectedEligibilityCheck.tsx": 1,
"src/components/screenerEditor/manageBenefits/configureBenefit/benefitResource.ts": 2,
"src/components/screenerEditor/manageBenefits/configureBenefit/modals/EditExplanationModal.test.tsx": 1,
- "src/components/screenerEditor/preview/FormRenderer.tsx": 1,
"src/components/screenerEditor/preview/Preview.tsx": 2,
"src/components/shared/CheckExplanation.tsx": 1,
"src/components/shared/ComponentLibrary.tsx": 1,