diff --git a/builder-api/src/main/java/org/codeforphilly/bdt/builder/controller/ScreenerResource.java b/builder-api/src/main/java/org/codeforphilly/bdt/builder/controller/ScreenerResource.java
index 711e6f80..b704b617 100644
--- a/builder-api/src/main/java/org/codeforphilly/bdt/builder/controller/ScreenerResource.java
+++ b/builder-api/src/main/java/org/codeforphilly/bdt/builder/controller/ScreenerResource.java
@@ -253,8 +253,9 @@ public Response saveFormSchema(
storageService.writeJsonToStorage(filePath, request.schema());
return Response.ok().build();
} catch (Exception e) {
- Log.info(("Failed to save form for screener " + screenerId));
- return Response.status(Response.Status.INTERNAL_SERVER_ERROR).build();
+ Log.error("Failed to save form for screener " + screenerId, e);
+ return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
+ .entity(Map.of("error", "Could not save the form. Please try again.")).build();
}
}
@@ -283,9 +284,12 @@ public Response publishScreener(
Screener screener = screenerOpt.get();
screenerRepository.publishScreener(screener);
return Response.ok().build();
+ } catch (BadRequestException e) {
+ return Response.status(Response.Status.BAD_REQUEST).entity(Map.of("error", e.getMessage())).build();
} catch (Exception e) {
- Log.error("Error: Error updating screener to published. Screener: " + screenerId);
- return Response.status(Response.Status.INTERNAL_SERVER_ERROR).build();
+ Log.error("Error updating screener to published. Screener: " + screenerId, e);
+ return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
+ .entity(Map.of("error", "Could not publish the screener. Please try again.")).build();
}
}
diff --git a/builder-api/src/main/java/org/codeforphilly/bdt/builder/persistence/FirestoreUtils.java b/builder-api/src/main/java/org/codeforphilly/bdt/builder/persistence/FirestoreUtils.java
index e3799896..99cdda5f 100644
--- a/builder-api/src/main/java/org/codeforphilly/bdt/builder/persistence/FirestoreUtils.java
+++ b/builder-api/src/main/java/org/codeforphilly/bdt/builder/persistence/FirestoreUtils.java
@@ -199,6 +199,19 @@ public static String persistDocumentWithId(String collectionPath, String documen
}
+ /* Writes a document under a known ID, replacing any document already there. */
+ public static void replaceDocument(String collectionPath, String documentId, Map data) throws Exception {
+ try {
+ db.collection(collectionPath).document(documentId).set(data).get();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new Exception("Thread interrupted while saving to Firestore", e);
+ } catch (ExecutionException e) {
+ throw new Exception("Failed to write document to Firestore", e);
+ }
+ }
+
+
/* Reads a document inside a transaction, so the transaction fails if it changes before commit. */
public interface TransactionReader {
Optional
-
+
+
+
unneededQuestionPaths().length}
showAllQuestions={showAllQuestions}
diff --git a/builder-frontend/src/components/screenerEditor/FormEditorView.bindings.test.tsx b/builder-frontend/src/components/screenerEditor/FormEditorView.bindings.test.tsx
index 6ee02841..4f47124b 100644
--- a/builder-frontend/src/components/screenerEditor/FormEditorView.bindings.test.tsx
+++ b/builder-frontend/src/components/screenerEditor/FormEditorView.bindings.test.tsx
@@ -2,6 +2,7 @@
import { createSignal } from "solid-js";
import { render } from "solid-js/web";
+import { act } from "preact/test-utils";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { extractFormPaths } from "@/utils/formSchemaUtils";
@@ -27,6 +28,13 @@ vi.mock("@/api/screener", () => ({
saveFormSchema: vi.fn(),
fetchFormPaths: vi.fn(),
}));
+vi.mock("@/api/benefit", () => ({
+ fetchScreenerBenefit: vi.fn(async (_screenerId, id) => ({
+ id,
+ name: id,
+ checks: [],
+ })),
+}));
import {
draftFormSchema,
@@ -34,9 +42,11 @@ import {
fetchFormPaths,
} from "@/api/screener";
import FormEditorView from "./FormEditorView";
+import { fetchScreenerBenefit } from "@/api/benefit";
let dispose: (() => void) | undefined;
beforeEach(() => {
+ editor.instance = undefined;
vi.stubGlobal(
"IntersectionObserver",
class {
@@ -46,11 +56,68 @@ beforeEach(() => {
},
);
});
-afterEach(() => {
+afterEach(async () => {
+ await act(async () => {});
dispose?.();
document.body.replaceChildren();
vi.clearAllMocks();
- vi.unstubAllGlobals();
+});
+// form-js has deferred Preact effects; keep the DOM shim for this file's environment.
+
+it("preserves saved component content after a failed source load and retry", async () => {
+ vi.mocked(fetchFormPaths).mockResolvedValue({ paths: [] });
+ const benefit = {
+ id: "housing",
+ name: "Housing",
+ description: "",
+ checks: [],
+ nextSteps: { eligible: { body: "New source instructions" } },
+ };
+ vi.mocked(fetchScreenerBenefit)
+ .mockRejectedValueOnce(new Error("Offline"))
+ .mockResolvedValueOnce(benefit);
+ const [schema, setSchema] = createSignal({
+ id: "form",
+ type: "default",
+ schemaVersion: 18,
+ components: [
+ {
+ id: "next",
+ type: "next_steps",
+ benefitContent: { housing: { eligible: { body: "Published copy" } } },
+ },
+ ],
+ });
+ const container = document.body.appendChild(document.createElement("div"));
+ dispose = render(
+ () => (
+ [benefit]}
+ />
+ ),
+ container,
+ );
+ await vi.waitFor(() =>
+ expect(container.querySelector('[role="alert"]')?.textContent).toContain(
+ "could not be loaded",
+ ),
+ );
+ expect(
+ editor.instance.saveSchema().components[0].benefitContent.housing.eligible
+ .body,
+ ).toBe("Published copy");
+ Array.from(container.querySelectorAll("button"))
+ .find((button) => button.textContent === "Try again")!
+ .click();
+ await vi.waitFor(() =>
+ expect(container.querySelector('[role="alert"]')).toBeNull(),
+ );
+ expect(
+ editor.instance.saveSchema().components[0].benefitContent.housing.eligible
+ .body,
+ ).toBe("Published copy");
});
it("keeps every required binding after the actual editor finishes importing and saving a draft", async () => {
@@ -146,6 +213,36 @@ it("defaults the key of a newly added field to its ID", async () => {
await vi.waitFor(() => expect(added.key).toBe(added.id));
});
+it("can explicitly save the initialized form before any fields are edited", async () => {
+ vi.mocked(fetchFormPaths).mockResolvedValue({ paths: [] });
+ const [schema, setSchema] = createSignal();
+ const container = document.body.appendChild(document.createElement("div"));
+ dispose = render(
+ () => ,
+ container,
+ );
+ await vi.waitFor(() =>
+ expect(
+ editor.instance?.get("formFieldRegistry").get("BDT_Form"),
+ ).toBeDefined(),
+ );
+ container
+ .querySelector(
+ '[data-testid="form-editor-save-button"]',
+ )!
+ .click();
+ await vi.waitFor(() =>
+ expect(saveFormSchema).toHaveBeenCalledWith(
+ "screener-1",
+ expect.objectContaining({
+ type: "default",
+ schemaVersion: 18,
+ components: [],
+ }),
+ ),
+ );
+});
+
it("provides reactive editor samples keyed to the screener's benefits", async () => {
vi.mocked(fetchFormPaths).mockResolvedValue({ paths: [] });
const [schema, setSchema] = createSignal();
@@ -164,7 +261,7 @@ it("provides reactive editor samples keyed to the screener's benefits", async ()
container,
);
await vi.waitFor(() =>
- expect(editor.instance.get("screeningResults").getSnapshot().mode).toBe(
+ expect(editor.instance?.get("screeningResults").getSnapshot().mode).toBe(
"editor",
),
);
@@ -176,3 +273,247 @@ it("provides reactive editor samples keyed to the screener's benefits", async ()
expect(service.getSnapshot().results.housing).toBeUndefined();
expect(editor.instance.saveSchema()).toEqual(saved);
});
+
+it("copies next steps only on insertion, keeps them out of answers, and preserves imports and undo/redo", async () => {
+ vi.mocked(fetchFormPaths).mockResolvedValue({ paths: [] });
+ const source = {
+ id: "housing",
+ name: "Housing",
+ description: "",
+ checks: [],
+ nextSteps: { eligible: { body: "Original benefit instructions" } },
+ };
+ vi.mocked(fetchScreenerBenefit).mockResolvedValue(source);
+ const [schema, setSchema] = createSignal();
+ const container = document.body.appendChild(document.createElement("div"));
+ dispose = render(
+ () => (
+ [source]}
+ />
+ ),
+ container,
+ );
+ await vi.waitFor(() => expect(editor.instance).toBeDefined());
+ await act(async () => {});
+ const model = editor.instance.get("modeling");
+ const registry = editor.instance.get("formFieldRegistry");
+ const field = model.addFormField(
+ { type: "next_steps" },
+ registry.getAll()[0],
+ 0,
+ );
+ expect(field.benefitContent.housing).toEqual(source.nextSteps);
+ expect(field.key).toBeUndefined();
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ expect(field.key).toBeUndefined();
+ expect(extractFormPaths(editor.instance.saveSchema())).toEqual([]);
+ source.nextSteps.eligible.body = "Updated benefit instructions";
+ expect(field.benefitContent.housing.eligible.body).toBe(
+ "Original benefit instructions",
+ );
+ editor.instance.get("commandStack").undo();
+ expect(registry.get(field.id)).toBeUndefined();
+ editor.instance.get("commandStack").redo();
+ expect(registry.get(field.id).benefitContent.housing.eligible.body).toBe(
+ "Original benefit instructions",
+ );
+ const saved = editor.instance.saveSchema();
+ await editor.instance.importSchema(saved);
+ expect(
+ editor.instance.saveSchema().components[0].benefitContent.housing.eligible
+ .body,
+ ).toBe("Original benefit instructions");
+});
+
+it("opens the editor before benefit content loads and fills components added meanwhile", async () => {
+ Object.defineProperty(HTMLDialogElement.prototype, "showModal", {
+ configurable: true,
+ value: () => {},
+ });
+ vi.mocked(fetchFormPaths).mockResolvedValue({ paths: [] });
+ const source = {
+ id: "housing",
+ name: "Housing",
+ description: "",
+ checks: [],
+ nextSteps: { eligible: { body: "Benefit instructions" } },
+ };
+ let finishLoading!: (benefit: typeof source) => void;
+ vi.mocked(fetchScreenerBenefit).mockReturnValueOnce(
+ new Promise((resolve) => {
+ finishLoading = resolve;
+ }),
+ );
+ const [schema, setSchema] = createSignal();
+ const container = document.body.appendChild(document.createElement("div"));
+ dispose = render(
+ () => (
+ [source]}
+ />
+ ),
+ container,
+ );
+ await vi.waitFor(() => expect(editor.instance).toBeDefined());
+ expect(
+ container.querySelector(
+ '[data-testid="form-editor-save-button"]',
+ )!.disabled,
+ ).toBe(false);
+ const registry = editor.instance.get("formFieldRegistry");
+ const field = editor.instance
+ .get("modeling")
+ .addFormField({ type: "next_steps" }, registry.getAll()[0], 0);
+ expect(field.benefitContent).toEqual({});
+ const save = container.querySelector(
+ '[data-testid="form-editor-save-button"]',
+ )!;
+ expect(save.disabled).toBe(true);
+ editor.instance.get("selection").set(field);
+ await act(async () => {});
+ const contentButton = () =>
+ Array.from(container.querySelectorAll("button")).find((button) =>
+ /Loading benefit content|Edit benefit content/.test(
+ button.textContent ?? "",
+ ),
+ );
+ await vi.waitFor(() => expect(contentButton()?.disabled).toBe(true));
+ finishLoading(source);
+ await vi.waitFor(() =>
+ expect(registry.get(field.id).benefitContent.housing).toEqual(
+ source.nextSteps,
+ ),
+ );
+ expect(
+ container.querySelector(
+ '[data-testid="form-editor-save-button"]',
+ )!.disabled,
+ ).toBe(false);
+ await vi.waitFor(() => expect(contentButton()?.disabled).toBe(false));
+ contentButton()!.click();
+ expect(
+ document.querySelector("#next-steps-body")?.value,
+ ).toBe("Benefit instructions");
+});
+
+it("fetches only newly added benefits' content", async () => {
+ vi.mocked(fetchFormPaths).mockResolvedValue({ paths: [] });
+ const benefit = (id: string) => ({ id, name: id, description: "" });
+ const [benefits, setBenefits] = createSignal([benefit("housing")]);
+ const [schema, setSchema] = createSignal();
+ const container = document.body.appendChild(document.createElement("div"));
+ dispose = render(
+ () => (
+
+ ),
+ container,
+ );
+ await vi.waitFor(() => expect(fetchScreenerBenefit).toHaveBeenCalledTimes(1));
+ setBenefits([benefit("housing"), benefit("food")]);
+ await vi.waitFor(() => expect(fetchScreenerBenefit).toHaveBeenCalledTimes(2));
+ await act(async () => {});
+ expect(
+ vi.mocked(fetchScreenerBenefit).mock.calls.map(([, id]) => id),
+ ).toEqual(["housing", "food"]);
+});
+
+async function insertBeforeSourcesLoad() {
+ vi.mocked(fetchFormPaths).mockResolvedValue({ paths: [] });
+ const source = {
+ id: "housing",
+ name: "Housing",
+ description: "",
+ checks: [],
+ nextSteps: { eligible: { body: "Benefit instructions" } },
+ };
+ let finishLoading!: (benefit: typeof source) => void;
+ let failLoading!: (error: Error) => void;
+ vi.mocked(fetchScreenerBenefit).mockReturnValueOnce(
+ new Promise((resolve, reject) => {
+ finishLoading = resolve;
+ failLoading = reject;
+ }),
+ );
+ const [schema, setSchema] = createSignal();
+ const container = document.body.appendChild(document.createElement("div"));
+ dispose = render(
+ () => (
+ [source]}
+ />
+ ),
+ container,
+ );
+ await vi.waitFor(() =>
+ expect(
+ editor.instance?.get("formFieldRegistry").get("BDT_Form"),
+ ).toBeDefined(),
+ );
+ const registry = editor.instance.get("formFieldRegistry");
+ const field = editor.instance
+ .get("modeling")
+ .addFormField({ type: "next_steps" }, registry.get("BDT_Form"), 0);
+ return { source, finishLoading, failLoading, registry, field, container };
+}
+
+it("finishes copying a pending component after undo and redo", async () => {
+ const { source, finishLoading, registry, field } =
+ await insertBeforeSourcesLoad();
+ const commands = editor.instance.get("commandStack");
+ commands.undo();
+ finishLoading(source);
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ await act(async () => {});
+ commands.redo();
+ await vi.waitFor(() =>
+ expect(registry.get(field.id).benefitContent.housing).toEqual(
+ source.nextSteps,
+ ),
+ );
+});
+
+it("does not fill an imported component that reuses a pending component's ID", async () => {
+ const { source, finishLoading, registry, field } =
+ await insertBeforeSourcesLoad();
+ await editor.instance.importSchema({
+ id: "BDT_Form",
+ type: "default",
+ schemaVersion: 18,
+ components: [{ id: field.id, type: "next_steps", benefitContent: {} }],
+ });
+ finishLoading(source);
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ await act(async () => {});
+ expect(registry.get(field.id).benefitContent).toEqual({});
+});
+
+it("finishes pending copies when a failed source request is retried", async () => {
+ const { source, failLoading, registry, field, container } =
+ await insertBeforeSourcesLoad();
+ failLoading(new Error("Offline"));
+ await vi.waitFor(() =>
+ expect(container.querySelector('[role="alert"]')?.textContent).toContain(
+ "could not be loaded",
+ ),
+ );
+ vi.mocked(fetchScreenerBenefit).mockResolvedValueOnce(source);
+ Array.from(container.querySelectorAll("button"))
+ .find((button) => button.textContent === "Try again")!
+ .click();
+ await vi.waitFor(() =>
+ expect(registry.get(field.id).benefitContent.housing).toEqual(
+ source.nextSteps,
+ ),
+ );
+});
diff --git a/builder-frontend/src/components/screenerEditor/FormEditorView.test.tsx b/builder-frontend/src/components/screenerEditor/FormEditorView.test.tsx
index 8d7206ee..271328b5 100644
--- a/builder-frontend/src/components/screenerEditor/FormEditorView.test.tsx
+++ b/builder-frontend/src/components/screenerEditor/FormEditorView.test.tsx
@@ -24,7 +24,12 @@ vi.mock("@bpmn-io/form-js-editor", () => ({
}
get(name: string) {
if (name === "screeningResults") return editor.screeningResults;
- return { on: vi.fn(), editFormField: vi.fn(), setOptions: vi.fn() };
+ return {
+ on: vi.fn(),
+ fire: vi.fn(),
+ editFormField: vi.fn(),
+ setOptions: vi.fn(),
+ };
}
},
}));
@@ -125,6 +130,55 @@ describe("AI form drafting", () => {
expect(button()?.disabled).toBe(false);
});
+ it("reports failed form saves without discarding edits and clears the error on retry", async () => {
+ vi.mocked(saveFormSchema)
+ .mockRejectedValueOnce(new Error("Storage failed"))
+ .mockResolvedValueOnce(undefined);
+ const { container, schema } = await mount(draft);
+ const save = () =>
+ container.querySelector(
+ '[data-testid="form-editor-save-button"]',
+ )!;
+ save().click();
+ await vi.waitFor(() =>
+ expect(container.querySelector('[role="alert"]')?.textContent).toContain(
+ "Could not save the form",
+ ),
+ );
+ expect(schema()).toEqual(draft);
+ expect(save().className).toContain("btn-yellow");
+ save().click();
+ await vi.waitFor(() => expect(save().className).toContain("btn-blue"));
+ expect(container.querySelector('[role="alert"]')).toBeNull();
+ });
+
+ it("prevents duplicate form saves and keeps edits made during a save unsaved", async () => {
+ let finishSave!: () => void;
+ vi.mocked(saveFormSchema).mockReturnValueOnce(
+ new Promise((resolve) => {
+ finishSave = resolve;
+ }),
+ );
+ const { container } = await mount(draft);
+ editor.handlers.changed({ schema: draft });
+ const saveButton = () =>
+ container.querySelector(
+ '[data-testid="form-editor-save-button"]',
+ )!;
+ saveButton().click();
+ expect(saveButton().disabled).toBe(true);
+ saveButton().click();
+ expect(saveFormSchema).toHaveBeenCalledTimes(1);
+ const newer = {
+ ...draft,
+ components: [...draft.components, { id: "next", type: "next_steps" }],
+ };
+ editor.handlers.changed({ schema: newer });
+ finishSave();
+ await vi.waitFor(() => expect(saveButton().disabled).toBe(false));
+ expect(saveButton().className).toContain("btn-yellow");
+ });
+
it("does not offer drafting on an existing form", async () => {
expect((await mount(draft)).button()).toBeUndefined();
});
diff --git a/builder-frontend/src/components/screenerEditor/FormEditorView.tsx b/builder-frontend/src/components/screenerEditor/FormEditorView.tsx
index 6c55adb2..3b593eb9 100644
--- a/builder-frontend/src/components/screenerEditor/FormEditorView.tsx
+++ b/builder-frontend/src/components/screenerEditor/FormEditorView.tsx
@@ -33,19 +33,81 @@ import {
} from "../../api/screener";
import { extractFormPaths } from "../../utils/formSchemaUtils";
import Loading from "../Loading";
+import { fetchScreenerBenefit } from "@/api/benefit";
+import NextStepsContentModal from "./NextStepsContentModal";
+import { nextStepsEditorModule } from "./formJsExtensions/nextSteps/editorModule";
+import {
+ NEXT_STEPS_TYPE,
+ copyBenefitContent,
+} from "./formJsExtensions/nextSteps/content";
import "@bpmn-io/form-js/dist/assets/form-js.css";
import "@bpmn-io/form-js-editor/dist/assets/form-js-editor.css";
-import { FormPath } from "@/types";
+import { Benefit, FormPath } from "@/types";
function FormEditorView({ formSchema, setFormSchema, benefits = () => [] }) {
const [isUnsaved, setIsUnsaved] = createSignal(false);
const [isSaving, setIsSaving] = createSignal(false);
+ const [saveError, setSaveError] = createSignal("");
const [isDrafting, setIsDrafting] = createSignal(false);
const [draftError, setDraftError] = createSignal("");
let disposed = false;
let importingDraft = false;
const params = useParams();
+ const [editorReady, setEditorReady] = createSignal(false);
+ const [contentField, setContentField] = createSignal();
+ const [schemaRevision, setSchemaRevision] = createSignal(0);
+ // Benefit content is loaded in the background and only used to copy into a new
+ // Next steps component or reset one, so it never holds up the editor. Each benefit
+ // is fetched once; a failed fetch is retried on the next load.
+ const sourceRequests = new Map>();
+ const fetchSource = (screenerId: string, benefitId: string) => {
+ const key = `${screenerId}/${benefitId}`;
+ let request = sourceRequests.get(key);
+ if (!request) {
+ request = fetchScreenerBenefit(screenerId, benefitId);
+ sourceRequests.set(key, request);
+ request.catch(() => {
+ if (sourceRequests.get(key) === request) sourceRequests.delete(key);
+ });
+ }
+ return request;
+ };
+ const [benefitSources, { refetch: reloadBenefitSources }] = createResource(
+ () => [params.screenerId, benefits()] as const,
+ async ([screenerId, details]) => {
+ const loaded = await Promise.allSettled(
+ details.map((benefit) => fetchSource(screenerId, benefit.id)),
+ );
+ return {
+ failed: loaded.some((result) => result.status === "rejected"),
+ benefits: loaded.flatMap((result) =>
+ result.status === "fulfilled" ? [result.value] : [],
+ ),
+ };
+ },
+ );
+ const benefitContentError = () => benefitSources()?.failed ?? false;
+ // Components added before the content loaded get it once it arrives.
+ const fieldsAwaitingSources = new Map<
+ string,
+ { field: any; benefitIds: Set }
+ >();
+ const isContentLoading = (field: any) =>
+ benefitSources.loading &&
+ fieldsAwaitingSources.get(field.id)?.field === field;
+ const hasPendingSourceCopy = () => {
+ schemaRevision();
+ if (!editorReady() || !benefitSources.loading) return false;
+ const registry = formEditor.get("formFieldRegistry") as any;
+ return Array.from(fieldsAwaitingSources.values()).some(
+ ({ field, benefitIds }) =>
+ registry.get(field.id) === field &&
+ Array.from(benefitIds).some(
+ (id) => !Object.hasOwn(field.benefitContent ?? {}, id),
+ ),
+ );
+ };
// Fetch form paths from backend (replaces local transformation logic)
const [formPaths] = createResource(
@@ -69,6 +131,7 @@ function FormEditorView({ formSchema, setFormSchema, benefits = () => [] }) {
};
onMount(() => {
+ // Existing schema content is never refreshed from benefit sources automatically.
formEditor = new FormEditor({
container,
additionalModules: [
@@ -77,6 +140,21 @@ function FormEditorView({ formSchema, setFormSchema, benefits = () => [] }) {
screeningResultsModule,
pathOptionsModule,
customKeyModule,
+ nextStepsEditorModule({
+ benefits,
+ sources: () => benefitSources()?.benefits ?? [],
+ isContentLoading,
+ onInsert: (field) => {
+ if (benefitSources.loading)
+ fieldsAwaitingSources.set(field.id, {
+ field,
+ benefitIds: new Set(benefits().map((benefit) => benefit.id)),
+ });
+ },
+ open: (field) => {
+ if (!isContentLoading(field)) setContentField(field);
+ },
+ }),
],
});
const screeningResults =
@@ -97,6 +175,7 @@ function FormEditorView({ formSchema, setFormSchema, benefits = () => [] }) {
if (importingDraft) return;
setIsUnsaved(true);
setFormSchema(e.schema);
+ setSchemaRevision((revision) => revision + 1);
});
// Set default key to field ID when a new form field is added
@@ -111,6 +190,7 @@ function FormEditorView({ formSchema, setFormSchema, benefits = () => [] }) {
!importingDraft &&
field &&
field.id &&
+ field.type !== NEXT_STEPS_TYPE &&
field.type !== "group" &&
field.type !== "default"
) {
@@ -122,20 +202,85 @@ function FormEditorView({ formSchema, setFormSchema, benefits = () => [] }) {
}, 0);
}
});
+ setEditorReady(true);
+ });
- onCleanup(() => {
- disposed = true;
- if (formEditor) {
- formEditor.destroy();
- formEditor = null;
- clearTimeout(timeoutId);
+ let copyUpdateQueued = false;
+ createEffect(() => {
+ benefitSources();
+ benefitSources.loading;
+ schemaRevision();
+ benefits();
+ if (!editorReady() || copyUpdateQueued || !fieldsAwaitingSources.size)
+ return;
+ copyUpdateQueued = true;
+ // A changed event can fire inside undo/redo. Wait until its command finishes
+ // before starting another modeling command, and read the latest field objects.
+ queueMicrotask(() => {
+ copyUpdateQueued = false;
+ if (disposed || benefitSources.loading) return;
+ const loaded = benefitSources();
+ if (!loaded) return;
+ const registry = formEditor.get("formFieldRegistry") as any;
+ const modeling = formEditor.get("modeling") as any;
+ const currentBenefits = new Set(benefits().map((benefit) => benefit.id));
+ for (const [id, pending] of Array.from(fieldsAwaitingSources)) {
+ const field = registry.get(id);
+ if (!field) continue;
+ // Undo may restore this same object; imports create a different one, even
+ // if their schema reuses its ID. Never fill an imported component.
+ if (field !== pending.field) {
+ fieldsAwaitingSources.delete(id);
+ continue;
+ }
+ const content = field.benefitContent ?? {};
+ const missing = loaded.benefits.filter(
+ (benefit) =>
+ pending.benefitIds.has(benefit.id) &&
+ currentBenefits.has(benefit.id) &&
+ !Object.hasOwn(content, benefit.id),
+ );
+ const copiedIds = new Set(missing.map((benefit) => benefit.id));
+ for (const benefitId of pending.benefitIds) {
+ if (
+ !currentBenefits.has(benefitId) ||
+ Object.hasOwn(content, benefitId) ||
+ copiedIds.has(benefitId)
+ )
+ pending.benefitIds.delete(benefitId);
+ }
+ // Keep failed requests pending so a retry can finish the original copy.
+ if (!pending.benefitIds.size) fieldsAwaitingSources.delete(id);
+ if (missing.length)
+ modeling.editFormField(field, "benefitContent", {
+ ...content,
+ ...copyBenefitContent(missing),
+ });
}
});
});
+ createEffect(() => {
+ benefitSources();
+ benefitSources.loading;
+ if (editorReady())
+ (formEditor.get("eventBus") as any).fire(
+ "propertiesPanel.providersChanged",
+ );
+ });
+
+ onCleanup(() => {
+ disposed = true;
+ if (formEditor) {
+ formEditor.destroy();
+ formEditor = null;
+ clearTimeout(timeoutId);
+ }
+ });
+
// Update path options when form paths load from backend
createEffect(() => {
- if (!formEditor || formPaths.loading) return;
+ if (!editorReady() || formPaths.loading) return;
const currentFormPaths: FormPath[] = formPaths() || [];
const pathOptionsService = formEditor.get(
@@ -151,18 +296,24 @@ function FormEditorView({ formSchema, setFormSchema, benefits = () => [] }) {
});
const handleSave = async () => {
+ if (isSaving() || hasPendingSourceCopy()) return;
+ setSaveError("");
const screenerId = params.screenerId;
- const schema = formSchema();
+ const schema = formSchema() ?? formEditor.saveSchema();
+ const revision = schemaRevision();
setIsSaving(true);
clearTimeout(timeoutId);
try {
await saveFormSchema(screenerId, schema);
- setIsUnsaved(false);
+ if (schemaRevision() === revision) setIsUnsaved(false);
} catch (error) {
// Keep the form marked as unsaved so a failed request is not presented
// to the user as a successful save.
setIsUnsaved(true);
+ setSaveError(
+ "Could not save the form. Your changes are still here. Please try again.",
+ );
console.error("Failed to save form schema", error);
} finally {
setIsSaving(false);
@@ -241,6 +392,41 @@ function FormEditorView({ formSchema, setFormSchema, benefits = () => [] }) {
return (
<>
+
+
+ {saveError()}
+
+
+
+
+ Some benefit content could not be loaded. Existing component content
+ is preserved.{" "}
+
+
+
+
+ {(field) => (
+ benefitSources()?.benefits ?? []}
+ field={field}
+ save={(benefitContent) =>
+ (formEditor.get("modeling") as any).editFormField(
+ field,
+ "benefitContent",
+ benefitContent,
+ )
+ }
+ close={() => setContentField()}
+ />
+ )}
+
@@ -248,7 +434,10 @@ function FormEditorView({ formSchema, setFormSchema, benefits = () => [] }) {
type="button"
class="btn-default btn-blue disabled:opacity-50"
disabled={
- isDrafting() || formPaths.loading || !formPaths()?.length
+ !editorReady() ||
+ isDrafting() ||
+ formPaths.loading ||
+ !formPaths()?.length
}
onClick={handleDraft}
>
@@ -281,27 +470,29 @@ function FormEditorView({ formSchema, setFormSchema, benefits = () => [] }) {