From 95ac4048a160ab7291677fb1032ad6adaacf08dc Mon Sep 17 00:00:00 2001 From: alfredorubin96 Date: Wed, 13 May 2026 13:14:43 +0200 Subject: [PATCH 1/7] feat(form): multi-step form wizard (#165) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add optional multi-step wizard mode to the form widget. Fields can be assigned to numbered steps, enabling guided data entry workflows. Data model: - FormFieldDef gains optional `step?: number` field (no DB migration) - chartOptions.stepLabels: string[] for custom step names - chartOptions.enableSummary: boolean (default true when steps exist) New components: - FormStepIndicator (component/) — progress bar with completed/current states - useFormWizard hook — manages step navigation, per-step validation, reset Renderer changes: - Wizard mode: shows step indicator, renders only current step's fields - Next button validates current step before advancing - Back button navigates freely (no validation) - Summary step shows all values for review before submit - Non-wizard forms render unchanged (backward compatible) Editor changes: - Step number input per field in form-fields-editor - Step label inputs in advanced tab (shown when steps are assigned) Closes #165 Co-Authored-By: Claude Opus 4.6 (1M context) --- app/src/components/form-widget-renderer.tsx | 194 +++++++++++++----- app/src/components/widget-editor-modal.tsx | 47 +++++ .../widget-editor/form-fields-editor.tsx | 23 +++ app/src/hooks/use-form-wizard.ts | 104 ++++++++++ .../__tests__/widget/form-field-def.test.ts | 113 +++++++++- .../widget/form-field-validation.test.ts | 47 ++++- app/src/lib/widget/form-field-def.ts | 30 +++ app/src/lib/widget/form-field-validation.ts | 18 ++ .../composed/form-step-indicator.tsx | 81 ++++++++ component/src/components/composed/index.ts | 4 + 10 files changed, 606 insertions(+), 55 deletions(-) create mode 100644 app/src/hooks/use-form-wizard.ts create mode 100644 component/src/components/composed/form-step-indicator.tsx diff --git a/app/src/components/form-widget-renderer.tsx b/app/src/components/form-widget-renderer.tsx index cb7691c3..8e43c687 100644 --- a/app/src/components/form-widget-renderer.tsx +++ b/app/src/components/form-widget-renderer.tsx @@ -21,8 +21,10 @@ import { Label, 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"; @@ -306,6 +308,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 (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 "—"; + } + if (field.parameterType === "number-range" && Array.isArray(value)) { + return `${value[0]} – ${value[1]}`; + } + return String(value); +} + export function FormWidgetRenderer({ connectionId, query, @@ -322,6 +341,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); @@ -468,6 +489,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] }); @@ -524,15 +546,16 @@ export function FormWidgetRenderer({ )} - {/* - * When the viewer is read-only, we disable pointer events on the - * field container and dim it to 60% opacity. This blocks all - * interactions (clicks, hover, focus via mouse) without having to - * thread a `disabled` prop through every FieldInput variant. The - * aria-disabled attribute tells screen readers the section is - * inactive. Submit is handled separately via the Button's own - * `disabled` prop below. - */} + {/* Step indicator for wizard forms */} + {wizard.isWizard && ( + + )} + + {/* Field container — inert when read-only */}
- {fields.map((field) => ( -
handleFieldBlur(field)} - > - - - {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)} + > + + + {fieldErrors[field.parameterName] && ( +

+ {fieldErrors[field.parameterName]} +

+ )} +
+ )) + )}
{successMessage && ( @@ -577,24 +623,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 41035919..933bd70c 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" + /> + ), + ); + })()} + + )} +

After Submit

diff --git a/app/src/components/widget-editor/form-fields-editor.tsx b/app/src/components/widget-editor/form-fields-editor.tsx index 689271d2..332d77dc 100644 --- a/app/src/components/widget-editor/form-fields-editor.tsx +++ b/app/src/components/widget-editor/form-fields-editor.tsx @@ -177,6 +177,29 @@ function SortableFieldItem({

+ {/* Step (for multi-step wizard) */} +
+ + + onUpdate(field.id, { + step: + e.target.value === "" + ? undefined + : parseInt(e.target.value, 10), + }) + } + 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/use-form-wizard.ts b/app/src/hooks/use-form-wizard.ts new file mode 100644 index 00000000..72fe3c4f --- /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 < 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/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"; From 94c0ba865a2df09d63257a31cb365742943b9677 Mon Sep 17 00:00:00 2001 From: alfredorubin96 Date: Wed, 13 May 2026 20:06:51 +0200 Subject: [PATCH 2/7] fix(test): stabilize login test timeout and add wizard coverage Skip per-keystroke delays in login page tests that timed out in CI. Add unit tests for useFormWizard hook, FormStepIndicator component, and FormWidgetRenderer wizard mode to satisfy SonarCloud 80% new code coverage gate. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../app/(auth)/login/__tests__/page.test.tsx | 4 +- .../form-widget-renderer-wizard.test.tsx | 492 ++++++++++++++++++ .../hooks/__tests__/use-form-wizard.test.ts | 399 ++++++++++++++ .../__tests__/form-step-indicator.test.tsx | 189 +++++++ 4 files changed, 1082 insertions(+), 2 deletions(-) create mode 100644 app/src/components/__tests__/form-widget-renderer-wizard.test.tsx create mode 100644 app/src/hooks/__tests__/use-form-wizard.test.ts create mode 100644 component/src/components/composed/__tests__/form-step-indicator.test.tsx 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__/form-widget-renderer-wizard.test.tsx b/app/src/components/__tests__/form-widget-renderer-wizard.test.tsx new file mode 100644 index 00000000..778f0890 --- /dev/null +++ b/app/src/components/__tests__/form-widget-renderer-wizard.test.tsx @@ -0,0 +1,492 @@ +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) => ( + + ))} +
+ ), + 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(() => defaultWizardState); + +vi.mock("@/hooks/use-form-wizard", () => ({ + useFormWizard: (...args: unknown[]) => mockUseFormWizard(...args), +})); + +/* ---------- 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("formats array values as comma-separated", () => { + 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/hooks/__tests__/use-form-wizard.test.ts b/app/src/hooks/__tests__/use-form-wizard.test.ts new file mode 100644 index 00000000..59301f73 --- /dev/null +++ b/app/src/hooks/__tests__/use-form-wizard.test.ts @@ -0,0 +1,399 @@ +// @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 + }); + }); + + 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/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..9464436e --- /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, within } 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"); + }); + }); +}); From 82647f8c7b8f3c65a5e8df99067fa9ec1eacd6f4 Mon Sep 17 00:00:00 2001 From: alfredorubin96 Date: Wed, 13 May 2026 22:27:46 +0200 Subject: [PATCH 3/7] fix(form): address CodeRabbit review findings - Fix unreachable number-range branch in formatSummaryValue by moving the check before the generic array early-return - Add NaN guard to step number input in form-fields-editor - Add bounds validation (step >= 0) to goToStep in useFormWizard - Remove unused import in form-step-indicator test - Rename misleading test description Co-Authored-By: Claude Opus 4.6 (1M context) --- .../form-widget-renderer-wizard.test.tsx | 2 +- app/src/components/form-widget-renderer.tsx | 6 +++--- .../widget-editor/form-fields-editor.tsx | 11 ++++++----- app/src/hooks/__tests__/use-form-wizard.test.ts | 15 +++++++++++++++ app/src/hooks/use-form-wizard.ts | 2 +- .../__tests__/form-step-indicator.test.tsx | 2 +- 6 files changed, 27 insertions(+), 11 deletions(-) diff --git a/app/src/components/__tests__/form-widget-renderer-wizard.test.tsx b/app/src/components/__tests__/form-widget-renderer-wizard.test.tsx index 778f0890..2f69e23f 100644 --- a/app/src/components/__tests__/form-widget-renderer-wizard.test.tsx +++ b/app/src/components/__tests__/form-widget-renderer-wizard.test.tsx @@ -457,7 +457,7 @@ describe("formatSummaryValue — via summary step rendering", () => { }); }); - it("formats array values as comma-separated", () => { + it("renders multi-select field label on summary step", () => { const multiField: FormFieldDef = { id: "f3", label: "Tags", diff --git a/app/src/components/form-widget-renderer.tsx b/app/src/components/form-widget-renderer.tsx index 8e43c687..49d5d610 100644 --- a/app/src/components/form-widget-renderer.tsx +++ b/app/src/components/form-widget-renderer.tsx @@ -311,6 +311,9 @@ function FieldInput({ /** 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 }; @@ -319,9 +322,6 @@ function formatSummaryValue(value: unknown, field: FormFieldDef): string { if (r.to) return `To ${r.to}`; return "—"; } - if (field.parameterType === "number-range" && Array.isArray(value)) { - return `${value[0]} – ${value[1]}`; - } return String(value); } diff --git a/app/src/components/widget-editor/form-fields-editor.tsx b/app/src/components/widget-editor/form-fields-editor.tsx index 332d77dc..424dfb52 100644 --- a/app/src/components/widget-editor/form-fields-editor.tsx +++ b/app/src/components/widget-editor/form-fields-editor.tsx @@ -184,14 +184,15 @@ function SortableFieldItem({ type="number" min={0} value={field.step ?? ""} - onChange={(e) => + onChange={(e) => { + const parsed = parseInt(e.target.value, 10); onUpdate(field.id, { step: - e.target.value === "" + e.target.value === "" || Number.isNaN(parsed) ? undefined - : parseInt(e.target.value, 10), - }) - } + : parsed, + }); + }} placeholder="—" className="h-7 text-xs w-20" /> diff --git a/app/src/hooks/__tests__/use-form-wizard.test.ts b/app/src/hooks/__tests__/use-form-wizard.test.ts index 59301f73..6ea2092f 100644 --- a/app/src/hooks/__tests__/use-form-wizard.test.ts +++ b/app/src/hooks/__tests__/use-form-wizard.test.ts @@ -297,6 +297,21 @@ describe("useFormWizard", () => { }); 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", () => { diff --git a/app/src/hooks/use-form-wizard.ts b/app/src/hooks/use-form-wizard.ts index 72fe3c4f..1c0fe864 100644 --- a/app/src/hooks/use-form-wizard.ts +++ b/app/src/hooks/use-form-wizard.ts @@ -80,7 +80,7 @@ export function useFormWizard( const goToStep = useCallback( (step: number) => { - if (step < currentStep) setCurrentStep(step); + if (step >= 0 && step < currentStep) setCurrentStep(step); }, [currentStep], ); diff --git a/component/src/components/composed/__tests__/form-step-indicator.test.tsx b/component/src/components/composed/__tests__/form-step-indicator.test.tsx index 9464436e..32c85602 100644 --- a/component/src/components/composed/__tests__/form-step-indicator.test.tsx +++ b/component/src/components/composed/__tests__/form-step-indicator.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from "vitest"; -import { render, screen, within } from "@testing-library/react"; +import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { FormStepIndicator } from "../form-step-indicator"; From bc2e64899d382a5f488e5e19bcdd374286499f9e Mon Sep 17 00:00:00 2001 From: alfredorubin96 Date: Wed, 13 May 2026 22:53:53 +0200 Subject: [PATCH 4/7] fix(test): stabilize signup tests and add formatSummaryValue coverage Skip per-keystroke delays in signup page tests that timed out in CI. Fix TS spread error in wizard test mock. Export and add direct unit tests for formatSummaryValue covering all branches (number-range, multi-select, date-range, scalars, empty values). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../app/(auth)/signup/__tests__/page.test.tsx | 8 +- .../form-widget-renderer-wizard.test.tsx | 4 +- .../__tests__/format-summary-value.test.tsx | 169 ++++++++++++++++++ app/src/components/form-widget-renderer.tsx | 5 +- 4 files changed, 179 insertions(+), 7 deletions(-) create mode 100644 app/src/components/__tests__/format-summary-value.test.tsx diff --git a/app/src/app/(auth)/signup/__tests__/page.test.tsx b/app/src/app/(auth)/signup/__tests__/page.test.tsx index ff372b2d..3668c67e 100644 --- a/app/src/app/(auth)/signup/__tests__/page.test.tsx +++ b/app/src/app/(auth)/signup/__tests__/page.test.tsx @@ -238,7 +238,7 @@ describe("SignupPage", () => { it("shows error when passwords do not match", async () => { mockFetchBootstrapStatus(false, true); - const user = userEvent.setup(); + const user = userEvent.setup({ delay: null }); render(); await waitFor(() => { @@ -263,7 +263,7 @@ describe("SignupPage", () => { error: "Email already registered", }); - const user = userEvent.setup(); + const user = userEvent.setup({ delay: null }); render(); await waitFor(() => { @@ -286,7 +286,7 @@ describe("SignupPage", () => { mockSignup.mockResolvedValue({ success: true }); mockSignIn.mockResolvedValue({ error: null }); - const user = userEvent.setup(); + const user = userEvent.setup({ delay: null }); render(); await waitFor(() => { @@ -309,7 +309,7 @@ describe("SignupPage", () => { mockSignup.mockResolvedValue({ success: true }); mockSignIn.mockResolvedValue({ error: "some-error" }); - const user = userEvent.setup(); + const user = userEvent.setup({ delay: null }); render(); await waitFor(() => { diff --git a/app/src/components/__tests__/form-widget-renderer-wizard.test.tsx b/app/src/components/__tests__/form-widget-renderer-wizard.test.tsx index 2f69e23f..517e317b 100644 --- a/app/src/components/__tests__/form-widget-renderer-wizard.test.tsx +++ b/app/src/components/__tests__/form-widget-renderer-wizard.test.tsx @@ -118,10 +118,10 @@ const defaultWizardState = { reset: mockReset, }; -const mockUseFormWizard = vi.fn(() => defaultWizardState); +const mockUseFormWizard = vi.fn().mockReturnValue(defaultWizardState); vi.mock("@/hooks/use-form-wizard", () => ({ - useFormWizard: (...args: unknown[]) => mockUseFormWizard(...args), + useFormWizard: () => mockUseFormWizard(), })); /* ---------- import under test ---------- */ diff --git a/app/src/components/__tests__/format-summary-value.test.tsx b/app/src/components/__tests__/format-summary-value.test.tsx new file mode 100644 index 00000000..448a35ae --- /dev/null +++ b/app/src/components/__tests__/format-summary-value.test.tsx @@ -0,0 +1,169 @@ +import { describe, it, expect, vi } from "vitest"; +import React from "react"; + +/* ---------- mocks (needed to import form-widget-renderer) ---------- */ + +vi.mock("next-auth/react", () => ({ useSession: () => ({ data: null }) })); +vi.mock("@neoboard/components", () => ({ + ParamSelector: () =>
, + ParamMultiSelector: () =>
, + DatePickerParameter: () =>
, + DateRangeParameter: () =>
, + DateRelativePicker: () =>
, + NumberRangeSlider: () =>
, + CascadingSelector: () =>
, + FormStepIndicator: () =>
, + Button: () =>