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: () => ,
+ Label: () => ,
+}));
+vi.mock("@/components/debounced-text-input", () => ({
+ DebouncedTextInput: () => ,
+}));
+vi.mock("@/stores/parameter-store", () => ({ useParameterValues: () => ({}) }));
+vi.mock("@/hooks/use-write-query-execution", () => ({
+ useWriteQueryExecution: () => ({ mutate: vi.fn(), isPending: false }),
+}));
+vi.mock("@/hooks/use-seed-query", () => ({
+ useSeedQuery: () => ({ options: [], loading: false }),
+}));
+vi.mock("@/hooks/use-form-wizard", () => ({
+ useFormWizard: () => ({
+ isWizard: false,
+ currentStep: 0,
+ totalSteps: 1,
+ stepGroups: [],
+ currentFields: [],
+ isLastStep: true,
+ isSummaryStep: false,
+ stepLabels: [],
+ goNext: vi.fn(),
+ goBack: vi.fn(),
+ goToStep: vi.fn(),
+ reset: vi.fn(),
+ }),
+}));
+vi.mock("@tanstack/react-query", async () => {
+ const actual = await vi.importActual(
+ "@tanstack/react-query",
+ );
+ return { ...actual, useQueryClient: () => ({ invalidateQueries: vi.fn() }) };
+});
+
+/* ---------- import under test ---------- */
+import { formatSummaryValue } from "../form-widget-renderer";
+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,
+ };
+}
+
+/* ---------- tests ---------- */
+
+describe("formatSummaryValue", () => {
+ const textField = makeField({ parameterName: "name" });
+
+ describe("empty values", () => {
+ it("returns dash for undefined", () => {
+ expect(formatSummaryValue(undefined, textField)).toBe("—");
+ });
+
+ it("returns dash for null", () => {
+ expect(formatSummaryValue(null, textField)).toBe("—");
+ });
+
+ it("returns dash for empty string", () => {
+ expect(formatSummaryValue("", textField)).toBe("—");
+ });
+ });
+
+ describe("number-range", () => {
+ const rangeField = makeField({
+ parameterName: "price",
+ parameterType: "number-range",
+ });
+
+ it("formats as min – max", () => {
+ expect(formatSummaryValue([10, 50], rangeField)).toBe("10 – 50");
+ });
+
+ it("handles zero values", () => {
+ expect(formatSummaryValue([0, 100], rangeField)).toBe("0 – 100");
+ });
+ });
+
+ describe("arrays (multi-select)", () => {
+ const multiField = makeField({
+ parameterName: "tags",
+ parameterType: "multi-select",
+ });
+
+ it("joins array values with comma", () => {
+ expect(formatSummaryValue(["a", "b", "c"], multiField)).toBe("a, b, c");
+ });
+
+ it("returns dash for empty array", () => {
+ expect(formatSummaryValue([], multiField)).toBe("—");
+ });
+
+ it("handles single-element array", () => {
+ expect(formatSummaryValue(["only"], multiField)).toBe("only");
+ });
+ });
+
+ describe("date-range", () => {
+ const dateRangeField = makeField({
+ parameterName: "period",
+ parameterType: "date-range",
+ });
+
+ it("formats from → to when both present", () => {
+ expect(
+ formatSummaryValue(
+ { from: "2026-01-01", to: "2026-12-31" },
+ dateRangeField,
+ ),
+ ).toBe("2026-01-01 → 2026-12-31");
+ });
+
+ it("formats From prefix when only from", () => {
+ expect(formatSummaryValue({ from: "2026-01-01" }, dateRangeField)).toBe(
+ "From 2026-01-01",
+ );
+ });
+
+ it("formats To prefix when only to", () => {
+ expect(formatSummaryValue({ to: "2026-12-31" }, dateRangeField)).toBe(
+ "To 2026-12-31",
+ );
+ });
+
+ it("returns dash when both from and to are empty", () => {
+ expect(formatSummaryValue({ from: "", to: "" }, dateRangeField)).toBe(
+ "—",
+ );
+ });
+ });
+
+ describe("scalar values", () => {
+ it("stringifies numbers", () => {
+ expect(formatSummaryValue(42, textField)).toBe("42");
+ });
+
+ it("passes strings through", () => {
+ expect(formatSummaryValue("hello", textField)).toBe("hello");
+ });
+
+ it("stringifies booleans", () => {
+ expect(formatSummaryValue(true, textField)).toBe("true");
+ });
+ });
+});
diff --git a/app/src/components/form-widget-renderer.tsx b/app/src/components/form-widget-renderer.tsx
index 49d5d610..6e59a02a 100644
--- a/app/src/components/form-widget-renderer.tsx
+++ b/app/src/components/form-widget-renderer.tsx
@@ -309,7 +309,10 @@ function FieldInput({
// ─── Main renderer ────────────────────────────────────────────────────────────
/** Format a field value for the summary step display. */
-function formatSummaryValue(value: unknown, field: FormFieldDef): string {
+export 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]}`;
From 9ade9cca064905c41d99d72146143044fe389382 Mon Sep 17 00:00:00 2001
From: alfredorubin96
Date: Wed, 13 May 2026 23:06:18 +0200
Subject: [PATCH 5/7] chore(test): remove unnecessary formatSummaryValue test
file
Revert the export and drop the 15-test file that only existed to
compensate for SonarCloud recounting lines after the source edit.
Co-Authored-By: Claude Opus 4.6 (1M context)
---
.../__tests__/format-summary-value.test.tsx | 169 ------------------
app/src/components/form-widget-renderer.tsx | 5 +-
2 files changed, 1 insertion(+), 173 deletions(-)
delete mode 100644 app/src/components/__tests__/format-summary-value.test.tsx
diff --git a/app/src/components/__tests__/format-summary-value.test.tsx b/app/src/components/__tests__/format-summary-value.test.tsx
deleted file mode 100644
index 448a35ae..00000000
--- a/app/src/components/__tests__/format-summary-value.test.tsx
+++ /dev/null
@@ -1,169 +0,0 @@
-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: () => ,
- Label: () => ,
-}));
-vi.mock("@/components/debounced-text-input", () => ({
- DebouncedTextInput: () => ,
-}));
-vi.mock("@/stores/parameter-store", () => ({ useParameterValues: () => ({}) }));
-vi.mock("@/hooks/use-write-query-execution", () => ({
- useWriteQueryExecution: () => ({ mutate: vi.fn(), isPending: false }),
-}));
-vi.mock("@/hooks/use-seed-query", () => ({
- useSeedQuery: () => ({ options: [], loading: false }),
-}));
-vi.mock("@/hooks/use-form-wizard", () => ({
- useFormWizard: () => ({
- isWizard: false,
- currentStep: 0,
- totalSteps: 1,
- stepGroups: [],
- currentFields: [],
- isLastStep: true,
- isSummaryStep: false,
- stepLabels: [],
- goNext: vi.fn(),
- goBack: vi.fn(),
- goToStep: vi.fn(),
- reset: vi.fn(),
- }),
-}));
-vi.mock("@tanstack/react-query", async () => {
- const actual = await vi.importActual(
- "@tanstack/react-query",
- );
- return { ...actual, useQueryClient: () => ({ invalidateQueries: vi.fn() }) };
-});
-
-/* ---------- import under test ---------- */
-import { formatSummaryValue } from "../form-widget-renderer";
-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,
- };
-}
-
-/* ---------- tests ---------- */
-
-describe("formatSummaryValue", () => {
- const textField = makeField({ parameterName: "name" });
-
- describe("empty values", () => {
- it("returns dash for undefined", () => {
- expect(formatSummaryValue(undefined, textField)).toBe("—");
- });
-
- it("returns dash for null", () => {
- expect(formatSummaryValue(null, textField)).toBe("—");
- });
-
- it("returns dash for empty string", () => {
- expect(formatSummaryValue("", textField)).toBe("—");
- });
- });
-
- describe("number-range", () => {
- const rangeField = makeField({
- parameterName: "price",
- parameterType: "number-range",
- });
-
- it("formats as min – max", () => {
- expect(formatSummaryValue([10, 50], rangeField)).toBe("10 – 50");
- });
-
- it("handles zero values", () => {
- expect(formatSummaryValue([0, 100], rangeField)).toBe("0 – 100");
- });
- });
-
- describe("arrays (multi-select)", () => {
- const multiField = makeField({
- parameterName: "tags",
- parameterType: "multi-select",
- });
-
- it("joins array values with comma", () => {
- expect(formatSummaryValue(["a", "b", "c"], multiField)).toBe("a, b, c");
- });
-
- it("returns dash for empty array", () => {
- expect(formatSummaryValue([], multiField)).toBe("—");
- });
-
- it("handles single-element array", () => {
- expect(formatSummaryValue(["only"], multiField)).toBe("only");
- });
- });
-
- describe("date-range", () => {
- const dateRangeField = makeField({
- parameterName: "period",
- parameterType: "date-range",
- });
-
- it("formats from → to when both present", () => {
- expect(
- formatSummaryValue(
- { from: "2026-01-01", to: "2026-12-31" },
- dateRangeField,
- ),
- ).toBe("2026-01-01 → 2026-12-31");
- });
-
- it("formats From prefix when only from", () => {
- expect(formatSummaryValue({ from: "2026-01-01" }, dateRangeField)).toBe(
- "From 2026-01-01",
- );
- });
-
- it("formats To prefix when only to", () => {
- expect(formatSummaryValue({ to: "2026-12-31" }, dateRangeField)).toBe(
- "To 2026-12-31",
- );
- });
-
- it("returns dash when both from and to are empty", () => {
- expect(formatSummaryValue({ from: "", to: "" }, dateRangeField)).toBe(
- "—",
- );
- });
- });
-
- describe("scalar values", () => {
- it("stringifies numbers", () => {
- expect(formatSummaryValue(42, textField)).toBe("42");
- });
-
- it("passes strings through", () => {
- expect(formatSummaryValue("hello", textField)).toBe("hello");
- });
-
- it("stringifies booleans", () => {
- expect(formatSummaryValue(true, textField)).toBe("true");
- });
- });
-});
diff --git a/app/src/components/form-widget-renderer.tsx b/app/src/components/form-widget-renderer.tsx
index 6e59a02a..49d5d610 100644
--- a/app/src/components/form-widget-renderer.tsx
+++ b/app/src/components/form-widget-renderer.tsx
@@ -309,10 +309,7 @@ function FieldInput({
// ─── Main renderer ────────────────────────────────────────────────────────────
/** Format a field value for the summary step display. */
-export function formatSummaryValue(
- value: unknown,
- field: FormFieldDef,
-): string {
+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]}`;
From 4d247ebc418939fd8ed9d5e8497dbc04649588b5 Mon Sep 17 00:00:00 2001
From: alfredorubin96
Date: Wed, 13 May 2026 23:17:01 +0200
Subject: [PATCH 6/7] test: trim type-checking tests and consolidate date-range
presets
Remove 4 TypeScript type-checking tests from advanced-connection-options
that only verified compilation, not runtime behavior. Consolidate 5
repetitive date-range-picker preset tests into a single test.each,
adding the missing 'Last 30 days' case in the process.
Co-Authored-By: Claude Opus 4.6 (1M context)
---
.../__tests__/date-range-picker.test.tsx | 88 +++++--------------
.../advanced-connection-options.test.ts | 36 --------
2 files changed, 23 insertions(+), 101 deletions(-)
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/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();
From d18e51eac2f06e487c216ddecdbf50e00aa6dd5c Mon Sep 17 00:00:00 2001
From: alfredorubin96
Date: Thu, 14 May 2026 08:58:31 +0200
Subject: [PATCH 7/7] test: fix test errors and remove hardcoded E2E waits
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- connection: exclude dist/ from Jest testPathIgnorePatterns (fixes 2
spurious suite failures from compiled output)
- app: rename chart-error-boundary.test.tsx → chart-renderer.test.tsx
to match the component it actually tests
- e2e/transforms: remove 1s sleep after connection select (Run button
enabled check already gates); replace 1s post-save sleep with
Save button re-enabled assertion
- e2e/form-widget: remove duplicate 400ms debounce wait
- e2e/widget-lab: replace 1s post-save sleep with Save button
re-enabled assertion
Co-Authored-By: Claude Opus 4.6 (1M context)
---
app/e2e/form-widget.spec.ts | 4 ----
app/e2e/transforms.spec.ts | 9 ++++-----
app/e2e/widget-lab.spec.ts | 7 ++++---
...t-error-boundary.test.tsx => chart-renderer.test.tsx} | 0
connection/jest.config.js | 2 +-
5 files changed, 9 insertions(+), 13 deletions(-)
rename app/src/components/__tests__/{chart-error-boundary.test.tsx => chart-renderer.test.tsx} (100%)
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/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/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.