From 8ecf044d61b27c9d06f86544a6d64d5c486714fc Mon Sep 17 00:00:00 2001 From: Cristian Calina Date: Thu, 23 Jul 2026 00:23:39 -0700 Subject: [PATCH 1/3] feat(apollo-wind): scrollable tabs, per-tab error badges, plain sections in MetadataForm Adapts the tabbed MetadataForm (properties-panel form) for narrow panels, surfaces validation state on the tab strip, and adds a borderless section treatment for hosts that frame the panel themselves. apollo-wind (metadata-form): - Scrollable tab strip: TabbedStepForm uses ScrollableTabsList, revealing prev/next chevrons and auto-scrolling the active tab into view when tabs overflow a narrow panel. A wide panel reads identically to a plain strip. - Per-tab error badges: each tab shows a live count of its errored fields (via useFormState), including fields on inactive tabs. Composite fields whose error is an array contribute one per issue. Conditionally-hidden sections AND individually-hidden fields are skipped so a badge never points at something the user cannot reach. The badge is exposed to assistive tech as "N issue(s)". - sectionVariant 'card' | 'plain': 'plain' drops the border, rounding, and horizontal padding so sections read as flush semibold headers over their content, separated by a full-width hairline divider between consecutive sections, for hosts (e.g. the canvas properties panel) that already frame the form. Applies to collapsible, non-collapsible, and titleless sections. - Adds a SectionVariants story with a live card/plain toggle, covering titled collapsible sections and sole untitled sections (the consumer's two shapes). apollo-react: - NodePropertyPanel forwards a sectionVariant prop through to MetadataForm. Tests: pnpm --filter @uipath/apollo-wind test, 987 pass (incl. inactive-tab badge and composite-array issue count). biome and tsc clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../NodePropertyPanel/NodePropertyPanel.tsx | 2 + .../NodePropertyPanel.types.ts | 7 + .../forms/metadata-form.stories.tsx | 129 +++++++++++ .../components/forms/metadata-form.test.tsx | 50 +++- .../src/components/forms/metadata-form.tsx | 216 +++++++++++++++--- 5 files changed, 367 insertions(+), 37 deletions(-) diff --git a/packages/apollo-react/src/canvas/components/NodePropertyPanel/NodePropertyPanel.tsx b/packages/apollo-react/src/canvas/components/NodePropertyPanel/NodePropertyPanel.tsx index c5cfd94b9..db8d41de4 100644 --- a/packages/apollo-react/src/canvas/components/NodePropertyPanel/NodePropertyPanel.tsx +++ b/packages/apollo-react/src/canvas/components/NodePropertyPanel/NodePropertyPanel.tsx @@ -50,6 +50,7 @@ export function NodePropertyPanel({ contentInset = '1.5rem', children, headerExtra, + sectionVariant, }: NodePropertyPanelProps) { const hasNodeHeader = !!(nodeLabel || nodeCategory || nodeIcon || action); @@ -137,6 +138,7 @@ export function NodePropertyPanel({ schema={formSchema} plugins={plugins} stepVariant="tabs" + sectionVariant={sectionVariant} onSubmit={onSubmit} disabled={disabled} className="flex flex-col gap-4 pb-6 pt-3 [padding-inline:var(--mf-content-inset)]" diff --git a/packages/apollo-react/src/canvas/components/NodePropertyPanel/NodePropertyPanel.types.ts b/packages/apollo-react/src/canvas/components/NodePropertyPanel/NodePropertyPanel.types.ts index 22d0a2689..880407eed 100644 --- a/packages/apollo-react/src/canvas/components/NodePropertyPanel/NodePropertyPanel.types.ts +++ b/packages/apollo-react/src/canvas/components/NodePropertyPanel/NodePropertyPanel.types.ts @@ -29,6 +29,13 @@ export interface NodePropertyPanelProps { * verbatim to the underlying single form instance. */ plugins?: FormPlugin[]; + /** + * Visual treatment for each form section, forwarded to `MetadataForm`. + * `'card'` (default) frames every section in a bordered box; `'plain'` drops + * the border/rounding/horizontal padding so sections read as flush headers — + * for hosts that already frame the panel and want a borderless list. + */ + sectionVariant?: 'card' | 'plain'; /** Called when the form is submitted (only when the schema defines a submit action). */ onSubmit?: (data: unknown) => void | Promise; /** Disables all fields (e.g. read-only nodes). */ diff --git a/packages/apollo-wind/src/components/forms/metadata-form.stories.tsx b/packages/apollo-wind/src/components/forms/metadata-form.stories.tsx index a98392aa6..667ebeade 100644 --- a/packages/apollo-wind/src/components/forms/metadata-form.stories.tsx +++ b/packages/apollo-wind/src/components/forms/metadata-form.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; +import { useState } from 'react'; import { Controller, type FieldValues, @@ -877,6 +878,134 @@ export const TabbedSteps: Story = { }, }; +// A tabbed node schema exercising both section shapes the properties panel +// produces: Parameters carries two titled, collapsible sections (where the two +// sectionVariants differ visibly), while Error handling and Advanced each hold a +// single untitled, non-collapsible section — mirroring the consumer rule that a +// tab's lone section drops the collapse affordance and title because the tab +// already labels it. `card` frames each section in its own bordered box; `plain` +// renders flush headers separated by hairline dividers (the host frames the +// panel). +const sectionVariantSchema: FormSchema = { + id: 'section-variant-demo', + title: 'Node properties', + actions: [], + initialData: { + 'inputs.method': 'GET', + 'inputs.url': 'https://api.example.com/v1/orders', + 'inputs.timeout': 30, + 'inputs.errorHandlingEnabled': false, + nodeId: 'httpRequest1', + 'display.label': 'HTTP request', + }, + steps: [ + { + id: 'parameters', + title: 'Parameters', + sections: [ + { + id: 'connection', + title: 'Connection', + collapsible: true, + defaultExpanded: true, + fields: [ + { + name: 'inputs.method', + type: 'select', + label: 'Method', + options: [ + { label: 'GET', value: 'GET' }, + { label: 'POST', value: 'POST' }, + { label: 'PUT', value: 'PUT' }, + ], + }, + { name: 'inputs.url', type: 'text', label: 'URL' }, + ], + }, + { + id: 'options', + title: 'Options', + collapsible: true, + defaultExpanded: true, + fields: [{ name: 'inputs.timeout', type: 'number', label: 'Timeout (s)' }], + }, + ], + }, + { + id: 'error-handling', + title: 'Error handling', + sections: [ + // Sole section in its tab: no title, no collapse — the tab labels it. + { + id: 'error', + fields: [ + { + name: 'inputs.errorHandlingEnabled', + type: 'switch', + label: 'Enable error handling', + description: 'Add an error output handle on the node to catch and handle failures.', + }, + ], + }, + ], + }, + { + id: 'advanced', + title: 'Advanced', + sections: [ + // Sole section in its tab: no title, no collapse — the tab labels it. + { + id: 'general', + fields: [ + { name: 'nodeId', type: 'text', label: 'ID', disabled: true }, + { name: 'display.label', type: 'text', label: 'Label' }, + { name: 'display.description', type: 'textarea', label: 'Description' }, + ], + }, + ], + }, + ], +}; + +/** + * Section variants (card vs plain) + * + * Flip the toggle to compare the two `sectionVariant` treatments live. `card` + * (default) wraps each section in its own bordered, rounded box. `plain` drops + * the border, rounding, and horizontal padding so sections read as flush headers + * over their content. `plain` assumes the host already frames the panel, so the + * form is rendered inside a narrow panel-like container here to match that use + * case (the canvas node properties panel). + */ +export const SectionVariants: Story = { + render: (args) => { + const [plain, setPlain] = useState(true); + return ( + // Canvas backdrop + panel frame matching the NodePropertyPanel stories: + // the canvas sits on `--surface` and the docked panel is a raised, + // subtly-bordered card (bg-surface-raised) floating over it. The variant + // toggle floats on the canvas, centered above the panel it controls. +
+
+ + +
+
+ +
+
+ ); + }, +}; + // ============================================================================ // FILE UPLOAD // ============================================================================ diff --git a/packages/apollo-wind/src/components/forms/metadata-form.test.tsx b/packages/apollo-wind/src/components/forms/metadata-form.test.tsx index b5c3c6f78..7880e5299 100644 --- a/packages/apollo-wind/src/components/forms/metadata-form.test.tsx +++ b/packages/apollo-wind/src/components/forms/metadata-form.test.tsx @@ -1,9 +1,9 @@ -import { render, screen, waitFor } from '@testing-library/react'; +import { render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { axe } from 'jest-axe'; import { describe, expect, it, vi } from 'vitest'; import { MetadataForm } from './metadata-form'; -import type { FormSchema } from './form-schema'; +import type { FormPlugin, FormSchema } from './form-schema'; // Basic form schema for tests const basicSchema: FormSchema = { @@ -418,5 +418,51 @@ describe('MetadataForm', () => { expect(screen.queryByRole('tab')).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Submit' })).not.toBeInTheDocument(); }); + + it("badges a tab with the count of its errored fields, including a tab that isn't active", async () => { + // Seed an error on `field2`, which lives on the (inactive) Advanced tab. + const seedError: FormPlugin = { + name: 'seed-error', + version: '1.0.0', + onFormInit: (context) => { + context.form.setError('field2', { message: 'Bad value' }); + }, + }; + + render(); + + // The Advanced tab (not active) shows a "1" badge for its single errored field. + const advancedTab = await screen.findByRole('tab', { name: /Advanced/ }); + await waitFor(() => expect(within(advancedTab).getByText('1')).toBeInTheDocument()); + + // The Parameters tab has no errored field, so it stays badge-free. + expect(screen.getByRole('tab', { name: 'Parameters' })).toBeInTheDocument(); + expect(within(screen.getByRole('tab', { name: 'Parameters' })).queryByText('1')).toBeNull(); + }); + + it('counts each issue of a composite field whose error is an array (not just the field)', async () => { + // A composite field (e.g. a connector editor) surfaces several issues at once, + // stored as an array of errors under one field name. The badge should reflect + // the issue count, not collapse the whole field to a single "1". + const seedArrayError: FormPlugin = { + name: 'seed-array-error', + version: '1.0.0', + onFormInit: (context) => { + context.form.setError('field1.0', { message: 'Channel is required' }); + context.form.setError('field1.1', { + message: 'Timestamp is required', + }); + context.form.setError('field1.2', { message: 'Message is required' }); + }, + }; + + render(); + + // `field1` lives on Parameters; its three issues make the badge read "3". + const parametersTab = await screen.findByRole('tab', { + name: /Parameters/, + }); + await waitFor(() => expect(within(parametersTab).getByText('3')).toBeInTheDocument()); + }); }); }); diff --git a/packages/apollo-wind/src/components/forms/metadata-form.tsx b/packages/apollo-wind/src/components/forms/metadata-form.tsx index 7f904674d..dd051a1f8 100644 --- a/packages/apollo-wind/src/components/forms/metadata-form.tsx +++ b/packages/apollo-wind/src/components/forms/metadata-form.tsx @@ -1,6 +1,6 @@ import { standardSchemaResolver } from '@hookform/resolvers/standard-schema'; import React, { useEffect, useMemo, useRef, useState } from 'react'; -import { type FieldValues, FormProvider, useForm } from 'react-hook-form'; +import { type FieldValues, FormProvider, useForm, useFormState } from 'react-hook-form'; import { z } from 'zod/v4'; import { Accordion, @@ -9,7 +9,7 @@ import { AccordionTrigger, } from '@/components/ui/accordion'; import { Button } from '@/components/ui/button'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { ScrollableTabsList, Tabs, TabsContent, TabsTrigger } from '@/components/ui/tabs'; import { DataFetcher } from './data-fetcher'; import { FormFieldRenderer } from './field-renderer'; @@ -46,6 +46,17 @@ interface MetadataFormProps { * across every step. Ignored for single-page (`sections`) schemas. */ stepVariant?: 'wizard' | 'tabs'; + /** + * Visual treatment for each titled section. `'card'` (default) wraps every + * section in a bordered, rounded, horizontally-padded box. `'plain'` drops the + * border, rounding, and horizontal padding so sections read as flush headers + * over their content, separated by a full-width hairline divider between + * consecutive sections — used by hosts (e.g. the canvas properties panel) that + * already frame the form and want a borderless list. Applies to both + * collapsible (accordion) and non-collapsible sections across every layout + * (single-page, wizard, tabs). + */ + sectionVariant?: 'card' | 'plain'; } // Stable default to prevent re-renders @@ -59,6 +70,7 @@ export function MetadataForm({ disabled = false, autoComplete, stepVariant = 'wizard', + sectionVariant = 'card', }: MetadataFormProps) { const [currentStep, setCurrentStep] = useState(0); const [customComponents, setCustomComponents] = useState< @@ -196,6 +208,7 @@ export function MetadataForm({ context={context} customComponents={customComponents} disabled={disabled} + sectionVariant={sectionVariant} onReset={() => reset()} /> ); @@ -208,6 +221,7 @@ export function MetadataForm({ setCurrentStep={setCurrentStep} customComponents={customComponents} disabled={disabled} + sectionVariant={sectionVariant} /> ); } @@ -218,6 +232,7 @@ export function MetadataForm({ context={context} customComponents={customComponents} disabled={disabled} + sectionVariant={sectionVariant} /> ); }; @@ -245,9 +260,16 @@ interface SinglePageFormProps { context: FormContext; customComponents: Record>; disabled?: boolean; + sectionVariant?: 'card' | 'plain'; } -function SinglePageForm({ schema, context, customComponents, disabled }: SinglePageFormProps) { +function SinglePageForm({ + schema, + context, + customComponents, + disabled, + sectionVariant, +}: SinglePageFormProps) { const sections = schema.sections || []; // Filter visible sections @@ -256,7 +278,9 @@ function SinglePageForm({ schema, context, customComponents, disabled }: SingleP ); return ( -
+ // Plain sections carry their own vertical rhythm (py-3 + divider), so the + // list adds no extra gap; card sections are spaced apart as separate boxes. +
{visibleSections.map((section) => ( ))}
@@ -282,6 +307,7 @@ function MultiStepForm({ setCurrentStep, customComponents, disabled, + sectionVariant, }: MultiStepFormProps) { const steps = schema.steps || []; const step = steps[currentStep]; @@ -310,8 +336,8 @@ function MultiStepForm({
- {/* Step content */} -
+ {/* Step content (plain sections carry their own rhythm, see SinglePageForm) */} +
{step.sections.map((section) => ( ))}
@@ -357,6 +384,7 @@ function TabbedStepForm({ context, customComponents, disabled, + sectionVariant, onReset, }: TabbedStepFormProps) { const steps = schema.steps || []; @@ -369,6 +397,43 @@ function TabbedStepForm({ step.sections.length > 0 && (!step.conditions || context.evaluateConditions(step.conditions)) ); + // Subscribe to validation state so a tab's error badge updates live as errors + // are set/cleared. Without this subscription the getters on `context.errors` + // don't re-render this component 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(''); @@ -386,24 +451,52 @@ function TabbedStepForm({ 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) => ( - - {step.title} - - ))} - + + {/* Segmented pill tabs (properties-panel style). When the tabs overflow a + narrow panel, ScrollableTabsList reveals prev/next chevrons and auto- + scrolls the active tab into view; in a wide panel it renders no chevrons + and reads identically to a plain tab strip. */} + {/* px-0 cancels the list's built-in p-1 horizontal padding so the first + tab's pill edge sits flush on the host content inset; py-0.5 keeps a + little vertical breathing room. The label stays inset inside the pill, + as segmented pill tabs do. */} + + {visibleSteps.map((step) => { + const errorCount = errorCountByStepId[step.id] ?? 0; + return ( + + {step.title} + {errorCount > 0 && ( + + {errorCount} + + )} + + ); + })} + {visibleSteps.map((step) => ( - + // Plain: cancel TabsContent's built-in mt-2 — the first section's own + // pt-3 already provides the tab-strip inset, and stacking both makes + // the tab→content gap as large as the full section-to-section rhythm. + {step.sections .filter( (section) => !section.conditions || context.evaluateConditions(section.conditions) @@ -415,6 +508,7 @@ function TabbedStepForm({ context={context} customComponents={customComponents} disabled={disabled} + sectionVariant={sectionVariant} /> ))} @@ -430,12 +524,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-3 header / pb-3 content) so the inter-section rhythm + // (12px + divider + 12px) 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-3 text-sm font-semibold hover:no-underline' + : 'text-sm font-medium'; + const fieldsGrid = (
); - // If no title, render fields directly without wrapper + // If no title, render fields directly without a header. In the plain variant + // (properties panel) a titleless section is typically a tab's sole section, + // where the host drops the title/collapse chrome. Add a top inset equal to the + // accordion trigger's py-3 so the first field lines up with where a section + // header would sit — otherwise a single-section tab hugs the tab strip tighter + // than a multi-section tab whose leading header carries that padding. if (!section.title) { - return fieldsGrid; + return isPlain ? ( +
{fieldsGrid}
+ ) : ( + fieldsGrid + ); } // Shared inner content (without padding - wrapper handles that) @@ -476,28 +609,41 @@ 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 */} - {innerContent} + + + {section.title} + {/* AccordionContent adds pb-4 pt-0 internally; plain tightens it to pb-3. */} + + {innerContent} + ); } - // Non-collapsible section - match AccordionItem/Trigger/Content structure exactly + // Non-collapsible section - match Accordion structure exactly, including the + // plain variant's divider + tightened header/content padding. return ( -
+
{/* Match AccordionPrimitive.Header + Trigger structure */}
-
+
{section.title}
- {/* Match AccordionContent: outer has text-sm, inner has pb-4 pt-0 */} + {/* Match AccordionContent: outer has text-sm, inner has pb-4 pt-0 (pb-3 in plain) */}
-
{innerContent}
+
{innerContent}
); From cbed1b216bd38a655997c62299d831ff8d5ece07 Mon Sep 17 00:00:00 2001 From: Cristian Calina Date: Thu, 23 Jul 2026 02:31:42 -0700 Subject: [PATCH 2/3] feat(apollo-wind): pin tab bar under header, scroll only active tab content MetadataForm's TabbedStepForm now owns the scroll: the tab strip is shrink-0 and pinned beneath NodePropertyPanel's header, while the active TabsContent is the scroll container (scrollbar at the panel edge). NodePropertyPanel makes the form wrapper a height-filling flex column for tabbed schemas; single-page schemas keep the classic overflow-auto wrapper. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../NodePropertyPanel/NodePropertyPanel.tsx | 22 +++ .../src/components/forms/metadata-form.tsx | 140 ++++++++++-------- 2 files changed, 97 insertions(+), 65 deletions(-) diff --git a/packages/apollo-react/src/canvas/components/NodePropertyPanel/NodePropertyPanel.tsx b/packages/apollo-react/src/canvas/components/NodePropertyPanel/NodePropertyPanel.tsx index db8d41de4..eb6dd13c7 100644 --- a/packages/apollo-react/src/canvas/components/NodePropertyPanel/NodePropertyPanel.tsx +++ b/packages/apollo-react/src/canvas/components/NodePropertyPanel/NodePropertyPanel.tsx @@ -130,7 +130,29 @@ export function NodePropertyPanel({
No form schema defined for this node.
+ ) : formSchema.steps ? ( + // Tabbed schema: MetadataForm owns the scroll so its tab bar pins under + // the header. This wrapper is a height-filling flex column, NOT the + // scroll container — the form's inset/padding move inside TabbedStepForm. +
+
+ +
+
) : ( + // Single-page schema: classic behavior — this wrapper scrolls the whole form.
- - {/* Segmented pill tabs (properties-panel style). When the tabs overflow a - narrow panel, ScrollableTabsList reveals prev/next chevrons and auto- - scrolls the active tab into view; in a wide panel it renders no chevrons - and reads identically to a plain tab strip. */} - {/* px-0 cancels the list's built-in p-1 horizontal padding so the first - tab's pill edge sits flush on the host content inset; py-0.5 keeps a - little vertical breathing room. The label stays inset inside the pill, - as segmented pill tabs do. */} - - {visibleSteps.map((step) => { - const errorCount = errorCountByStepId[step.id] ?? 0; - return ( - - {step.title} - {errorCount > 0 && ( - - {errorCount} - - )} - - ); - })} - + + {/* 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) => ( - // Plain: cancel TabsContent's built-in mt-2 — the first section's own - // pt-3 already provides the tab-strip inset, and stacking both makes - // the tab→content gap as large as the full section-to-section rhythm. - - {step.sections - .filter( - (section) => !section.conditions || context.evaluateConditions(section.conditions) - ) - .map((section) => ( - - ))} + // 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); card keeps the 2-unit rhythm between boxes. + +
+ {step.sections + .filter( + (section) => !section.conditions || context.evaluateConditions(section.conditions) + ) + .map((section) => ( + + ))} +
))}
@@ -542,8 +552,8 @@ function FormSection({ // 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-3 header / pb-3 content) so the inter-section rhythm - // (12px + divider + 12px) reads clearly against the tighter field gap; the + // 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 @@ -557,7 +567,7 @@ function FormSection({ // (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-3 text-sm font-semibold hover:no-underline' + ? 'justify-start gap-2 py-4 text-sm font-semibold hover:no-underline' : 'text-sm font-medium'; const fieldsGrid = ( @@ -588,7 +598,7 @@ function FormSection({ // than a multi-section tab whose leading header carries that padding. if (!section.title) { return isPlain ? ( -
{fieldsGrid}
+
{fieldsGrid}
) : ( fieldsGrid ); @@ -617,7 +627,7 @@ function FormSection({ {section.title} {/* AccordionContent adds pb-4 pt-0 internally; plain tightens it to pb-3. */} - + {innerContent} @@ -634,7 +644,7 @@ function FormSection({
@@ -643,7 +653,7 @@ function FormSection({
{/* Match AccordionContent: outer has text-sm, inner has pb-4 pt-0 (pb-3 in plain) */}
-
{innerContent}
+
{innerContent}
); From 7e76888d93f1e78963494de12fb45d6ce915dccd Mon Sep 17 00:00:00 2001 From: Cristian Calina Date: Thu, 23 Jul 2026 04:27:21 -0700 Subject: [PATCH 3/3] feat(apollo-wind): always-shown tabs with empty-state + controlled active tab - FormStep.emptyState: a tabbed step with no visible sections stays in the tab bar and renders the message instead of being hidden (e.g. an always-present Parameters tab reading "No available parameters"). - MetadataForm/TabbedStepForm: controlled activeStepId + onActiveStepChange so a host can persist the selected tab across remounts (keep the same tab when switching nodes); clamps to the first tab when the id isn't present. - NodePropertyPanel threads both props through to MetadataForm. - Plain-section divider: bump vertical spacing 12px -> 16px. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../NodePropertyPanel/NodePropertyPanel.tsx | 8 +- .../NodePropertyPanel.types.ts | 11 ++ .../src/components/forms/form-schema.ts | 8 + .../forms/metadata-form.stories.tsx | 6 +- .../components/forms/metadata-form.test.tsx | 134 ++++++++++++- .../src/components/forms/metadata-form.tsx | 178 ++++++++++++------ 6 files changed, 277 insertions(+), 68 deletions(-) diff --git a/packages/apollo-react/src/canvas/components/NodePropertyPanel/NodePropertyPanel.tsx b/packages/apollo-react/src/canvas/components/NodePropertyPanel/NodePropertyPanel.tsx index eb6dd13c7..7999d80da 100644 --- a/packages/apollo-react/src/canvas/components/NodePropertyPanel/NodePropertyPanel.tsx +++ b/packages/apollo-react/src/canvas/components/NodePropertyPanel/NodePropertyPanel.tsx @@ -51,6 +51,8 @@ export function NodePropertyPanel({ children, headerExtra, sectionVariant, + activeStepId, + onActiveStepChange, }: NodePropertyPanelProps) { const hasNodeHeader = !!(nodeLabel || nodeCategory || nodeIcon || action); @@ -145,6 +147,8 @@ export function NodePropertyPanel({ plugins={plugins} stepVariant="tabs" sectionVariant={sectionVariant} + activeStepId={activeStepId} + onActiveStepChange={onActiveStepChange} onSubmit={onSubmit} disabled={disabled} className="flex min-h-0 flex-1 flex-col" @@ -152,14 +156,14 @@ export function NodePropertyPanel({
) : ( - // Single-page schema: classic behavior — this wrapper scrolls the whole form. + // Single-page schema: classic behavior — this wrapper scrolls the whole + // form. No stepVariant: it only applies to step-based schemas.
void; /** Called when the form is submitted (only when the schema defines a submit action). */ onSubmit?: (data: unknown) => void | Promise; /** Disables all fields (e.g. read-only nodes). */ diff --git a/packages/apollo-wind/src/components/forms/form-schema.ts b/packages/apollo-wind/src/components/forms/form-schema.ts index 882a80206..c8ae7f500 100644 --- a/packages/apollo-wind/src/components/forms/form-schema.ts +++ b/packages/apollo-wind/src/components/forms/form-schema.ts @@ -339,6 +339,14 @@ export interface FormStep { validation?: 'onChange' | 'onBlur' | 'onSubmit'; canSkip?: boolean; conditions?: FieldCondition[]; // Show/hide entire step + /** + * Message shown (in the `tabs` step variant) when this step has no visible + * sections. A step that sets this stays in the tab bar even while empty and + * renders the message in place of its content — e.g. a "Parameters" tab that + * always shows, reading "No available parameters" for a node with no inputs. + * Steps without both sections and `emptyState` are hidden from the tab bar. + */ + emptyState?: string; } export interface FormAction { diff --git a/packages/apollo-wind/src/components/forms/metadata-form.stories.tsx b/packages/apollo-wind/src/components/forms/metadata-form.stories.tsx index 667ebeade..426eb594b 100644 --- a/packages/apollo-wind/src/components/forms/metadata-form.stories.tsx +++ b/packages/apollo-wind/src/components/forms/metadata-form.stories.tsx @@ -881,7 +881,7 @@ export const TabbedSteps: Story = { // A tabbed node schema exercising both section shapes the properties panel // produces: Parameters carries two titled, collapsible sections (where the two // sectionVariants differ visibly), while Error handling and Advanced each hold a -// single untitled, non-collapsible section — mirroring the consumer rule that a +// single untitled, non-collapsible section. That mirrors the consumer rule that a // tab's lone section drops the collapse affordance and title because the tab // already labels it. `card` frames each section in its own bordered box; `plain` // renders flush headers separated by hairline dividers (the host frames the @@ -935,7 +935,7 @@ const sectionVariantSchema: FormSchema = { id: 'error-handling', title: 'Error handling', sections: [ - // Sole section in its tab: no title, no collapse — the tab labels it. + // Sole section in its tab: no title, no collapse. The tab labels it. { id: 'error', fields: [ @@ -953,7 +953,7 @@ const sectionVariantSchema: FormSchema = { id: 'advanced', title: 'Advanced', sections: [ - // Sole section in its tab: no title, no collapse — the tab labels it. + // Sole section in its tab: no title, no collapse. The tab labels it. { id: 'general', fields: [ diff --git a/packages/apollo-wind/src/components/forms/metadata-form.test.tsx b/packages/apollo-wind/src/components/forms/metadata-form.test.tsx index 7880e5299..e829cd48d 100644 --- a/packages/apollo-wind/src/components/forms/metadata-form.test.tsx +++ b/packages/apollo-wind/src/components/forms/metadata-form.test.tsx @@ -2,8 +2,8 @@ import { render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { axe } from 'jest-axe'; import { describe, expect, it, vi } from 'vitest'; -import { MetadataForm } from './metadata-form'; import type { FormPlugin, FormSchema } from './form-schema'; +import { MetadataForm } from './metadata-form'; // Basic form schema for tests const basicSchema: FormSchema = { @@ -464,5 +464,137 @@ describe('MetadataForm', () => { }); await waitFor(() => expect(within(parametersTab).getByText('3')).toBeInTheDocument()); }); + + it('controlled: an activeStepId not among the visible steps clamps to the first tab without firing onActiveStepChange', async () => { + const onActiveStepChange = vi.fn(); + render( + + ); + + // Clamped to the first visible tab rather than stranded on a dead id. + const parametersTab = await screen.findByRole('tab', { name: 'Parameters' }); + await waitFor(() => expect(parametersTab).toHaveAttribute('data-state', 'active')); + // Clamping is internal: it must not fire the change callback (nor mutate the caller's value). + expect(onActiveStepChange).not.toHaveBeenCalled(); + }); + + it('controlled: selecting a tab fires onActiveStepChange but leaves the displayed tab to the caller', async () => { + const user = userEvent.setup(); + const onActiveStepChange = vi.fn(); + render( + + ); + + await user.click(screen.getByRole('tab', { name: 'Advanced' })); + + // Callback fires with the selection... + expect(onActiveStepChange).toHaveBeenCalledWith('advanced'); + // ...but the form stays on the caller-controlled tab until the caller updates activeStepId. + await waitFor(() => + expect(screen.getByRole('tab', { name: 'Parameters' })).toHaveAttribute( + 'data-state', + 'active' + ) + ); + }); + + it('uncontrolled: selecting a tab both fires onActiveStepChange and moves to that tab', async () => { + const user = userEvent.setup(); + const onActiveStepChange = vi.fn(); + render( + + ); + + await user.click(screen.getByRole('tab', { name: 'Advanced' })); + + expect(onActiveStepChange).toHaveBeenCalledWith('advanced'); + await waitFor(() => + expect(screen.getByRole('tab', { name: 'Advanced' })).toHaveAttribute( + 'data-state', + 'active' + ) + ); + }); + + it('keeps an otherwise-empty step in the tab bar when it defines an emptyState, and renders that message', async () => { + const withEmptyState: FormSchema = { + id: 'with-empty-state', + title: 'With empty state', + actions: [], + steps: [ + { + id: 'parameters', + title: 'Parameters', + sections: [{ id: 'p', fields: [{ name: 'field1', type: 'text', label: 'Field 1' }] }], + }, + { + id: 'advanced', + title: 'Advanced', + emptyState: 'No advanced settings for this node.', + sections: [], + }, + ], + }; + + const user = userEvent.setup(); + render(); + + // The empty step still gets a tab (unlike a section-less, message-less step, which is omitted). + const advancedTab = await screen.findByRole('tab', { name: 'Advanced' }); + await user.click(advancedTab); + + // Its emptyState message renders in place of sections. + await waitFor(() => + expect(screen.getByText('No advanced settings for this node.')).toBeInTheDocument() + ); + }); + + it('omits a step whose sections are all hidden by section conditions and has no emptyState (no blank tab)', async () => { + const schema: FormSchema = { + id: 'hidden-sections', + title: 'Hidden sections', + actions: [], + steps: [ + { + id: 'parameters', + title: 'Parameters', + sections: [{ id: 'p', fields: [{ name: 'field1', type: 'text', label: 'Field 1' }] }], + }, + { + id: 'conditional', + title: 'Conditional', + sections: [ + { + id: 'c', + // Hidden until field1 === 'show-me'; field1 is empty here. + conditions: [{ when: 'field1', is: 'show-me' }], + fields: [{ name: 'field2', type: 'text', label: 'Field 2' }], + }, + ], + }, + ], + }; + + render(); + + // The Conditional step's only section is hidden and it has no emptyState, + // so it must not surface a blank tab. + expect(await screen.findByRole('tab', { name: 'Parameters' })).toBeInTheDocument(); + expect(screen.queryByRole('tab', { name: 'Conditional' })).not.toBeInTheDocument(); + }); }); }); diff --git a/packages/apollo-wind/src/components/forms/metadata-form.tsx b/packages/apollo-wind/src/components/forms/metadata-form.tsx index be417155c..c139e3419 100644 --- a/packages/apollo-wind/src/components/forms/metadata-form.tsx +++ b/packages/apollo-wind/src/components/forms/metadata-form.tsx @@ -57,6 +57,19 @@ interface MetadataFormProps { * (single-page, wizard, tabs). */ sectionVariant?: 'card' | 'plain'; + /** + * Controlled active step id for the `tabs` step variant. When provided the + * caller owns which tab is selected — persist it across remounts to keep the + * user on the same tab when switching between nodes. If the id isn't one of + * the currently visible steps, the form shows the first tab without mutating + * the caller's value. Omit for uncontrolled (internal) tab state. + */ + activeStepId?: string; + /** + * Fires with the step id whenever the user selects a tab, in both controlled + * and uncontrolled mode. Pair it with `activeStepId` to persist the selection. + */ + onActiveStepChange?: (stepId: string) => void; } // Stable default to prevent re-renders @@ -71,6 +84,8 @@ export function MetadataForm({ autoComplete, stepVariant = 'wizard', sectionVariant = 'card', + activeStepId, + onActiveStepChange, }: MetadataFormProps) { const [currentStep, setCurrentStep] = useState(0); const [customComponents, setCustomComponents] = useState< @@ -209,6 +224,8 @@ export function MetadataForm({ customComponents={customComponents} disabled={disabled} sectionVariant={sectionVariant} + activeStepId={activeStepId} + onActiveStepChange={onActiveStepChange} onReset={() => reset()} /> ); @@ -278,7 +295,7 @@ function SinglePageForm({ ); return ( - // Plain sections carry their own vertical rhythm (py-3 + divider), so the + // Plain sections carry their own vertical rhythm (py-4 + divider), so the // list adds no extra gap; card sections are spaced apart as separate boxes.
{visibleSections.map((section) => ( @@ -377,6 +394,8 @@ function MultiStepForm({ interface TabbedStepFormProps extends SinglePageFormProps { onReset: () => void; + activeStepId?: string; + onActiveStepChange?: (stepId: string) => void; } function TabbedStepForm({ @@ -386,20 +405,28 @@ function TabbedStepForm({ 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. Without this subscription the getters on `context.errors` - // don't re-render this component when only the error state changes. + // 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 @@ -434,18 +461,30 @@ function TabbedStepForm({ 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; @@ -453,7 +492,7 @@ function TabbedStepForm({ <> {/* Pinned tab bar: shrink-0 keeps it under the panel header while the @@ -493,36 +532,50 @@ function TabbedStepForm({
- {visibleSteps.map((step) => ( - // 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); card keeps the 2-unit rhythm between boxes. - -
{ + 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.sections - .filter( - (section) => !section.conditions || context.evaluateConditions(section.conditions) - ) - .map((section) => ( - - ))} -
-
- ))} + {stepSections.length === 0 && step.emptyState !== undefined ? ( +
+ {step.emptyState} +
+ ) : ( +
+ {stepSections.map((section) => ( + + ))} +
+ )} + + ); + })} @@ -590,12 +643,15 @@ function FormSection({
); - // If no title, render fields directly without a header. In the plain variant - // (properties panel) a titleless section is typically a tab's sole section, - // where the host drops the title/collapse chrome. Add a top inset equal to the - // accordion trigger's py-3 so the first field lines up with where a section - // header would sit — otherwise a single-section tab hugs the tab strip tighter - // than a multi-section tab whose leading header carries that padding. + // 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 isPlain ? (
{fieldsGrid}
@@ -626,17 +682,15 @@ function FormSection({ > {section.title} - {/* AccordionContent adds pb-4 pt-0 internally; plain tightens it to pb-3. */} - - {innerContent} - + {/* AccordionContent adds pb-4 pt-0 internally, which both variants keep. */} + {innerContent} ); } // Non-collapsible section - match Accordion structure exactly, including the - // plain variant's divider + tightened header/content padding. + // plain variant's divider and left-packed semibold header. return (
{/* Match AccordionPrimitive.Header + Trigger structure */} @@ -651,9 +705,9 @@ function FormSection({ {section.title}
- {/* Match AccordionContent: outer has text-sm, inner has pb-4 pt-0 (pb-3 in plain) */} + {/* Match AccordionContent: outer has text-sm, inner has pb-4 pt-0 */}
-
{innerContent}
+
{innerContent}
);