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,