+ {/* Step content (plain sections carry their own rhythm, see SinglePageForm) */}
+
{step.sections.map((section) => (
))}
@@ -350,6 +394,8 @@ function MultiStepForm({
interface TabbedStepFormProps extends SinglePageFormProps {
onReset: () => void;
+ activeStepId?: string;
+ onActiveStepChange?: (stepId: string) => void;
}
function TabbedStepForm({
@@ -357,68 +403,179 @@ function TabbedStepForm({
context,
customComponents,
disabled,
+ sectionVariant,
onReset,
+ activeStepId,
+ onActiveStepChange,
}: TabbedStepFormProps) {
const steps = schema.steps || [];
- // Hide steps that have no sections or whose conditions evaluate to false, so a
- // node that doesn't supply a given step (e.g. a trigger with no parameters)
- // never renders an empty tab.
- const visibleSteps = steps.filter(
- (step) =>
- step.sections.length > 0 && (!step.conditions || context.evaluateConditions(step.conditions))
- );
+ // Hide steps whose conditions evaluate to false, or that would render nothing:
+ // a step needs at least one *currently visible* section (sections hidden by
+ // their own `section.conditions` don't count, matching the render path and the
+ // error-count walk below) or an `emptyState` message. A step with an
+ // `emptyState` stays in the tab bar even while empty (e.g. an always-shown
+ // "Parameters" tab) and renders that message in place of its sections.
+ const visibleSteps = steps.filter((step) => {
+ if (step.conditions && !context.evaluateConditions(step.conditions)) return false;
+ const hasVisibleSection = step.sections.some(
+ (section) => !section.conditions || context.evaluateConditions(section.conditions)
+ );
+ return hasVisibleSection || step.emptyState !== undefined;
+ });
+
+ // Subscribe to validation state so a tab's error badge updates live as errors
+ // are set/cleared. This subscription is what re-renders the component (and
+ // feeds `getFieldState` below) when only the error state changes.
+ const formState = useFormState({ control: context.form.control });
+
+ // Per-step error count = number of validation issues on that step. A plain
+ // field contributes 1; a composite field whose error is an array of issues
+ // (e.g. a connector editor holding many sub-fields) contributes one per issue,
+ // so the badge reflects what the user sees inside it rather than always "1".
+ // Fields on inactive tabs are counted too (their errors persist in form state
+ // even while unmounted), so a badge surfaces a problem the user can't currently
+ // see. Conditionally-hidden sections AND individually-hidden fields are skipped
+ // (mirroring FormFieldRenderer) so a badge never points at something the form
+ // isn't showing and the user can't reach to fix.
+ //
+ // Recomputed every render on purpose: `formState` (the reactive dependency) and
+ // `visibleSteps` (a fresh array from `steps.filter`) both change per render, so
+ // memoizing would never hit; the walk over sections/fields is cheap.
+ const errorCountByStepId: Record
= {};
+ for (const step of visibleSteps) {
+ let count = 0;
+ for (const section of step.sections) {
+ if (section.conditions && !context.evaluateConditions(section.conditions)) {
+ continue;
+ }
+ for (const field of section.fields) {
+ if (field.rules && !RulesEngine.isFieldVisible(field.rules, context.values)) {
+ continue;
+ }
+ const fieldError: unknown = context.form.getFieldState(field.name, formState).error;
+ if (!fieldError) continue;
+ count += Array.isArray(fieldError) ? fieldError.filter(Boolean).length : 1;
+ }
+ }
+ errorCountByStepId[step.id] = count;
+ }
- // Clamp the active tab to a still-visible step so a step that disappears
- // (condition flips, manifest swap) can't strand the form on a dead tab.
- const [activeTab, setActiveTab] = useState('');
- const currentTab = visibleSteps.some((step) => step.id === activeTab)
- ? activeTab
+ // Active tab: controlled by the caller when `activeStepId` is provided (so it
+ // can persist across remounts — e.g. keep the user on the same tab when
+ // switching nodes), otherwise internal state. Either way it's clamped to a
+ // still-visible step, so a step that disappears (condition flips, manifest
+ // swap, or a node-specific tab absent on the next node) falls back to the
+ // first tab instead of stranding the form on a dead tab.
+ const [internalTab, setInternalTab] = useState('');
+ const controlled = activeStepId !== undefined;
+ const requestedTab = controlled ? activeStepId : internalTab;
+ const currentTab = visibleSteps.some((step) => step.id === requestedTab)
+ ? requestedTab
: (visibleSteps[0]?.id ?? '');
+ const selectTab = (stepId: string) => {
+ if (!controlled) setInternalTab(stepId);
+ onActiveStepChange?.(stepId);
+ };
- // Persist the clamp into state: once a selected step disappears we settle on the
- // fallback, so a later reappearance doesn't snap the user back to the old tab.
+ // Uncontrolled only: settle internal state on the clamped value so a step that
+ // disappeared and later reappears doesn't snap the user back to it. In
+ // controlled mode the caller owns the value and we never overwrite it, so a
+ // node-specific tab stays remembered for nodes that do have it.
useEffect(() => {
- if (activeTab !== currentTab) setActiveTab(currentTab);
- }, [activeTab, currentTab]);
+ if (!controlled && internalTab !== currentTab) setInternalTab(currentTab);
+ }, [controlled, internalTab, currentTab]);
if (visibleSteps.length === 0) return null;
return (
<>
-
- {/* Segmented pill tabs (properties-panel style). Horizontal scroll when
- tabs overflow a narrow panel; scrollbar is hidden so tabs stay on one
- line without clipping. */}
-
- {visibleSteps.map((step) => (
-
+ {/* Pinned tab bar: shrink-0 keeps it under the panel header while the
+ active tab's content scrolls below. The wrapper supplies the
+ horizontal inset (matching the content) so the tab strip lines up
+ with the fields; px-0 on the list keeps the first pill flush to it.
+ Segmented pill tabs (properties-panel style): in a narrow panel
+ ScrollableTabsList reveals prev/next chevrons and auto-scrolls the
+ active tab into view; in a wide panel it reads as a plain strip. */}
+
+
+ {visibleSteps.map((step) => {
+ const errorCount = errorCountByStepId[step.id] ?? 0;
+ return (
+
+ {step.title}
+ {errorCount > 0 && (
+
+ {errorCount}
+
+ )}
+
+ );
+ })}
+
+
+
+ {visibleSteps.map((step) => {
+ const stepSections = step.sections.filter(
+ (section) => !section.conditions || context.evaluateConditions(section.conditions)
+ );
+ return (
+ // The active tab is the scroll container so the tab bar above stays
+ // pinned and the scrollbar sits at the panel edge; the inner wrapper
+ // carries the content inset + bottom padding. Plain drops space-y-2
+ // (sections self-space) and gets its leading whitespace from the first
+ // section's own py-4/pt-4; card boxes have none, so the wrapper adds a
+ // matching pt-4 to keep the first card off the tab strip by the same
+ // amount. A step with no visible sections renders its `emptyState`.
+
- {step.title}
-
- ))}
-
-
- {visibleSteps.map((step) => (
-
- {step.sections
- .filter(
- (section) => !section.conditions || context.evaluateConditions(section.conditions)
- )
- .map((section) => (
-
- ))}
-
- ))}
+ {stepSections.length === 0 && step.emptyState !== undefined ? (
+
+ {step.emptyState}
+
+ ) : (
+
+ {stepSections.map((section) => (
+
+ ))}
+
+ )}
+
+ );
+ })}
>
@@ -430,12 +587,42 @@ interface FormSectionProps {
context: FormContext;
customComponents: Record>;
disabled?: boolean;
+ sectionVariant?: 'card' | 'plain';
}
-function FormSection({ section, context, customComponents, disabled }: FormSectionProps) {
+function FormSection({
+ section,
+ context,
+ customComponents,
+ disabled,
+ sectionVariant = 'card',
+}: FormSectionProps) {
const gridColumns = context.schema.layout?.columns || 1;
const gap = context.schema.layout?.gap || 4;
+ // `'card'` frames each section in a bordered, rounded, horizontally-padded box;
+ // `'plain'` drops all of that — plus the accordion's default bottom border — and
+ // instead separates stacked sections with a full-width hairline divider above
+ // every section except the first, so groups stay distinguishable without boxes
+ // (the host provides the frame + horizontal inset). Sections carry symmetric
+ // vertical padding (py-4 header / pb-4 content) so the inter-section rhythm
+ // (16px + divider + 16px) reads clearly against the tighter field gap; the
+ // parent list renders plain sections with no extra gap of its own.
+ const isPlain = sectionVariant === 'plain';
+ // Lives on each section's OUTERMOST element (the Accordion root for
+ // collapsible sections, not the AccordionItem — an item is always its
+ // Accordion's first child) so `first:` resolves against the sibling list.
+ const dividerClassName = 'border-t first:border-t-0';
+ const wrapperClassName = isPlain ? 'border-b-0' : 'border rounded-lg px-3';
+ // Plain: the chevron sits immediately right of the title (packed left with a
+ // small gap) rather than pushed to the far edge, and the header no longer
+ // underlines on hover — it reads as a section label, not a link. Semibold
+ // (vs the field labels' medium) keeps the header distinguishable from its own
+ // fields even when the collapse chevron is absent.
+ const triggerClassName = isPlain
+ ? 'justify-start gap-2 py-4 text-sm font-semibold hover:no-underline'
+ : 'text-sm font-medium';
+
const fieldsGrid = (
);
- // If no title, render fields directly without wrapper
+ // Titleless section: render fields directly, with no header (the sole-section
+ // case where the host drops the title/collapse chrome). Card keeps its
+ // long-standing behavior — bare fields, no extra padding — so existing
+ // single-page/wizard layouts (e.g. contactFormSchema) are unchanged. Plain
+ // adds the header-aligned pt-4/pb-4 and the between-sections divider so a
+ // titleless section lines up with where a section header would sit. Any
+ // tab-specific top inset for the card variant is supplied by the
+ // TabbedStepForm content wrapper's pt-4, not here, so it can't leak into
+ // non-tabbed layouts.
if (!section.title) {
- return fieldsGrid;
+ return isPlain ? (
+
{fieldsGrid}
+ ) : (
+ fieldsGrid
+ );
}
// Shared inner content (without padding - wrapper handles that)
@@ -476,22 +675,33 @@ function FormSection({ section, context, customComponents, disabled }: FormSecti
const defaultValue = section.defaultExpanded !== false ? [section.id] : [];
return (
-
-
- {section.title}
- {/* AccordionContent adds pb-4 pt-0 wrapper internally */}
+
+
+ {section.title}
+ {/* AccordionContent adds pb-4 pt-0 internally, which both variants keep. */}
{innerContent}
);
}
- // Non-collapsible section - match AccordionItem/Trigger/Content structure exactly
+ // Non-collapsible section - match Accordion structure exactly, including the
+ // plain variant's divider and left-packed semibold header.
return (
-
+
{/* Match AccordionPrimitive.Header + Trigger structure */}