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.