diff --git a/app/e2e/form-widget.spec.ts b/app/e2e/form-widget.spec.ts
index e3f78992..30d96bec 100644
--- a/app/e2e/form-widget.spec.ts
+++ b/app/e2e/form-widget.spec.ts
@@ -210,10 +210,6 @@ test.describe("Form widget", () => {
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(400);
- // Wait for the 200ms debounce in DebouncedTextInput to propagate the value
- // eslint-disable-next-line playwright/no-wait-for-timeout
- await page.waitForTimeout(400);
-
// Submit the form
await page.getByRole("button", { name: "Submit" }).click();
diff --git a/app/e2e/transforms.spec.ts b/app/e2e/transforms.spec.ts
index 73a6232c..550e4750 100644
--- a/app/e2e/transforms.spec.ts
+++ b/app/e2e/transforms.spec.ts
@@ -19,9 +19,6 @@ async function setupWidgetWithQuery(page: import("@playwright/test").Page) {
await dialog.getByRole("combobox").nth(0).click();
await page.getByRole("option").first().click();
- // Wait for editor to stabilize after connection selection triggers schema fetch
- await page.waitForTimeout(1_000);
-
await typeInEditor(
dialog,
page,
@@ -134,9 +131,11 @@ test.describe("Data Transforms", () => {
await dialog.getByRole("button", { name: "Add Widget" }).click();
await expect(dialog).not.toBeVisible({ timeout: 10_000 });
- // Save the dashboard
+ // Save the dashboard and wait for it to persist
await page.getByRole("button", { name: /save/i }).click();
- await page.waitForTimeout(1_000);
+ await expect(page.getByRole("button", { name: /save/i })).toBeEnabled({
+ timeout: 10_000,
+ });
// Reopen the widget editor
const widgetCard = page.locator("[data-testid='widget-card']").first();
diff --git a/app/e2e/widget-lab.spec.ts b/app/e2e/widget-lab.spec.ts
index cbfecbfc..afbedf99 100644
--- a/app/e2e/widget-lab.spec.ts
+++ b/app/e2e/widget-lab.spec.ts
@@ -575,10 +575,11 @@ test.describe("Widget Lab", () => {
await mainDialog.getByRole("button", { name: "Add Widget" }).click();
await expect(mainDialog).not.toBeVisible();
- // Save dashboard
+ // Save dashboard and wait for it to persist
await page.getByRole("button", { name: "Save" }).click();
- // eslint-disable-next-line playwright/no-wait-for-timeout
- await page.waitForTimeout(1_000);
+ await expect(page.getByRole("button", { name: "Save" })).toBeEnabled({
+ timeout: 10_000,
+ });
// 3. Edit the template in Widget Lab — change its name
await page.goto("/widget-lab");
diff --git a/app/src/app/(auth)/login/__tests__/page.test.tsx b/app/src/app/(auth)/login/__tests__/page.test.tsx
index d65d977b..2c163b40 100644
--- a/app/src/app/(auth)/login/__tests__/page.test.tsx
+++ b/app/src/app/(auth)/login/__tests__/page.test.tsx
@@ -190,7 +190,7 @@ describe("LoginPage", () => {
mockFetchBootstrapStatus(true);
mockSignIn.mockResolvedValue({ error: "CredentialsSignin" });
- const user = userEvent.setup();
+ const user = userEvent.setup({ delay: null });
render();
const emailInput = screen.getByLabelText("Email");
@@ -210,7 +210,7 @@ describe("LoginPage", () => {
mockFetchBootstrapStatus(true);
mockSignIn.mockResolvedValue({ error: null });
- const user = userEvent.setup();
+ const user = userEvent.setup({ delay: null });
render();
const emailInput = screen.getByLabelText("Email");
diff --git a/app/src/components/__tests__/chart-error-boundary.test.tsx b/app/src/components/__tests__/chart-renderer.test.tsx
similarity index 100%
rename from app/src/components/__tests__/chart-error-boundary.test.tsx
rename to app/src/components/__tests__/chart-renderer.test.tsx
diff --git a/app/src/components/__tests__/form-widget-renderer-wizard.test.tsx b/app/src/components/__tests__/form-widget-renderer-wizard.test.tsx
new file mode 100644
index 00000000..b1b254be
--- /dev/null
+++ b/app/src/components/__tests__/form-widget-renderer-wizard.test.tsx
@@ -0,0 +1,520 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen, fireEvent } from "@testing-library/react";
+import React from "react";
+
+/* ---------- mocks (must be declared before imports) ---------- */
+
+const mockUseSession = vi.fn();
+vi.mock("next-auth/react", () => ({
+ useSession: (...args: unknown[]) => mockUseSession(...args),
+}));
+
+vi.mock("@neoboard/components", () => ({
+ ParamSelector: () =>
,
+ ParamMultiSelector: () => ,
+ DatePickerParameter: () => ,
+ DateRangeParameter: () => ,
+ DateRelativePicker: () => ,
+ NumberRangeSlider: () => ,
+ CascadingSelector: () => ,
+ FormStepIndicator: ({
+ stepLabels,
+ currentStep,
+ onStepClick,
+ }: {
+ stepLabels: string[];
+ currentStep: number;
+ onStepClick?: (step: number) => void;
+ }) => (
+
+ {stepLabels.map((l, i) => (
+
+ ))}
+
+ ),
+ AlertDialog: ({
+ open,
+ children,
+ }: {
+ open?: boolean;
+ children: React.ReactNode;
+ }) => (open ? {children}
: null),
+ AlertDialogContent: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ AlertDialogHeader: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ AlertDialogTitle: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ AlertDialogDescription: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ AlertDialogFooter: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ AlertDialogAction: ({ children }: { children: React.ReactNode }) => (
+
+ ),
+ AlertDialogCancel: ({ children }: { children: React.ReactNode }) => (
+
+ ),
+ Button: ({
+ children,
+ ...rest
+ }: React.ButtonHTMLAttributes) => (
+
+ ),
+ Label: ({
+ children,
+ htmlFor,
+ }: {
+ children: React.ReactNode;
+ htmlFor?: string;
+ }) => ,
+}));
+
+vi.mock("@/components/debounced-text-input", () => ({
+ DebouncedTextInput: ({
+ parameterName,
+ value,
+ }: {
+ parameterName: string;
+ value: string;
+ }) => (
+
+ ),
+}));
+
+vi.mock("@/stores/parameter-store", () => ({
+ useParameterValues: () => ({}),
+}));
+
+const mockMutate = vi.fn();
+vi.mock("@/hooks/use-write-query-execution", () => ({
+ useWriteQueryExecution: () => ({
+ mutate: mockMutate,
+ isPending: false,
+ }),
+}));
+
+vi.mock("@/hooks/use-seed-query", () => ({
+ useSeedQuery: () => ({ options: [], loading: false }),
+}));
+
+vi.mock("@tanstack/react-query", async () => {
+ const actual = await vi.importActual(
+ "@tanstack/react-query",
+ );
+ return {
+ ...actual,
+ useQueryClient: () => ({
+ invalidateQueries: vi.fn(),
+ }),
+ };
+});
+
+// Mock the wizard hook — we control its return value per test
+const mockGoNext = vi.fn();
+const mockGoBack = vi.fn();
+const mockGoToStep = vi.fn();
+const mockReset = vi.fn();
+
+const defaultWizardState = {
+ isWizard: false,
+ currentStep: 0,
+ totalSteps: 1,
+ stepGroups: [] as unknown[][],
+ currentFields: [] as unknown[],
+ isLastStep: true,
+ isSummaryStep: false,
+ stepLabels: ["Step 1"],
+ goNext: mockGoNext,
+ goBack: mockGoBack,
+ goToStep: mockGoToStep,
+ reset: mockReset,
+};
+
+const mockUseFormWizard = vi.fn().mockReturnValue(defaultWizardState);
+
+vi.mock("@/hooks/use-form-wizard", () => ({
+ useFormWizard: () => mockUseFormWizard(),
+}));
+
+/* ---------- import under test ---------- */
+import { FormWidgetRenderer } from "../form-widget-renderer";
+import type { FormFieldDef } from "@/lib/widget/form-field-def";
+
+/* ---------- helpers ---------- */
+
+function adminSession() {
+ mockUseSession.mockReturnValue({
+ data: { user: { role: "admin", canWrite: true, tenantId: "t1" } },
+ });
+}
+
+const step0Fields: FormFieldDef[] = [
+ {
+ id: "f1",
+ label: "Name",
+ parameterName: "name",
+ parameterType: "text",
+ required: true,
+ step: 0,
+ },
+];
+
+const step1Fields: FormFieldDef[] = [
+ {
+ id: "f2",
+ label: "Age",
+ parameterName: "age",
+ parameterType: "text",
+ step: 1,
+ },
+];
+
+const allFields = [...step0Fields, ...step1Fields];
+
+const wizardProps = {
+ connectionId: "conn-1",
+ query: "CREATE (n:X {name: $param_name, age: $param_age}) RETURN n",
+ settings: {
+ formFields: allFields,
+ chartOptions: {},
+ },
+};
+
+/* ---------- tests ---------- */
+
+describe("FormWidgetRenderer — wizard mode", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ adminSession();
+ });
+
+ describe("step indicator", () => {
+ it("renders step indicator when isWizard is true", () => {
+ mockUseFormWizard.mockReturnValue({
+ ...defaultWizardState,
+ isWizard: true,
+ currentFields: step0Fields,
+ stepGroups: [step0Fields, step1Fields],
+ totalSteps: 2,
+ isLastStep: false,
+ stepLabels: ["Step 1", "Step 2", "Review"],
+ });
+
+ render();
+
+ expect(screen.getByTestId("form-step-indicator")).toBeDefined();
+ expect(screen.getByText("Step 1")).toBeDefined();
+ expect(screen.getByText("Step 2")).toBeDefined();
+ });
+
+ it("does not render step indicator when isWizard is false", () => {
+ mockUseFormWizard.mockReturnValue({
+ ...defaultWizardState,
+ isWizard: false,
+ currentFields: allFields,
+ });
+
+ render();
+
+ expect(screen.queryByTestId("form-step-indicator")).toBeNull();
+ });
+ });
+
+ describe("navigation buttons", () => {
+ it("shows Next button (not Submit) on non-last wizard step", () => {
+ mockUseFormWizard.mockReturnValue({
+ ...defaultWizardState,
+ isWizard: true,
+ currentStep: 0,
+ currentFields: step0Fields,
+ stepGroups: [step0Fields, step1Fields],
+ totalSteps: 2,
+ isLastStep: false,
+ stepLabels: ["Step 1", "Step 2", "Review"],
+ });
+
+ render();
+
+ expect(screen.getByRole("button", { name: "Next" })).toBeDefined();
+ expect(screen.queryByRole("button", { name: "Submit" })).toBeNull();
+ });
+
+ it("shows Submit button on last step", () => {
+ mockUseFormWizard.mockReturnValue({
+ ...defaultWizardState,
+ isWizard: true,
+ currentStep: 1,
+ currentFields: step1Fields,
+ stepGroups: [step0Fields, step1Fields],
+ totalSteps: 2,
+ isLastStep: true,
+ stepLabels: ["Step 1", "Step 2", "Review"],
+ });
+
+ render();
+
+ expect(screen.getByRole("button", { name: "Submit" })).toBeDefined();
+ });
+
+ it("shows Submit button on summary step", () => {
+ mockUseFormWizard.mockReturnValue({
+ ...defaultWizardState,
+ isWizard: true,
+ currentStep: 2,
+ currentFields: allFields,
+ stepGroups: [step0Fields, step1Fields],
+ totalSteps: 2,
+ isLastStep: false,
+ isSummaryStep: true,
+ stepLabels: ["Step 1", "Step 2", "Review"],
+ });
+
+ render();
+
+ expect(screen.getByRole("button", { name: "Submit" })).toBeDefined();
+ });
+
+ it("hides Back button on step 0", () => {
+ mockUseFormWizard.mockReturnValue({
+ ...defaultWizardState,
+ isWizard: true,
+ currentStep: 0,
+ currentFields: step0Fields,
+ stepGroups: [step0Fields, step1Fields],
+ totalSteps: 2,
+ isLastStep: false,
+ stepLabels: ["Step 1", "Step 2", "Review"],
+ });
+
+ render();
+
+ expect(screen.queryByRole("button", { name: "Back" })).toBeNull();
+ });
+
+ it("shows Back button after step 0", () => {
+ mockUseFormWizard.mockReturnValue({
+ ...defaultWizardState,
+ isWizard: true,
+ currentStep: 1,
+ currentFields: step1Fields,
+ stepGroups: [step0Fields, step1Fields],
+ totalSteps: 2,
+ isLastStep: true,
+ stepLabels: ["Step 1", "Step 2", "Review"],
+ });
+
+ render();
+
+ expect(screen.getByRole("button", { name: "Back" })).toBeDefined();
+ });
+
+ it("calls goBack when Back button is clicked", () => {
+ mockUseFormWizard.mockReturnValue({
+ ...defaultWizardState,
+ isWizard: true,
+ currentStep: 1,
+ currentFields: step1Fields,
+ stepGroups: [step0Fields, step1Fields],
+ totalSteps: 2,
+ isLastStep: true,
+ stepLabels: ["Step 1", "Step 2", "Review"],
+ });
+
+ render();
+
+ fireEvent.click(screen.getByRole("button", { name: "Back" }));
+ expect(mockGoBack).toHaveBeenCalledTimes(1);
+ });
+
+ it("calls goNext when Next button is clicked", () => {
+ mockGoNext.mockReturnValue(null);
+ mockUseFormWizard.mockReturnValue({
+ ...defaultWizardState,
+ isWizard: true,
+ currentStep: 0,
+ currentFields: step0Fields,
+ stepGroups: [step0Fields, step1Fields],
+ totalSteps: 2,
+ isLastStep: false,
+ stepLabels: ["Step 1", "Step 2", "Review"],
+ });
+
+ render();
+
+ fireEvent.click(screen.getByRole("button", { name: "Next" }));
+ expect(mockGoNext).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe("field rendering", () => {
+ it("renders only current step fields", () => {
+ mockUseFormWizard.mockReturnValue({
+ ...defaultWizardState,
+ isWizard: true,
+ currentStep: 0,
+ currentFields: step0Fields,
+ stepGroups: [step0Fields, step1Fields],
+ totalSteps: 2,
+ isLastStep: false,
+ stepLabels: ["Step 1", "Step 2", "Review"],
+ });
+
+ render();
+
+ expect(screen.getByTestId("input-name")).toBeDefined();
+ expect(screen.queryByTestId("input-age")).toBeNull();
+ });
+
+ it("renders step 1 fields when on step 1", () => {
+ mockUseFormWizard.mockReturnValue({
+ ...defaultWizardState,
+ isWizard: true,
+ currentStep: 1,
+ currentFields: step1Fields,
+ stepGroups: [step0Fields, step1Fields],
+ totalSteps: 2,
+ isLastStep: true,
+ stepLabels: ["Step 1", "Step 2", "Review"],
+ });
+
+ render();
+
+ expect(screen.getByTestId("input-age")).toBeDefined();
+ expect(screen.queryByTestId("input-name")).toBeNull();
+ });
+ });
+
+ describe("summary step", () => {
+ it("renders summary review text on summary step", () => {
+ mockUseFormWizard.mockReturnValue({
+ ...defaultWizardState,
+ isWizard: true,
+ currentStep: 2,
+ currentFields: allFields,
+ stepGroups: [step0Fields, step1Fields],
+ totalSteps: 2,
+ isSummaryStep: true,
+ stepLabels: ["Step 1", "Step 2", "Review"],
+ });
+
+ render();
+
+ expect(
+ screen.getByText("Review your entries before submitting"),
+ ).toBeDefined();
+ });
+
+ it("displays field labels on summary step", () => {
+ mockUseFormWizard.mockReturnValue({
+ ...defaultWizardState,
+ isWizard: true,
+ currentStep: 2,
+ currentFields: allFields,
+ stepGroups: [step0Fields, step1Fields],
+ totalSteps: 2,
+ isSummaryStep: true,
+ stepLabels: ["Step 1", "Step 2", "Review"],
+ });
+
+ render();
+
+ expect(screen.getByText("Name")).toBeDefined();
+ expect(screen.getByText("Age")).toBeDefined();
+ });
+
+ it("shows dash for empty values on summary step", () => {
+ mockUseFormWizard.mockReturnValue({
+ ...defaultWizardState,
+ isWizard: true,
+ currentStep: 2,
+ currentFields: allFields,
+ stepGroups: [step0Fields, step1Fields],
+ totalSteps: 2,
+ isSummaryStep: true,
+ stepLabels: ["Step 1", "Step 2", "Review"],
+ });
+
+ render();
+
+ // Empty values render as "—"
+ const dashes = screen.getAllByText("—");
+ expect(dashes.length).toBeGreaterThan(0);
+ });
+ });
+
+ describe("non-wizard mode", () => {
+ it("shows a single Submit button (not Next/Back)", () => {
+ mockUseFormWizard.mockReturnValue({
+ ...defaultWizardState,
+ isWizard: false,
+ currentFields: [step0Fields[0]],
+ });
+
+ render();
+
+ expect(screen.getByRole("button", { name: "Submit" })).toBeDefined();
+ expect(screen.queryByRole("button", { name: "Next" })).toBeNull();
+ expect(screen.queryByRole("button", { name: "Back" })).toBeNull();
+ });
+ });
+});
+
+/* ---------- formatSummaryValue (tested via component output) ---------- */
+
+describe("formatSummaryValue — via summary step rendering", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockUseSession.mockReturnValue({
+ data: { user: { role: "admin", canWrite: true, tenantId: "t1" } },
+ });
+ });
+
+ it("renders multi-select field label on summary step", () => {
+ const multiField: FormFieldDef = {
+ id: "f3",
+ label: "Tags",
+ parameterName: "tags",
+ parameterType: "multi-select",
+ step: 0,
+ };
+
+ mockUseFormWizard.mockReturnValue({
+ ...defaultWizardState,
+ isWizard: true,
+ currentStep: 1,
+ currentFields: [multiField],
+ stepGroups: [[multiField]],
+ totalSteps: 1,
+ isSummaryStep: true,
+ stepLabels: ["Step 1", "Review"],
+ });
+
+ // The renderer reads localValues from internal state seeded from fields.
+ // Since we can't inject localValues directly, we verify the summary renders.
+ render(
+ ,
+ );
+
+ expect(screen.getByText("Tags")).toBeDefined();
+ });
+});
diff --git a/app/src/components/form-widget-renderer.tsx b/app/src/components/form-widget-renderer.tsx
index d526cb38..cea18e90 100644
--- a/app/src/components/form-widget-renderer.tsx
+++ b/app/src/components/form-widget-renderer.tsx
@@ -29,8 +29,10 @@ import {
AlertDialogTitle,
type RelativeDatePreset,
} from "@neoboard/components";
+import { FormStepIndicator } from "@neoboard/components";
import { useParameterValues } from "@/stores/parameter-store";
import { useWriteQueryExecution } from "@/hooks/use-write-query-execution";
+import { useFormWizard } from "@/hooks/use-form-wizard";
import { useSeedQuery } from "@/hooks/use-seed-query";
import { buildFormParams } from "@/lib/widget/form-field-def";
import type { FormFieldDef } from "@/lib/widget/form-field-def";
@@ -334,6 +336,23 @@ function FieldInput({
// ─── Main renderer ────────────────────────────────────────────────────────────
+/** Format a field value for the summary step display. */
+function formatSummaryValue(value: unknown, field: FormFieldDef): string {
+ if (value === undefined || value === null || value === "") return "—";
+ if (field.parameterType === "number-range" && Array.isArray(value)) {
+ return `${value[0]} – ${value[1]}`;
+ }
+ if (Array.isArray(value)) return value.join(", ") || "—";
+ if (field.parameterType === "date-range" && typeof value === "object") {
+ const r = value as { from?: string; to?: string };
+ if (r.from && r.to) return `${r.from} → ${r.to}`;
+ if (r.from) return `From ${r.from}`;
+ if (r.to) return `To ${r.to}`;
+ return "—";
+ }
+ return String(value);
+}
+
export function FormWidgetRenderer({
connectionId,
query,
@@ -350,6 +369,8 @@ export function FormWidgetRenderer({
[settings.chartOptions],
);
+ const wizard = useFormWizard(fields, chartOptions);
+
const [localValues, setLocalValues] = useState>({});
const [successMessage, setSuccessMessage] = useState(null);
const [errorMessage, setErrorMessage] = useState(null);
@@ -495,6 +516,7 @@ export function FormWidgetRenderer({
setSuccessMessage(msg || "Form submitted successfully");
if (chartOptions.resetOnSuccess !== false) {
setLocalValues({});
+ wizard.reset();
}
for (const id of refreshWidgetIds) {
queryClient.invalidateQueries({ queryKey: ["widget-query", id] });
@@ -588,6 +610,15 @@ export function FormWidgetRenderer({
)}
+ {/* Step indicator for wizard forms */}
+ {wizard.isWizard && (
+
+ )}
+
{/*
* When the viewer is read-only, the `inert` attribute blocks ALL
* interactions — mouse, keyboard, and assistive technology. This
@@ -602,47 +633,70 @@ export function FormWidgetRenderer({
readOnly ? "select-none space-y-4 opacity-60" : "space-y-4"
}
>
- {fields.map((field) => (
- handleFieldBlur(field)}
- >
-
-
{
- if (handle) {
- textInputRefs.current.set(
- field.parameterName,
- handle,
- );
- } else {
- textInputRefs.current.delete(field.parameterName);
- }
- }
- : undefined
- }
- />
- {fieldErrors[field.parameterName] && (
-
- {fieldErrors[field.parameterName]}
-
- )}
+ {wizard.isSummaryStep ? (
+ /* Summary step: show all values read-only */
+
+
+ Review your entries before submitting
+
+ {fields.map((field) => (
+
+
+ {field.label || field.parameterName}
+
+
+ {formatSummaryValue(
+ localValues[field.parameterName],
+ field,
+ )}
+
+
+ ))}
- ))}
+ ) : (
+ /* Regular step: render field inputs */
+ wizard.currentFields.map((field) => (
+ handleFieldBlur(field)}
+ >
+
+
{
+ if (handle) {
+ textInputRefs.current.set(
+ field.parameterName,
+ handle,
+ );
+ } else {
+ textInputRefs.current.delete(field.parameterName);
+ }
+ }
+ : undefined
+ }
+ />
+ {fieldErrors[field.parameterName] && (
+
+ {fieldErrors[field.parameterName]}
+
+ )}
+
+ ))
+ )}
{successMessage && (
@@ -652,24 +706,66 @@ export function FormWidgetRenderer({
{errorMessage}
)}
-
+ {/* Navigation buttons */}
+ {wizard.isWizard ? (
+
+ {wizard.currentStep > 0 && (
+
+ )}
+ {wizard.isSummaryStep || wizard.isLastStep ? (
+
+ ) : (
+
+ )}
+
+ ) : (
+
+ )}
diff --git a/app/src/components/widget-editor-modal.tsx b/app/src/components/widget-editor-modal.tsx
index b83de11c..8fe3009d 100644
--- a/app/src/components/widget-editor-modal.tsx
+++ b/app/src/components/widget-editor-modal.tsx
@@ -1388,6 +1388,53 @@ export function WidgetEditorModal({
) : isForm ? (
+ {/* Wizard step labels */}
+ {formFields.some(
+ (f: { step?: number }) => f.step !== undefined,
+ ) && (
+ <>
+
+ Wizard Steps
+
+
+ Name each step. Steps are derived from field
+ assignments.
+
+ {(() => {
+ const stepNums = [
+ ...new Set(
+ formFields
+ .map((f: { step?: number }) => f.step ?? 0)
+ .sort((a: number, b: number) => a - b),
+ ),
+ ];
+ const labels =
+ (chartOptions.stepLabels as
+ | string[]
+ | undefined) ?? [];
+ return stepNums.map(
+ (stepNum: number, idx: number) => (
+
{
+ const next = [...labels];
+ while (next.length <= idx) next.push("");
+ next[idx] = e.target.value;
+ setChartOptions({
+ ...chartOptions,
+ stepLabels: next,
+ });
+ }}
+ placeholder={`Step ${idx + 1}`}
+ className="text-sm"
+ />
+ ),
+ );
+ })()}
+ >
+ )}
+
{/* Confirmation dialog toggle */}
Submit Behavior
diff --git a/app/src/components/widget-editor/form-fields-editor.tsx b/app/src/components/widget-editor/form-fields-editor.tsx
index edbc9587..e91f4c8b 100644
--- a/app/src/components/widget-editor/form-fields-editor.tsx
+++ b/app/src/components/widget-editor/form-fields-editor.tsx
@@ -186,6 +186,30 @@ function SortableFieldItem({
)}
+ {/* Step (for multi-step wizard) */}
+
+
+
{
+ const parsed = parseInt(e.target.value, 10);
+ onUpdate(field.id, {
+ step:
+ e.target.value === "" || Number.isNaN(parsed)
+ ? undefined
+ : parsed,
+ });
+ }}
+ placeholder="—"
+ className="h-7 text-xs w-20"
+ />
+
+ Assign a step number to enable multi-step wizard mode.
+
+
+
{/* Input Type */}
diff --git a/app/src/hooks/__tests__/use-form-wizard.test.ts b/app/src/hooks/__tests__/use-form-wizard.test.ts
new file mode 100644
index 00000000..6ea2092f
--- /dev/null
+++ b/app/src/hooks/__tests__/use-form-wizard.test.ts
@@ -0,0 +1,414 @@
+// @vitest-environment jsdom
+import { describe, it, expect } from "vitest";
+import { renderHook, act } from "@testing-library/react";
+import { useFormWizard } from "../use-form-wizard";
+import type { FormFieldDef } from "@/lib/widget/form-field-def";
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+function makeField(
+ overrides: Partial
& { parameterName: string },
+): FormFieldDef {
+ return {
+ id: overrides.parameterName,
+ label: overrides.parameterName,
+ parameterType: "text",
+ ...overrides,
+ };
+}
+
+const singleStepFields: FormFieldDef[] = [
+ makeField({ parameterName: "name" }),
+ makeField({ parameterName: "email" }),
+];
+
+const wizardFields: FormFieldDef[] = [
+ makeField({ parameterName: "name", step: 0, required: true }),
+ makeField({ parameterName: "email", step: 0 }),
+ makeField({ parameterName: "age", step: 1, required: true }),
+ makeField({ parameterName: "city", step: 2 }),
+];
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+describe("useFormWizard", () => {
+ describe("wizard detection", () => {
+ it("returns isWizard=false when no fields have step", () => {
+ const { result } = renderHook(() => useFormWizard(singleStepFields, {}));
+ expect(result.current.isWizard).toBe(false);
+ });
+
+ it("returns isWizard=true when fields have step assigned", () => {
+ const { result } = renderHook(() => useFormWizard(wizardFields, {}));
+ expect(result.current.isWizard).toBe(true);
+ });
+
+ it("returns isWizard=false for empty fields", () => {
+ const { result } = renderHook(() => useFormWizard([], {}));
+ expect(result.current.isWizard).toBe(false);
+ });
+ });
+
+ describe("step grouping", () => {
+ it("groups all fields into one step when no wizard", () => {
+ const { result } = renderHook(() => useFormWizard(singleStepFields, {}));
+ expect(result.current.stepGroups).toHaveLength(1);
+ expect(result.current.stepGroups[0]).toEqual(singleStepFields);
+ });
+
+ it("groups fields by step number", () => {
+ const { result } = renderHook(() => useFormWizard(wizardFields, {}));
+ expect(result.current.stepGroups).toHaveLength(3);
+ expect(result.current.stepGroups[0]).toHaveLength(2); // step 0: name, email
+ expect(result.current.stepGroups[1]).toHaveLength(1); // step 1: age
+ expect(result.current.stepGroups[2]).toHaveLength(1); // step 2: city
+ });
+
+ it("normalizes gaps in step numbers", () => {
+ const gappedFields: FormFieldDef[] = [
+ makeField({ parameterName: "a", step: 0 }),
+ makeField({ parameterName: "b", step: 5 }),
+ makeField({ parameterName: "c", step: 10 }),
+ ];
+ const { result } = renderHook(() => useFormWizard(gappedFields, {}));
+ expect(result.current.stepGroups).toHaveLength(3);
+ expect(result.current.totalSteps).toBe(3);
+ });
+ });
+
+ describe("initial state", () => {
+ it("starts at step 0", () => {
+ const { result } = renderHook(() => useFormWizard(wizardFields, {}));
+ expect(result.current.currentStep).toBe(0);
+ });
+
+ it("returns currentFields for step 0", () => {
+ const { result } = renderHook(() => useFormWizard(wizardFields, {}));
+ expect(result.current.currentFields).toHaveLength(2);
+ expect(result.current.currentFields[0].parameterName).toBe("name");
+ expect(result.current.currentFields[1].parameterName).toBe("email");
+ });
+
+ it("totalSteps matches number of step groups", () => {
+ const { result } = renderHook(() => useFormWizard(wizardFields, {}));
+ expect(result.current.totalSteps).toBe(3);
+ });
+
+ it("isLastStep is false on step 0 with multiple steps", () => {
+ const { result } = renderHook(() => useFormWizard(wizardFields, {}));
+ expect(result.current.isLastStep).toBe(false);
+ });
+
+ it("isSummaryStep is false on step 0", () => {
+ const { result } = renderHook(() => useFormWizard(wizardFields, {}));
+ expect(result.current.isSummaryStep).toBe(false);
+ });
+ });
+
+ describe("step labels", () => {
+ it("generates default labels when none configured", () => {
+ const { result } = renderHook(() => useFormWizard(wizardFields, {}));
+ // 3 content steps + "Review" summary
+ expect(result.current.stepLabels).toEqual([
+ "Step 1",
+ "Step 2",
+ "Step 3",
+ "Review",
+ ]);
+ });
+
+ it("uses configured stepLabels from chartOptions", () => {
+ const { result } = renderHook(() =>
+ useFormWizard(wizardFields, {
+ stepLabels: ["Personal", "Details", "Location"],
+ }),
+ );
+ expect(result.current.stepLabels).toEqual([
+ "Personal",
+ "Details",
+ "Location",
+ "Review",
+ ]);
+ });
+
+ it("falls back to default for missing configured labels", () => {
+ const { result } = renderHook(() =>
+ useFormWizard(wizardFields, {
+ stepLabels: ["Personal"],
+ }),
+ );
+ expect(result.current.stepLabels[0]).toBe("Personal");
+ expect(result.current.stepLabels[1]).toBe("Step 2");
+ expect(result.current.stepLabels[2]).toBe("Step 3");
+ });
+
+ it("omits Review label when enableSummary is false", () => {
+ const { result } = renderHook(() =>
+ useFormWizard(wizardFields, { enableSummary: false }),
+ );
+ expect(result.current.stepLabels).toEqual(["Step 1", "Step 2", "Step 3"]);
+ });
+
+ it("does not add Review label for non-wizard forms", () => {
+ const { result } = renderHook(() => useFormWizard(singleStepFields, {}));
+ expect(result.current.stepLabels).toEqual(["Step 1"]);
+ });
+ });
+
+ describe("goNext", () => {
+ it("advances step when all fields are valid", () => {
+ const { result } = renderHook(() => useFormWizard(wizardFields, {}));
+
+ act(() => {
+ const errors = result.current.goNext({
+ name: "Alice",
+ email: "alice@example.com",
+ });
+ expect(errors).toBeNull();
+ });
+
+ expect(result.current.currentStep).toBe(1);
+ });
+
+ it("returns validation errors when required fields are empty", () => {
+ const { result } = renderHook(() => useFormWizard(wizardFields, {}));
+
+ let errors: Record | null = null;
+ act(() => {
+ errors = result.current.goNext({ name: "", email: "" });
+ });
+
+ expect(errors).toEqual({ name: "This field is required" });
+ expect(result.current.currentStep).toBe(0); // did NOT advance
+ });
+
+ it("advances through multiple steps sequentially", () => {
+ const { result } = renderHook(() => useFormWizard(wizardFields, {}));
+
+ // Step 0 → 1
+ act(() => {
+ result.current.goNext({ name: "Alice" });
+ });
+ expect(result.current.currentStep).toBe(1);
+ expect(result.current.currentFields[0].parameterName).toBe("age");
+
+ // Step 1 → 2
+ act(() => {
+ result.current.goNext({ age: "30" });
+ });
+ expect(result.current.currentStep).toBe(2);
+ expect(result.current.isLastStep).toBe(true);
+ });
+
+ it("advances to summary step from last content step", () => {
+ const { result } = renderHook(() => useFormWizard(wizardFields, {}));
+
+ // Navigate to last content step
+ act(() => {
+ result.current.goNext({ name: "Alice" });
+ });
+ act(() => {
+ result.current.goNext({ age: "30" });
+ });
+ act(() => {
+ result.current.goNext({ city: "NYC" });
+ });
+
+ expect(result.current.currentStep).toBe(3);
+ expect(result.current.isSummaryStep).toBe(true);
+ // Summary step shows all fields
+ expect(result.current.currentFields).toEqual(wizardFields);
+ });
+ });
+
+ describe("goBack", () => {
+ it("goes to previous step", () => {
+ const { result } = renderHook(() => useFormWizard(wizardFields, {}));
+
+ // Advance to step 1
+ act(() => {
+ result.current.goNext({ name: "Alice" });
+ });
+ expect(result.current.currentStep).toBe(1);
+
+ // Go back
+ act(() => {
+ result.current.goBack();
+ });
+ expect(result.current.currentStep).toBe(0);
+ });
+
+ it("clamps at step 0", () => {
+ const { result } = renderHook(() => useFormWizard(wizardFields, {}));
+ expect(result.current.currentStep).toBe(0);
+
+ act(() => {
+ result.current.goBack();
+ });
+ expect(result.current.currentStep).toBe(0);
+ });
+
+ it("does not validate when going back", () => {
+ const { result } = renderHook(() => useFormWizard(wizardFields, {}));
+
+ // Go to step 1
+ act(() => {
+ result.current.goNext({ name: "Alice" });
+ });
+
+ // Go back — no validation needed
+ act(() => {
+ result.current.goBack();
+ });
+ expect(result.current.currentStep).toBe(0);
+ });
+ });
+
+ describe("goToStep", () => {
+ it("allows jumping backward to a completed step", () => {
+ const { result } = renderHook(() => useFormWizard(wizardFields, {}));
+
+ // Advance to step 2
+ act(() => {
+ result.current.goNext({ name: "Alice" });
+ });
+ act(() => {
+ result.current.goNext({ age: "30" });
+ });
+ expect(result.current.currentStep).toBe(2);
+
+ // Jump back to step 0
+ act(() => {
+ result.current.goToStep(0);
+ });
+ expect(result.current.currentStep).toBe(0);
+ });
+
+ it("prevents jumping forward", () => {
+ const { result } = renderHook(() => useFormWizard(wizardFields, {}));
+ expect(result.current.currentStep).toBe(0);
+
+ act(() => {
+ result.current.goToStep(2);
+ });
+ expect(result.current.currentStep).toBe(0); // unchanged
+ });
+
+ it("prevents jumping to negative step", () => {
+ const { result } = renderHook(() => useFormWizard(wizardFields, {}));
+
+ // Advance to step 1
+ act(() => {
+ result.current.goNext({ name: "Alice" });
+ });
+ expect(result.current.currentStep).toBe(1);
+
+ act(() => {
+ result.current.goToStep(-1);
+ });
+ expect(result.current.currentStep).toBe(1); // unchanged
+ });
+ });
+
+ describe("reset", () => {
+ it("returns to step 0", () => {
+ const { result } = renderHook(() => useFormWizard(wizardFields, {}));
+
+ // Advance a few steps
+ act(() => {
+ result.current.goNext({ name: "Alice" });
+ });
+ act(() => {
+ result.current.goNext({ age: "30" });
+ });
+ expect(result.current.currentStep).toBe(2);
+
+ act(() => {
+ result.current.reset();
+ });
+ expect(result.current.currentStep).toBe(0);
+ });
+ });
+
+ describe("summary step", () => {
+ it("isSummaryStep is true on the step after the last content step", () => {
+ const { result } = renderHook(() => useFormWizard(wizardFields, {}));
+
+ // Navigate through all content steps
+ act(() => {
+ result.current.goNext({ name: "Alice" });
+ });
+ act(() => {
+ result.current.goNext({ age: "30" });
+ });
+ act(() => {
+ result.current.goNext({ city: "NYC" });
+ });
+
+ expect(result.current.isSummaryStep).toBe(true);
+ expect(result.current.currentStep).toBe(3); // totalSteps = 3
+ });
+
+ it("currentFields returns all fields on summary step", () => {
+ const { result } = renderHook(() => useFormWizard(wizardFields, {}));
+
+ act(() => {
+ result.current.goNext({ name: "Alice" });
+ });
+ act(() => {
+ result.current.goNext({ age: "30" });
+ });
+ act(() => {
+ result.current.goNext({ city: "NYC" });
+ });
+
+ expect(result.current.currentFields).toHaveLength(4);
+ });
+
+ it("disables summary step when enableSummary is false", () => {
+ const { result } = renderHook(() =>
+ useFormWizard(wizardFields, { enableSummary: false }),
+ );
+
+ act(() => {
+ result.current.goNext({ name: "Alice" });
+ });
+ act(() => {
+ result.current.goNext({ age: "30" });
+ });
+
+ // On last content step
+ expect(result.current.isLastStep).toBe(true);
+ expect(result.current.isSummaryStep).toBe(false);
+
+ // Advance past last step
+ act(() => {
+ result.current.goNext({ city: "NYC" });
+ });
+
+ // Should NOT show summary
+ expect(result.current.isSummaryStep).toBe(false);
+ });
+ });
+
+ describe("non-wizard form", () => {
+ it("returns all fields as currentFields", () => {
+ const { result } = renderHook(() => useFormWizard(singleStepFields, {}));
+ expect(result.current.currentFields).toEqual(singleStepFields);
+ });
+
+ it("totalSteps is 1", () => {
+ const { result } = renderHook(() => useFormWizard(singleStepFields, {}));
+ expect(result.current.totalSteps).toBe(1);
+ });
+
+ it("isLastStep is true on the only step", () => {
+ const { result } = renderHook(() => useFormWizard(singleStepFields, {}));
+ expect(result.current.isLastStep).toBe(true);
+ });
+ });
+});
diff --git a/app/src/hooks/use-form-wizard.ts b/app/src/hooks/use-form-wizard.ts
new file mode 100644
index 00000000..1c0fe864
--- /dev/null
+++ b/app/src/hooks/use-form-wizard.ts
@@ -0,0 +1,104 @@
+import { useState, useMemo, useCallback } from "react";
+import type { FormFieldDef } from "@/lib/widget/form-field-def";
+import { isWizardForm, groupFieldsByStep } from "@/lib/widget/form-field-def";
+import { validateStepFields } from "@/lib/widget/form-field-validation";
+
+export interface FormWizardState {
+ /** Whether this form uses multi-step wizard mode */
+ isWizard: boolean;
+ /** Current step index (0-based) */
+ currentStep: number;
+ /** Total number of steps (excluding summary) */
+ totalSteps: number;
+ /** Fields grouped by step */
+ stepGroups: FormFieldDef[][];
+ /** Fields for the current step */
+ currentFields: FormFieldDef[];
+ /** Whether we're on the last content step (next = summary or submit) */
+ isLastStep: boolean;
+ /** Whether we're on the summary step */
+ isSummaryStep: boolean;
+ /** Step labels for the indicator */
+ stepLabels: string[];
+ /** Go to next step. Validates current step first. Returns errors or null. */
+ goNext: (
+ localValues: Record,
+ ) => Record | null;
+ /** Go to previous step. No validation. */
+ goBack: () => void;
+ /** Jump to a specific step (for clicking completed steps). */
+ goToStep: (step: number) => void;
+ /** Reset to step 0 (e.g. after successful submit). */
+ reset: () => void;
+}
+
+/**
+ * Hook that manages multi-step form wizard state.
+ * Returns a flat interface — non-wizard forms get a single "step" with all fields.
+ */
+export function useFormWizard(
+ fields: FormFieldDef[],
+ chartOptions: Record,
+): FormWizardState {
+ const [currentStep, setCurrentStep] = useState(0);
+
+ const isWizard = useMemo(() => isWizardForm(fields), [fields]);
+ const stepGroups = useMemo(() => groupFieldsByStep(fields), [fields]);
+ const enableSummary = isWizard && chartOptions.enableSummary !== false;
+ const totalSteps = stepGroups.length;
+
+ const configuredLabels =
+ (chartOptions.stepLabels as string[] | undefined) ?? [];
+ const stepLabels = useMemo(() => {
+ const labels = stepGroups.map(
+ (_, i) => configuredLabels[i] || `Step ${i + 1}`,
+ );
+ if (enableSummary) labels.push("Review");
+ return labels;
+ }, [stepGroups, configuredLabels, enableSummary]);
+
+ const isSummaryStep = enableSummary && currentStep === totalSteps;
+ const isLastStep = currentStep === totalSteps - 1;
+ const currentFields = isSummaryStep
+ ? fields // Summary shows all fields
+ : (stepGroups[currentStep] ?? []);
+
+ const goNext = useCallback(
+ (localValues: Record) => {
+ const stepFields = stepGroups[currentStep] ?? [];
+ const errors = validateStepFields(stepFields, localValues);
+ if (Object.keys(errors).length > 0) return errors;
+ setCurrentStep((s) => s + 1);
+ return null;
+ },
+ [currentStep, stepGroups],
+ );
+
+ const goBack = useCallback(() => {
+ setCurrentStep((s) => Math.max(0, s - 1));
+ }, []);
+
+ const goToStep = useCallback(
+ (step: number) => {
+ if (step >= 0 && step < currentStep) setCurrentStep(step);
+ },
+ [currentStep],
+ );
+
+ const reset = useCallback(() => setCurrentStep(0), []);
+
+ return {
+ isWizard,
+ currentStep,
+ totalSteps,
+ stepGroups,
+ currentFields,
+ isLastStep,
+ isSummaryStep,
+ stepLabels,
+ goNext,
+ goBack,
+ goToStep,
+ reset,
+ };
+}
diff --git a/app/src/lib/__tests__/widget/form-field-def.test.ts b/app/src/lib/__tests__/widget/form-field-def.test.ts
index c57f0b1d..87bcc84e 100644
--- a/app/src/lib/__tests__/widget/form-field-def.test.ts
+++ b/app/src/lib/__tests__/widget/form-field-def.test.ts
@@ -1,5 +1,9 @@
import { describe, it, expect } from "vitest";
-import { buildFormParams } from "@/lib/widget/form-field-def";
+import {
+ buildFormParams,
+ isWizardForm,
+ groupFieldsByStep,
+} from "@/lib/widget/form-field-def";
import type { FormFieldDef } from "@/lib/widget/form-field-def";
describe("buildFormParams", () => {
@@ -200,3 +204,110 @@ describe("buildFormParams", () => {
expect(result).toEqual({ param_period_from: "2024-01-01" });
});
});
+
+describe("isWizardForm", () => {
+ it("returns false when no fields have step", () => {
+ const fields: FormFieldDef[] = [
+ { id: "1", label: "Name", parameterName: "name", parameterType: "text" },
+ ];
+ expect(isWizardForm(fields)).toBe(false);
+ });
+
+ it("returns true when at least one field has step", () => {
+ const fields: FormFieldDef[] = [
+ {
+ id: "1",
+ label: "Name",
+ parameterName: "name",
+ parameterType: "text",
+ step: 0,
+ },
+ ];
+ expect(isWizardForm(fields)).toBe(true);
+ });
+
+ it("returns false for empty fields array", () => {
+ expect(isWizardForm([])).toBe(false);
+ });
+});
+
+describe("groupFieldsByStep", () => {
+ it("groups fields by step number", () => {
+ const fields: FormFieldDef[] = [
+ {
+ id: "1",
+ label: "Name",
+ parameterName: "name",
+ parameterType: "text",
+ step: 0,
+ },
+ {
+ id: "2",
+ label: "Email",
+ parameterName: "email",
+ parameterType: "text",
+ step: 0,
+ },
+ {
+ id: "3",
+ label: "Role",
+ parameterName: "role",
+ parameterType: "select",
+ step: 1,
+ },
+ ];
+ const groups = groupFieldsByStep(fields);
+ expect(groups).toHaveLength(2);
+ expect(groups[0]).toHaveLength(2);
+ expect(groups[1]).toHaveLength(1);
+ });
+
+ it("normalizes gaps in step numbers", () => {
+ const fields: FormFieldDef[] = [
+ {
+ id: "1",
+ label: "A",
+ parameterName: "a",
+ parameterType: "text",
+ step: 0,
+ },
+ {
+ id: "2",
+ label: "B",
+ parameterName: "b",
+ parameterType: "text",
+ step: 5,
+ },
+ ];
+ const groups = groupFieldsByStep(fields);
+ // Should normalize to 2 sequential steps, not 6
+ expect(groups).toHaveLength(2);
+ });
+
+ it("treats fields without step as step 0", () => {
+ const fields: FormFieldDef[] = [
+ { id: "1", label: "A", parameterName: "a", parameterType: "text" },
+ {
+ id: "2",
+ label: "B",
+ parameterName: "b",
+ parameterType: "text",
+ step: 1,
+ },
+ ];
+ const groups = groupFieldsByStep(fields);
+ expect(groups).toHaveLength(2);
+ expect(groups[0]).toHaveLength(1);
+ expect(groups[0][0].parameterName).toBe("a");
+ });
+
+ it("returns single group for non-wizard forms", () => {
+ const fields: FormFieldDef[] = [
+ { id: "1", label: "A", parameterName: "a", parameterType: "text" },
+ { id: "2", label: "B", parameterName: "b", parameterType: "text" },
+ ];
+ const groups = groupFieldsByStep(fields);
+ expect(groups).toHaveLength(1);
+ expect(groups[0]).toHaveLength(2);
+ });
+});
diff --git a/app/src/lib/__tests__/widget/form-field-validation.test.ts b/app/src/lib/__tests__/widget/form-field-validation.test.ts
index 437d8504..afb9bda1 100644
--- a/app/src/lib/__tests__/widget/form-field-validation.test.ts
+++ b/app/src/lib/__tests__/widget/form-field-validation.test.ts
@@ -1,5 +1,8 @@
import { describe, it, expect } from "vitest";
-import { validateFieldValue } from "@/lib/widget/form-field-validation";
+import {
+ validateFieldValue,
+ validateStepFields,
+} from "@/lib/widget/form-field-validation";
import type { FormFieldDef } from "@/lib/widget/form-field-def";
describe("validateFieldValue", () => {
@@ -153,3 +156,45 @@ describe("validateFieldValue", () => {
});
});
});
+
+describe("validateStepFields", () => {
+ it("returns empty object when all fields are valid", () => {
+ const fields: FormFieldDef[] = [
+ {
+ id: "1",
+ label: "Name",
+ parameterName: "name",
+ parameterType: "text",
+ required: true,
+ },
+ ];
+ const errors = validateStepFields(fields, { name: "Alice" });
+ expect(Object.keys(errors)).toHaveLength(0);
+ });
+
+ it("returns errors for invalid fields", () => {
+ const fields: FormFieldDef[] = [
+ {
+ id: "1",
+ label: "Name",
+ parameterName: "name",
+ parameterType: "text",
+ required: true,
+ },
+ {
+ id: "2",
+ label: "Email",
+ parameterName: "email",
+ parameterType: "text",
+ required: true,
+ },
+ ];
+ const errors = validateStepFields(fields, { name: "Alice" });
+ expect(errors.email).toBe("This field is required");
+ expect(errors.name).toBeUndefined();
+ });
+
+ it("returns empty object for empty fields array", () => {
+ expect(Object.keys(validateStepFields([], {}))).toHaveLength(0);
+ });
+});
diff --git a/app/src/lib/widget/form-field-def.ts b/app/src/lib/widget/form-field-def.ts
index 601ef31d..f63faa52 100644
--- a/app/src/lib/widget/form-field-def.ts
+++ b/app/src/lib/widget/form-field-def.ts
@@ -22,6 +22,8 @@ export interface FormFieldDef {
rangeStep?: number;
placeholder?: string;
searchable?: boolean;
+ /** Step index for multi-step wizard forms. Omit for single-page mode. */
+ step?: number;
}
/** Build the params object to send to the write-query API. */
@@ -72,3 +74,31 @@ export function buildFormParams(
}
return params;
}
+
+/** Returns true if any field has a `step` assigned (wizard mode). */
+export function isWizardForm(fields: FormFieldDef[]): boolean {
+ return fields.some((f) => f.step !== undefined);
+}
+
+/**
+ * Group fields by their step number, returning an array of arrays.
+ * Normalizes gaps in step numbers (e.g. steps 0, 2, 5 become indices 0, 1, 2).
+ * Fields without a step are placed in step 0.
+ */
+export function groupFieldsByStep(fields: FormFieldDef[]): FormFieldDef[][] {
+ if (!isWizardForm(fields)) {
+ return [fields];
+ }
+
+ // Collect unique step numbers and sort them
+ const stepSet = new Set();
+ for (const f of fields) {
+ stepSet.add(f.step ?? 0);
+ }
+ const sortedSteps = [...stepSet].sort((a, b) => a - b);
+
+ // Build groups in normalized order
+ return sortedSteps.map((stepNum) =>
+ fields.filter((f) => (f.step ?? 0) === stepNum),
+ );
+}
diff --git a/app/src/lib/widget/form-field-validation.ts b/app/src/lib/widget/form-field-validation.ts
index d3c079d0..6ffaedea 100644
--- a/app/src/lib/widget/form-field-validation.ts
+++ b/app/src/lib/widget/form-field-validation.ts
@@ -64,3 +64,21 @@ export function validateFieldValue(
return null;
}
}
+
+/**
+ * Validate all fields in a step. Returns a map of parameterName → error message
+ * for invalid fields, or an empty object if all fields are valid.
+ */
+export function validateStepFields(
+ stepFields: FormFieldDef[],
+ localValues: Record,
+): Record {
+ const errors: Record = {};
+ for (const field of stepFields) {
+ const error = validateFieldValue(field, localValues[field.parameterName]);
+ if (error) {
+ errors[field.parameterName] = error;
+ }
+ }
+ return errors;
+}
diff --git a/component/src/components/composed/__tests__/date-range-picker.test.tsx b/component/src/components/composed/__tests__/date-range-picker.test.tsx
index eec676d8..94078c57 100644
--- a/component/src/components/composed/__tests__/date-range-picker.test.tsx
+++ b/component/src/components/composed/__tests__/date-range-picker.test.tsx
@@ -65,69 +65,27 @@ describe("DateRangePicker", () => {
expect(screen.queryByText("Last 7 days")).not.toBeInTheDocument();
});
- it("calls onChange with correct range for 'Today' preset", async () => {
- const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
- const onChange = vi.fn();
- render();
-
- await user.click(screen.getByText("Pick a date range"));
- await user.click(screen.getByText("Today"));
-
- expect(onChange).toHaveBeenCalledOnce();
- const range = onChange.mock.calls[0][0];
- expect(format(range.from, "yyyy-MM-dd")).toBe("2025-06-15");
- expect(format(range.to, "yyyy-MM-dd")).toBe("2025-06-15");
- });
-
- it("calls onChange with correct range for 'Yesterday' preset", async () => {
- const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
- const onChange = vi.fn();
- render();
-
- await user.click(screen.getByText("Pick a date range"));
- await user.click(screen.getByText("Yesterday"));
-
- const range = onChange.mock.calls[0][0];
- expect(format(range.from, "yyyy-MM-dd")).toBe("2025-06-14");
- expect(format(range.to, "yyyy-MM-dd")).toBe("2025-06-14");
- });
-
- it("calls onChange with correct range for 'Last 7 days' preset", async () => {
- const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
- const onChange = vi.fn();
- render();
-
- await user.click(screen.getByText("Pick a date range"));
- await user.click(screen.getByText("Last 7 days"));
-
- const range = onChange.mock.calls[0][0];
- expect(format(range.from, "yyyy-MM-dd")).toBe("2025-06-09");
- expect(format(range.to, "yyyy-MM-dd")).toBe("2025-06-15");
- });
-
- it("calls onChange with correct range for 'This month' preset", async () => {
- const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
- const onChange = vi.fn();
- render();
-
- await user.click(screen.getByText("Pick a date range"));
- await user.click(screen.getByText("This month"));
-
- const range = onChange.mock.calls[0][0];
- expect(format(range.from, "yyyy-MM-dd")).toBe("2025-06-01");
- expect(format(range.to, "yyyy-MM-dd")).toBe("2025-06-30");
- });
-
- it("calls onChange with correct range for 'This year' preset", async () => {
- const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
- const onChange = vi.fn();
- render();
-
- await user.click(screen.getByText("Pick a date range"));
- await user.click(screen.getByText("This year"));
-
- const range = onChange.mock.calls[0][0];
- expect(format(range.from, "yyyy-MM-dd")).toBe("2025-01-01");
- expect(format(range.to, "yyyy-MM-dd")).toBe("2025-06-15");
- });
+ it.each([
+ ["Today", "2025-06-15", "2025-06-15"],
+ ["Yesterday", "2025-06-14", "2025-06-14"],
+ ["Last 7 days", "2025-06-09", "2025-06-15"],
+ ["Last 30 days", "2025-05-17", "2025-06-15"],
+ ["This month", "2025-06-01", "2025-06-30"],
+ ["This year", "2025-01-01", "2025-06-15"],
+ ])(
+ "calls onChange with correct range for '%s' preset",
+ async (label, expectedFrom, expectedTo) => {
+ const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
+ const onChange = vi.fn();
+ render();
+
+ await user.click(screen.getByText("Pick a date range"));
+ await user.click(screen.getByText(label));
+
+ expect(onChange).toHaveBeenCalledOnce();
+ const range = onChange.mock.calls[0][0];
+ expect(format(range.from, "yyyy-MM-dd")).toBe(expectedFrom);
+ expect(format(range.to, "yyyy-MM-dd")).toBe(expectedTo);
+ },
+ );
});
diff --git a/component/src/components/composed/__tests__/form-step-indicator.test.tsx b/component/src/components/composed/__tests__/form-step-indicator.test.tsx
new file mode 100644
index 00000000..32c85602
--- /dev/null
+++ b/component/src/components/composed/__tests__/form-step-indicator.test.tsx
@@ -0,0 +1,189 @@
+import { describe, it, expect, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { FormStepIndicator } from "../form-step-indicator";
+
+const labels = ["Personal", "Details", "Review"];
+
+describe("FormStepIndicator", () => {
+ describe("rendering", () => {
+ it("renders all step labels", () => {
+ render();
+
+ for (const label of labels) {
+ expect(screen.getByText(label)).toBeInTheDocument();
+ }
+ });
+
+ it("renders the nav with aria-label", () => {
+ render();
+
+ expect(
+ screen.getByRole("navigation", { name: "Form steps" }),
+ ).toBeInTheDocument();
+ });
+
+ it("renders the container with data-testid", () => {
+ render();
+
+ expect(screen.getByTestId("form-step-indicator")).toBeInTheDocument();
+ });
+ });
+
+ describe("step badges", () => {
+ it("shows numeric badge for current and upcoming steps", () => {
+ render();
+
+ // Current step shows "1"
+ expect(screen.getByText("1")).toBeInTheDocument();
+ // Upcoming steps show "2" and "3"
+ expect(screen.getByText("2")).toBeInTheDocument();
+ expect(screen.getByText("3")).toBeInTheDocument();
+ });
+
+ it("shows checkmark for completed steps", () => {
+ render();
+
+ // Steps 0 and 1 are completed — should show checkmarks
+ const checkmarks = screen.getAllByText("✓");
+ expect(checkmarks).toHaveLength(2);
+
+ // Step 3 (current) still shows number
+ expect(screen.getByText("3")).toBeInTheDocument();
+ });
+ });
+
+ describe("aria-current", () => {
+ it("marks current step with aria-current=step", () => {
+ render();
+
+ const buttons = screen.getAllByRole("button");
+ // Step 0 is completed, step 1 is current, step 2 is upcoming
+ expect(buttons[0]).not.toHaveAttribute("aria-current");
+ expect(buttons[1]).toHaveAttribute("aria-current", "step");
+ expect(buttons[2]).not.toHaveAttribute("aria-current");
+ });
+ });
+
+ describe("disabled state", () => {
+ it("disables upcoming steps", () => {
+ render();
+
+ const buttons = screen.getAllByRole("button");
+ // Step 0 is current (not completed), step 1 & 2 are upcoming — all disabled
+ expect(buttons[0]).toBeDisabled();
+ expect(buttons[1]).toBeDisabled();
+ expect(buttons[2]).toBeDisabled();
+ });
+
+ it("enables completed steps", () => {
+ render();
+
+ const buttons = screen.getAllByRole("button");
+ // Steps 0 and 1 are completed — enabled
+ expect(buttons[0]).not.toBeDisabled();
+ expect(buttons[1]).not.toBeDisabled();
+ // Step 2 is current — disabled
+ expect(buttons[2]).toBeDisabled();
+ });
+ });
+
+ describe("click interaction", () => {
+ it("calls onStepClick with step index when a completed step is clicked", async () => {
+ const onClick = vi.fn();
+ const user = userEvent.setup({ delay: null });
+
+ render(
+ ,
+ );
+
+ const buttons = screen.getAllByRole("button");
+ await user.click(buttons[0]);
+
+ expect(onClick).toHaveBeenCalledWith(0);
+ });
+
+ it("does not call onStepClick when clicking current step", async () => {
+ const onClick = vi.fn();
+ const user = userEvent.setup({ delay: null });
+
+ render(
+ ,
+ );
+
+ const buttons = screen.getAllByRole("button");
+ // Current step button is disabled — click should not fire
+ await user.click(buttons[1]);
+
+ expect(onClick).not.toHaveBeenCalled();
+ });
+
+ it("does not call onStepClick when clicking upcoming step", async () => {
+ const onClick = vi.fn();
+ const user = userEvent.setup({ delay: null });
+
+ render(
+ ,
+ );
+
+ const buttons = screen.getAllByRole("button");
+ await user.click(buttons[2]);
+
+ expect(onClick).not.toHaveBeenCalled();
+ });
+
+ it("renders without errors when onStepClick is not provided", () => {
+ render();
+
+ // Completed steps still render — just no click handler
+ const buttons = screen.getAllByRole("button");
+ expect(buttons[0]).not.toBeDisabled();
+ });
+ });
+
+ describe("connector lines", () => {
+ it("renders connector lines between steps", () => {
+ const { container } = render(
+ ,
+ );
+
+ // There should be (labels.length - 1) connectors
+ const connectors = container.querySelectorAll(".h-px.flex-1");
+ expect(connectors).toHaveLength(2);
+ });
+
+ it("completed connectors have primary color", () => {
+ const { container } = render(
+ ,
+ );
+
+ const connectors = container.querySelectorAll(".h-px.flex-1");
+ // Connector before step 1 (completed) is primary
+ expect(connectors[0]).toHaveClass("bg-primary");
+ // Connector before step 2 (current, not completed) is border
+ expect(connectors[1]).toHaveClass("bg-border");
+ });
+
+ it("upcoming connectors have border color", () => {
+ const { container } = render(
+ ,
+ );
+
+ const connectors = container.querySelectorAll(".h-px.flex-1");
+ expect(connectors[0]).toHaveClass("bg-border");
+ expect(connectors[1]).toHaveClass("bg-border");
+ });
+ });
+});
diff --git a/component/src/components/composed/form-step-indicator.tsx b/component/src/components/composed/form-step-indicator.tsx
new file mode 100644
index 00000000..e5ee82a6
--- /dev/null
+++ b/component/src/components/composed/form-step-indicator.tsx
@@ -0,0 +1,81 @@
+"use client";
+
+import * as React from "react";
+import { cn } from "@/lib/utils";
+
+export interface FormStepIndicatorProps {
+ /** Labels for each step */
+ stepLabels: string[];
+ /** Current active step index (0-based) */
+ currentStep: number;
+ /** Callback when a completed step is clicked */
+ onStepClick?: (step: number) => void;
+}
+
+/**
+ * Step indicator for multi-step form wizards.
+ * Shows completed, current, and upcoming steps.
+ * Completed steps are clickable to navigate back.
+ */
+function FormStepIndicator({
+ stepLabels,
+ currentStep,
+ onStepClick,
+}: FormStepIndicatorProps) {
+ return (
+
+ );
+}
+
+export { FormStepIndicator };
diff --git a/component/src/components/composed/index.ts b/component/src/components/composed/index.ts
index e60df806..57ac5056 100644
--- a/component/src/components/composed/index.ts
+++ b/component/src/components/composed/index.ts
@@ -179,6 +179,10 @@ export {
type FormWidgetProps,
type FormFieldDef,
} from "./form-widget";
+export {
+ FormStepIndicator,
+ type FormStepIndicatorProps,
+} from "./form-step-indicator";
// Content Widgets
export { MarkdownWidget, type MarkdownWidgetProps } from "./markdown-widget";
diff --git a/connection/__tests__/advanced-connection-options.test.ts b/connection/__tests__/advanced-connection-options.test.ts
index 03703e55..954f6c54 100644
--- a/connection/__tests__/advanced-connection-options.test.ts
+++ b/connection/__tests__/advanced-connection-options.test.ts
@@ -1,6 +1,5 @@
import { AuthType } from "../src/generalized/interfaces";
import type {
- AdvancedConnectionOptions,
Neo4jAdvancedOptions,
PostgresAdvancedOptions,
} from "../src/generalized/interfaces";
@@ -66,41 +65,6 @@ const pgAuth = {
// Tests
// ---------------------------------------------------------------------------
-describe("AdvancedConnectionOptions split types", () => {
- it("Neo4jAdvancedOptions allows partial fields", () => {
- const opts: Neo4jAdvancedOptions = {};
- expect(opts).toEqual({});
- });
-
- it("Neo4jAdvancedOptions accepts all Neo4j-specific fields", () => {
- const opts: Neo4jAdvancedOptions = {
- neo4jConnectionTimeout: 5000,
- neo4jQueryTimeout: 3000,
- neo4jMaxPoolSize: 50,
- neo4jAcquisitionTimeout: 10000,
- };
- expect(opts.neo4jConnectionTimeout).toBe(5000);
- });
-
- it("PostgresAdvancedOptions accepts all PostgreSQL-specific fields", () => {
- const opts: PostgresAdvancedOptions = {
- pgConnectionTimeoutMillis: 8000,
- pgIdleTimeoutMillis: 15000,
- pgMaxPoolSize: 20,
- pgStatementTimeout: 60000,
- pgSslRejectUnauthorized: false,
- };
- expect(opts.pgMaxPoolSize).toBe(20);
- });
-
- it("AdvancedConnectionOptions union accepts either type", () => {
- const neo4j: AdvancedConnectionOptions = { neo4jConnectionTimeout: 5000 };
- const pg: AdvancedConnectionOptions = { pgMaxPoolSize: 20 };
- expect(neo4j).toBeDefined();
- expect(pg).toBeDefined();
- });
-});
-
describe("Neo4jAuthenticationModule with advanced options", () => {
beforeEach(() => {
jest.clearAllMocks();
diff --git a/connection/jest.config.js b/connection/jest.config.js
index db9761b9..93e29050 100644
--- a/connection/jest.config.js
+++ b/connection/jest.config.js
@@ -4,7 +4,7 @@ module.exports = {
transform: {
"^.+.tsx?$": ["ts-jest", { diagnostics: false }],
},
- testPathIgnorePatterns: ["utils"],
+ testPathIgnorePatterns: ["utils", "dist"],
globalSetup: "./__tests__/utils/setup.ts",
globalTeardown: "./__tests__/utils/teardown.ts",
// Integration tests hit a live Neo4j/PostgreSQL testcontainer.