diff --git a/web_src/src/ui/CanvasPage/index.tsx b/web_src/src/ui/CanvasPage/index.tsx index dd0deb0a42..98993d3d08 100644 --- a/web_src/src/ui/CanvasPage/index.tsx +++ b/web_src/src/ui/CanvasPage/index.tsx @@ -1604,6 +1604,9 @@ function CanvasPage(props: CanvasPageProps) { canReadIntegrations={props.canReadIntegrations} canCreateIntegrations={props.canCreateIntegrations} canUpdateIntegrations={props.canUpdateIntegrations} + onEnterEditMode={props.onEnterEditMode} + enterEditModeDisabled={props.enterEditModeDisabled} + enterEditModeDisabledTooltip={props.enterEditModeDisabledTooltip} /> ) : null} @@ -1681,6 +1684,9 @@ function Sidebar({ canReadIntegrations, canCreateIntegrations, canUpdateIntegrations, + onEnterEditMode, + enterEditModeDisabled, + enterEditModeDisabledTooltip, }: { state: CanvasPageState; getSidebarData?: (nodeId: string) => SidebarData | null; @@ -1728,6 +1734,9 @@ function Sidebar({ canReadIntegrations?: boolean; canCreateIntegrations?: boolean; canUpdateIntegrations?: boolean; + onEnterEditMode?: () => void; + enterEditModeDisabled?: boolean; + enterEditModeDisabledTooltip?: string; }) { const sidebarData = useMemo(() => { if (!state.componentSidebar.selectedNodeId || !getSidebarData) { @@ -1892,6 +1901,9 @@ function Sidebar({ hideDocsTab={isAnnotationNode} hideNodeId={isAnnotationNode} readOnly={readOnly} + onEnterEditMode={onEnterEditMode} + enterEditModeDisabled={enterEditModeDisabled} + enterEditModeDisabledTooltip={enterEditModeDisabledTooltip} /> ); } @@ -2107,6 +2119,35 @@ function resolveAbsoluteNodeRect( }; } +type ComponentSidebarTab = "latest" | "settings" | "docs"; + +type NodeConfigurationWarningData = { + component?: { error?: string }; + composite?: { error?: string }; + trigger?: { error?: string }; +} | null; + +function shouldOpenSidebarSettingsTab(nodeData: NodeConfigurationWarningData, isEditMode: boolean): boolean { + return Boolean(nodeData?.component?.error || nodeData?.composite?.error || nodeData?.trigger?.error) || isEditMode; +} + +function applySidebarTabOnNodeOpen( + setCurrentTab: ((tab: ComponentSidebarTab) => void) | undefined, + wasSidebarOpen: boolean, + shouldOpenSettings: boolean, +): void { + if (!setCurrentTab) { + return; + } + if (!wasSidebarOpen) { + setCurrentTab(shouldOpenSettings ? "settings" : "latest"); + return; + } + if (shouldOpenSettings) { + setCurrentTab("settings"); + } +} + function CanvasContent({ state, onNodeEdit, @@ -2384,24 +2425,12 @@ function CanvasContent({ } else if (onNodeClick) { onNodeClick(nodeId); } else { + const wasSidebarOpen = stateRef.current.componentSidebar.isOpen; stateRef.current.componentSidebar.open(nodeId); - const nodeData = clickedNode?.data as { - component?: { error?: string }; - composite?: { error?: string }; - trigger?: { error?: string }; - } | null; - const hasConfigurationWarning = Boolean( - nodeData?.component?.error || nodeData?.composite?.error || nodeData?.trigger?.error, - ); - - if (setCurrentTab) { - setCurrentTab(hasConfigurationWarning || isEditMode ? "settings" : "latest"); - } - - if (onBuildingBlocksSidebarToggle) { - onBuildingBlocksSidebarToggle(false); - } + const nodeData = clickedNode?.data as NodeConfigurationWarningData; + applySidebarTabOnNodeOpen(setCurrentTab, wasSidebarOpen, shouldOpenSidebarSettingsTab(nodeData, isEditMode)); + onBuildingBlocksSidebarToggle?.(false); } stateRef.current.setNodes((nodes) => diff --git a/web_src/src/ui/componentSidebar/DocsTab.tsx b/web_src/src/ui/componentSidebar/DocsTab.tsx index 1ac51b35c6..b3cd286700 100644 --- a/web_src/src/ui/componentSidebar/DocsTab.tsx +++ b/web_src/src/ui/componentSidebar/DocsTab.tsx @@ -1,10 +1,12 @@ import type { ConfigurationField } from "@/api-client"; import { BookOpen, ExternalLink } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; import { PayloadPreview } from "@/ui/BuildingBlocksSidebar/PayloadPreview"; -function ConfigTable({ fields }: { fields: ConfigurationField[] }) { +function ConfigTable({ fields, showTopBorder }: { fields: ConfigurationField[]; showTopBorder?: boolean }) { return ( -
+
Configuration
@@ -39,6 +41,54 @@ function ConfigTable({ fields }: { fields: ConfigurationField[] }) { ); } +function DocsReferenceSection({ documentationUrl }: { documentationUrl: string }) { + return ( +
+ +
+ ); +} + +function DocsDescriptionSection({ description, showBottomBorder }: { description: string; showBottomBorder: boolean }) { + return ( +
+ Description +

{description}

+
+ ); +} + +function DocsPayloadSection({ + examplePayload, + payloadLabel, + showBottomBorder, +}: { + examplePayload: Record; + payloadLabel: string; + showBottomBorder: boolean; +}) { + return ( +
+
+ +
+
+ ); +} + interface DocsTabProps { description?: string; examplePayload?: Record; @@ -56,8 +106,10 @@ export function DocsTab({ configurationFields = [], }: DocsTabProps) { const hasPayload = examplePayload && Object.keys(examplePayload).length > 0; + const hasFollowingContent = Boolean(hasPayload || configurationFields.length > 0); + const hasContent = Boolean(description || hasPayload || configurationFields.length > 0 || documentationUrl); - if (!description && !hasPayload && configurationFields.length === 0 && !documentationUrl) { + if (!hasContent) { return (

No documentation available for this component.

@@ -67,43 +119,21 @@ export function DocsTab({ return (
- {documentationUrl && ( - - )} - {description && ( -
- Description -

{description}

-
- )} - - {hasPayload && ( -
-
- -
-
- )} - - {configurationFields.length > 0 && } + {documentationUrl ? : null} + {description ? : null} + {hasPayload ? ( + 0} + /> + ) : null} + {configurationFields.length > 0 ? ( + + ) : null}
); } diff --git a/web_src/src/ui/componentSidebar/SettingsTab.stories.tsx b/web_src/src/ui/componentSidebar/SettingsTab.stories.tsx index bee4a85d86..904f31e088 100644 --- a/web_src/src/ui/componentSidebar/SettingsTab.stories.tsx +++ b/web_src/src/ui/componentSidebar/SettingsTab.stories.tsx @@ -96,3 +96,40 @@ export const RendererCoverage: Story = { }, render: () => , }; + +function ReadOnlyConfigurationPlayground() { + return ( + undefined} + domainId={STORY_DOMAIN_ID} + domainType={STORY_DOMAIN_TYPE} + integrationName="github" + integrationRef={STORY_INTEGRATION_REF} + integrations={STORY_INTEGRATIONS} + integrationDefinition={{ + name: "github", + label: "GitHub", + icon: "github", + }} + autocompleteExampleObj={STORY_AUTOCOMPLETE_CONTEXT} + configurationSaveMode="manual" + readOnly={true} + /> + ); +} + +export const ReadOnlyConfiguration: Story = { + parameters: { + docs: { + description: { + story: "Read-only configuration view shown when the component sidebar is not editable.", + }, + }, + }, + render: () => , +}; diff --git a/web_src/src/ui/componentSidebar/SettingsTab.tsx b/web_src/src/ui/componentSidebar/SettingsTab.tsx index 446e381082..d58077e09a 100644 --- a/web_src/src/ui/componentSidebar/SettingsTab.tsx +++ b/web_src/src/ui/componentSidebar/SettingsTab.tsx @@ -21,6 +21,8 @@ import { validateFieldForSubmission, } from "@/lib/components"; import { useRealtimeValidation } from "@/hooks/useRealtimeValidation"; +import { buildConfigurationDisplayModel } from "./configurationView/buildConfigurationDisplayModel"; +import { ConfigurationView } from "./configurationView/ConfigurationView"; import { SimpleTooltip } from "./SimpleTooltip"; const REQUIRED_FIELD_BADGE_CLASS = @@ -53,6 +55,9 @@ interface SettingsTabProps { canReadIntegrations?: boolean; canCreateIntegrations?: boolean; canUpdateIntegrations?: boolean; + onEnterEditMode?: () => void; + enterEditModeDisabled?: boolean; + enterEditModeDisabledTooltip?: string; /** Canvas uses debounced autosave without a footer Save; Custom Component Builder keeps explicit Save. */ configurationSaveMode?: "manual" | "auto"; } @@ -96,6 +101,9 @@ export function SettingsTab({ canReadIntegrations, canCreateIntegrations, canUpdateIntegrations, + onEnterEditMode, + enterEditModeDisabled, + enterEditModeDisabledTooltip, configurationSaveMode = "manual", }: SettingsTabProps) { const CONNECT_ANOTHER_INSTANCE_VALUE = "__connect_another_instance__"; @@ -259,6 +267,10 @@ export function SettingsTab({ // Auto-select the first installation if none is selected or selection is invalid useEffect(() => { + if (isReadOnly) { + return; + } + if (integrationsOfType.length === 0) { if (selectedIntegration) { autosaveBaselineSnapshotRef.current = buildAutosaveSnapshot(nodeConfiguration, currentNodeName, undefined); @@ -285,7 +297,7 @@ export function SettingsTab({ id: firstIntegration.metadata?.id, name: firstIntegration.metadata?.name, }); - }, [integrationsOfType, selectedIntegration, nodeConfiguration, currentNodeName]); + }, [integrationsOfType, isReadOnly, selectedIntegration, nodeConfiguration, currentNodeName]); const shouldShowConfiguration = true; const shouldAutosaveOnChangeByFieldType = useCallback((fieldType: ConfigurationField["type"] | undefined) => { @@ -451,12 +463,51 @@ export function SettingsTab({ }; }, [configurationSaveMode, isReadOnly, nodeConfiguration, currentNodeName, selectedIntegration]); + const configurationDisplayModel = useMemo( + () => + buildConfigurationDisplayModel({ + configuration: nodeConfiguration, + configurationFields, + integrationName, + integrationRef, + integrations, + allowIntegrations, + }), + [allowIntegrations, configurationFields, integrationName, integrationRef, integrations, nodeConfiguration], + ); + + if (isReadOnly) { + return ( +
+
+ + {customField && shouldShowConfiguration ? ( +
0 + ? "" + : "border-t border-gray-200 dark:border-gray-700 pt-6" + } + > + {customField(nodeConfiguration)} +
+ ) : null} +
+
+ ); + } + return (
{ - if (configurationSaveMode !== "auto" || isReadOnly) { + if (configurationSaveMode !== "auto") { return; } const target = event.target as HTMLElement | null; @@ -471,7 +522,7 @@ export function SettingsTab({ >
{/* Node identification section — always visible */} -
+
@@ -497,7 +547,7 @@ export function SettingsTab({ const runTitleField = configurationFields?.find((f) => f.name === "customName"); if (!runTitleField || !shouldShowConfiguration) return null; return ( -
+
+
{!allowIntegrations ? (
You don't have permission to view integrations. @@ -550,7 +598,7 @@ export function SettingsTab({ size="sm" onClick={onOpenCreateIntegrationDialog} className="flex-shrink-0" - disabled={isReadOnly || !allowCreateIntegrations} + disabled={!allowCreateIntegrations} > Connect @@ -570,7 +618,7 @@ export function SettingsTab({ value={selectedIntegration?.id || ""} onValueChange={(value) => { if (value === CONNECT_ANOTHER_INSTANCE_VALUE) { - if (!isReadOnly && allowCreateIntegrations && onOpenCreateIntegrationDialog) { + if (allowCreateIntegrations && onOpenCreateIntegrationDialog) { onOpenCreateIntegrationDialog(); } return; @@ -584,7 +632,6 @@ export function SettingsTab({ requestAutosave(); } }} - disabled={isReadOnly} > @@ -666,7 +713,7 @@ export function SettingsTab({ size="sm" className="text-sm py-1.5" onClick={() => onOpenConfigureIntegrationDialog(selectedIntegrationFull.metadata!.id!)} - disabled={isReadOnly || !allowUpdateIntegrations} + disabled={!allowUpdateIntegrations} > Configure... @@ -697,9 +744,7 @@ export function SettingsTab({ {/* Configuration section */} {configurationFields && configurationFields.length > 0 && shouldShowConfiguration && ( -
+
{configurationFields.map((field) => { if (!field.name || field.name === "customName") return null; const fieldName = field.name; @@ -766,7 +811,6 @@ export function SettingsTab({ data-testid="save-node-button" variant="default" onClick={handleSave} - disabled={isReadOnly} loading={isSaving} loadingText="Saving..." > diff --git a/web_src/src/ui/componentSidebar/configurationView/ConfigurationNestedGroup.tsx b/web_src/src/ui/componentSidebar/configurationView/ConfigurationNestedGroup.tsx new file mode 100644 index 0000000000..424ad78af4 --- /dev/null +++ b/web_src/src/ui/componentSidebar/configurationView/ConfigurationNestedGroup.tsx @@ -0,0 +1,67 @@ +import type { ReactNode } from "react"; +import { cn } from "@/lib/utils"; +import type { ConfigurationDisplayBlock } from "./configurationDisplayBlocks"; +import type { ConfigurationDisplayRow } from "./types"; +import { EMPTY_DISPLAY_VALUE } from "./formatConfigurationValue"; + +type ConfigurationGroupHeaderProps = { + header: ConfigurationDisplayRow; + className?: string; +}; + +function ConfigurationGroupHeader({ header, className }: ConfigurationGroupHeaderProps) { + const hasSummary = header.displayText !== "" && header.displayText !== EMPTY_DISPLAY_VALUE; + + return ( +
+

{header.label}

+ {hasSummary ? {header.displayText} : null} +
+ ); +} + +type ConfigurationNestedGroupProps = { + header: ConfigurationDisplayRow; + children: ReactNode; + className?: string; + contentClassName?: string; +}; + +export function ConfigurationNestedGroup({ + header, + children, + className, + contentClassName, +}: ConfigurationNestedGroupProps) { + return ( +
+ +
+
{children}
+
+
+ ); +} + +type ConfigurationDisplayBlockListProps = { + blocks: ConfigurationDisplayBlock[]; + renderRow: (row: ConfigurationDisplayRow) => ReactNode; +}; + +export function ConfigurationDisplayBlockList({ blocks, renderRow }: ConfigurationDisplayBlockListProps) { + return ( + <> + {blocks.map((block) => { + if (block.type === "row") { + return
{renderRow(block.row)}
; + } + + return ( + + + + ); + })} + + ); +} diff --git a/web_src/src/ui/componentSidebar/configurationView/ConfigurationValueDisplay.spec.tsx b/web_src/src/ui/componentSidebar/configurationView/ConfigurationValueDisplay.spec.tsx new file mode 100644 index 0000000000..2ed42eaf35 --- /dev/null +++ b/web_src/src/ui/componentSidebar/configurationView/ConfigurationValueDisplay.spec.tsx @@ -0,0 +1,50 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { ConfigurationValueDisplay } from "./ConfigurationValueDisplay"; +import type { ConfigurationDisplayRow } from "./types"; + +function renderRow(row: ConfigurationDisplayRow) { + return render(); +} + +describe("ConfigurationValueDisplay", () => { + it("renders validated http(s) URLs as external links", () => { + renderRow({ + key: "endpoint", + label: "Endpoint", + kind: "url", + displayText: "https://api.example.com/hook", + href: "https://api.example.com/hook", + }); + + const link = screen.getByRole("link", { name: "https://api.example.com/hook" }); + expect(link).toHaveAttribute("href", "https://api.example.com/hook"); + expect(link).toHaveAttribute("target", "_blank"); + expect(link).toHaveAttribute("rel", "noopener noreferrer"); + }); + + it("does not render javascript: URLs as links for url fields", () => { + renderRow({ + key: "endpoint", + label: "Endpoint", + kind: "text", + displayText: "javascript:alert(1)", + }); + + expect(screen.queryByRole("link")).not.toBeInTheDocument(); + expect(screen.getByText("javascript:alert(1)").tagName).toBe("SPAN"); + }); + + it("does not render links when href is an unsafe scheme even if kind is url", () => { + renderRow({ + key: "endpoint", + label: "Endpoint", + kind: "url", + displayText: "javascript:alert(1)", + href: "javascript:alert(1)", + }); + + expect(screen.queryByRole("link")).not.toBeInTheDocument(); + expect(screen.getByText("javascript:alert(1)").tagName).toBe("SPAN"); + }); +}); diff --git a/web_src/src/ui/componentSidebar/configurationView/ConfigurationValueDisplay.tsx b/web_src/src/ui/componentSidebar/configurationView/ConfigurationValueDisplay.tsx new file mode 100644 index 0000000000..ad15fa7186 --- /dev/null +++ b/web_src/src/ui/componentSidebar/configurationView/ConfigurationValueDisplay.tsx @@ -0,0 +1,95 @@ +import { cn, isUrl } from "@/lib/utils"; +import type { ConfigurationDisplayRow } from "./types"; +import { EMPTY_DISPLAY_VALUE } from "./formatConfigurationValue"; + +type ConfigurationValueDisplayProps = { + row: ConfigurationDisplayRow; + className?: string; +}; + +const INTEGRATION_STATUS_CLASSES = { + ready: + "border border-green-950/15 bg-green-100 text-green-800 dark:border-green-950/15 dark:bg-green-900/30 dark:text-green-400", + error: "border border-red-950/15 bg-red-100 text-red-800 dark:border-red-950/15 dark:bg-red-900/30 dark:text-red-400", + pending: + "border border-orange-950/15 bg-orange-100 text-yellow-800 dark:border-orange-950/15 dark:bg-orange-950/30 dark:text-yellow-400", +} as const; + +function IntegrationStatusBadge({ row, className }: { row: ConfigurationDisplayRow; className?: string }) { + const variant = row.integrationStatusVariant ?? "pending"; + const showSummary = + row.displayText !== "" && row.displayText !== EMPTY_DISPLAY_VALUE && row.displayText !== row.integrationStatus; + + return ( + + {showSummary ? {row.displayText} : null} + + {row.integrationStatus} + + + ); +} + +function ChipList({ chips, className }: { chips: string[]; className?: string }) { + return ( +
+ {chips.map((chip) => ( + + {chip} + + ))} +
+ ); +} + +export function ConfigurationValueDisplay({ row, className }: ConfigurationValueDisplayProps) { + if (row.kind === "empty" || row.displayText === EMPTY_DISPLAY_VALUE) { + return {EMPTY_DISPLAY_VALUE}; + } + + if (row.kind === "integration" && row.integrationStatus) { + return ; + } + + if (row.chips && row.chips.length > 0) { + return ; + } + + const candidateHref = row.href ?? (row.kind === "url" ? row.displayText : undefined); + const href = candidateHref && isUrl(candidateHref) ? candidateHref : undefined; + if (href) { + return ( + + {row.displayText} + + ); + } + + const isMonospace = row.kind === "expression" || row.kind === "code"; + + return ( + + {row.displayText} + + ); +} diff --git a/web_src/src/ui/componentSidebar/configurationView/ConfigurationView.tsx b/web_src/src/ui/componentSidebar/configurationView/ConfigurationView.tsx new file mode 100644 index 0000000000..b2a97af945 --- /dev/null +++ b/web_src/src/ui/componentSidebar/configurationView/ConfigurationView.tsx @@ -0,0 +1,69 @@ +import { Button } from "@/components/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { ConfigurationValueDisplay } from "./ConfigurationValueDisplay"; +import { parseConfigurationDisplayBlocks } from "./configurationDisplayBlocks"; +import { ConfigurationDisplayBlockList } from "./ConfigurationNestedGroup"; +import type { ConfigurationDisplayModel, ConfigurationDisplayRow } from "./types"; + +type ConfigurationViewProps = { + model: ConfigurationDisplayModel; + onEdit?: () => void; + editDisabled?: boolean; + editDisabledTooltip?: string; +}; + +function ConfigurationRow({ row }: { row: ConfigurationDisplayRow }) { + return ( +
+ {row.label} + +
+ ); +} + +function ConfigurationEditButton({ + onEdit, + disabled, + disabledTooltip, +}: { + onEdit: () => void; + disabled?: boolean; + disabledTooltip?: string; +}) { + const button = ( + + ); + + if (disabled && disabledTooltip) { + return ( + + +
{button}
+
+ {disabledTooltip} +
+ ); + } + + return button; +} + +export function ConfigurationView({ model, onEdit, editDisabled, editDisabledTooltip }: ConfigurationViewProps) { + return ( +
+ {onEdit ? ( +
+ +
+ ) : null} +
+ } + /> +
+
+ ); +} diff --git a/web_src/src/ui/componentSidebar/configurationView/buildConfigurationDisplayModel.spec.ts b/web_src/src/ui/componentSidebar/configurationView/buildConfigurationDisplayModel.spec.ts new file mode 100644 index 0000000000..8a5d913eec --- /dev/null +++ b/web_src/src/ui/componentSidebar/configurationView/buildConfigurationDisplayModel.spec.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from "vitest"; +import { + settingsTabConfiguration, + settingsTabFields, + STORY_INTEGRATION_REF, + STORY_INTEGRATIONS, +} from "@/ui/configurationFieldRenderer/storybooks/fixtures"; +import { buildConfigurationDisplayModel } from "./buildConfigurationDisplayModel"; + +describe("buildConfigurationDisplayModel", () => { + it("builds a flat row list with integration and configuration fields", () => { + const model = buildConfigurationDisplayModel({ + configuration: settingsTabConfiguration, + configurationFields: settingsTabFields, + integrationName: "github", + integrationRef: STORY_INTEGRATION_REF, + integrations: STORY_INTEGRATIONS, + }); + + expect(model.rows.some((row) => row.key === "nodeName")).toBe(false); + expect(model.rows.some((row) => row.label === "Instance" && row.displayText === "GitHub Production")).toBe(true); + expect(model.rows.some((row) => row.integrationStatus === "Ready")).toBe(true); + expect(model.rows.some((row) => row.label === "Environment" && row.displayText === "Production")).toBe(true); + expect(model.rows.some((row) => row.label === "Send digest" && row.displayText === "Yes")).toBe(true); + }); + + it("flattens nested object fields with visibility conditions", () => { + const configurationFields = settingsTabFields.filter((field) => field.name === "authConfig"); + const model = buildConfigurationDisplayModel({ + configuration: { + authConfig: { + authMethod: "token", + token: "sp_live_token", + includeMetadata: true, + }, + }, + configurationFields, + }); + + expect(model.rows.some((row) => row.label === "Auth method" && row.displayText === "API token")).toBe(true); + expect(model.rows.some((row) => row.label === "Token" && row.displayText === "••••••")).toBe(true); + expect(model.rows.some((row) => row.label === "Username")).toBe(false); + expect(model.rows.some((row) => row.label === "Include metadata" && row.displayText === "Yes")).toBe(true); + }); + + it("expands list object items", () => { + const configurationFields = settingsTabFields.filter((field) => field.name === "headers"); + const model = buildConfigurationDisplayModel({ + configuration: { + headers: [ + { key: "X-Environment", value: "production" }, + { key: "X-Request-Source", value: "storybook" }, + ], + }, + configurationFields, + }); + + expect(model.rows.some((row) => row.label === "Header 1")).toBe(true); + expect(model.rows.some((row) => row.label === "Key" && row.displayText === "X-Environment")).toBe(true); + }); + + it("shows not connected integration state", () => { + const model = buildConfigurationDisplayModel({ + configuration: {}, + configurationFields: [], + integrationName: "github", + integrations: [], + }); + + expect(model.rows[0]?.integrationStatus).toBe("Not connected"); + }); + + it("does not default to the first integration when no ref is saved", () => { + const model = buildConfigurationDisplayModel({ + configuration: {}, + configurationFields: [], + integrationName: "github", + integrations: STORY_INTEGRATIONS, + }); + + expect(model.rows).toHaveLength(1); + expect(model.rows[0]?.integrationStatus).toBe("Not connected"); + expect(model.rows.some((row) => row.label === "Instance")).toBe(false); + }); + + it("does not default to the first integration when the saved ref is stale", () => { + const model = buildConfigurationDisplayModel({ + configuration: {}, + configurationFields: [], + integrationName: "github", + integrationRef: { id: "int_deleted", name: "Old GitHub" }, + integrations: STORY_INTEGRATIONS, + }); + + expect(model.rows).toHaveLength(1); + expect(model.rows[0]?.integrationStatus).toBe("Not connected"); + expect(model.rows.some((row) => row.label === "Instance" && row.displayText === "GitHub Production")).toBe(false); + }); + + it("shows not connected integration type label", () => { + const model = buildConfigurationDisplayModel({ + configuration: {}, + configurationFields: [], + integrationName: "github", + integrations: STORY_INTEGRATIONS, + }); + + expect(model.rows[0]?.displayText).toBe("GitHub"); + expect(model.rows[0]?.integrationStatus).toBe("Not connected"); + }); + + it("expands object schema defaults when the object value is missing", () => { + const configurationFields = settingsTabFields.filter((field) => field.name === "authConfig"); + const model = buildConfigurationDisplayModel({ + configuration: {}, + configurationFields, + }); + + expect(model.rows.some((row) => row.label === "Auth method")).toBe(true); + }); +}); diff --git a/web_src/src/ui/componentSidebar/configurationView/buildConfigurationDisplayModel.ts b/web_src/src/ui/componentSidebar/configurationView/buildConfigurationDisplayModel.ts new file mode 100644 index 0000000000..a5532cb172 --- /dev/null +++ b/web_src/src/ui/componentSidebar/configurationView/buildConfigurationDisplayModel.ts @@ -0,0 +1,305 @@ +import type { ComponentsIntegrationRef, ConfigurationField, OrganizationsIntegration } from "@/api-client"; +import { getIntegrationTypeDisplayName } from "@/lib/integrationDisplayName"; +import { isFieldVisible, parseDefaultValues } from "@/lib/components"; +import { EMPTY_DISPLAY_VALUE, formatConfigurationLabel, formatConfigurationValue } from "./formatConfigurationValue"; +import type { ConfigurationDisplayModel, ConfigurationDisplayRow } from "./types"; + +export type BuildConfigurationDisplayModelInput = { + configuration: Record; + configurationFields: ConfigurationField[]; + integrationName?: string; + integrationRef?: ComponentsIntegrationRef; + integrations?: OrganizationsIntegration[]; + allowIntegrations?: boolean; +}; + +type FieldRowsContext = { + fields: ConfigurationField[]; + values: Record; + rootConfiguration: Record; + parentPath: string; + depth: number; + rows: ConfigurationDisplayRow[]; +}; + +function resolveIntegrationInstanceName(integration: OrganizationsIntegration): string { + const instanceName = integration.metadata?.name; + const typeName = integration.metadata?.integrationName; + if (!instanceName) { + return "Unnamed integration"; + } + if (instanceName.toLowerCase() === typeName?.toLowerCase()) { + return getIntegrationTypeDisplayName(undefined, typeName) || instanceName; + } + return instanceName; +} + +function appendCustomNameRow( + rows: ConfigurationDisplayRow[], + configuration: Record, + configurationFields: ConfigurationField[], +): void { + const runTitleField = configurationFields.find((field) => field.name === "customName"); + if (!runTitleField?.name || !isFieldVisible(runTitleField, configuration)) { + return; + } + + const formatted = formatConfigurationValue(runTitleField, configuration[runTitleField.name]); + rows.push({ + key: "customName", + label: formatConfigurationLabel(runTitleField), + ...formatted, + }); +} + +function appendNotConnectedIntegrationRow(rows: ConfigurationDisplayRow[], typeLabel: string): void { + rows.push({ + key: "integration.notConnected", + label: "Integration", + kind: "integration", + displayText: typeLabel, + integrationStatus: "Not connected", + integrationStatusVariant: "pending", + }); +} + +function findSelectedIntegration( + integrationsOfType: OrganizationsIntegration[], + integrationRef?: ComponentsIntegrationRef, +): OrganizationsIntegration | undefined { + if (!integrationRef?.id) { + return undefined; + } + + return integrationsOfType.find((integration) => integration.metadata?.id === integrationRef.id); +} + +function appendConnectedIntegrationRows( + rows: ConfigurationDisplayRow[], + typeLabel: string, + selectedIntegration: OrganizationsIntegration, +): void { + const status = selectedIntegration.status?.state ?? "unknown"; + const statusLabel = status.charAt(0).toUpperCase() + status.slice(1); + const statusVariant: ConfigurationDisplayRow["integrationStatusVariant"] = + status === "ready" || status === "error" ? status : "pending"; + + rows.push({ + key: "integration.type", + label: "Type", + kind: "text", + displayText: typeLabel, + }); + rows.push({ + key: "integration.instance", + label: "Instance", + kind: "text", + displayText: resolveIntegrationInstanceName(selectedIntegration), + }); + rows.push({ + key: "integration.status", + label: "Connection", + kind: "integration", + displayText: statusLabel, + integrationStatus: statusLabel, + integrationStatusVariant: statusVariant, + }); + + if (status === "error" && selectedIntegration.status?.stateDescription) { + rows.push({ + key: "integration.statusDescription", + label: "Status details", + kind: "text", + displayText: selectedIntegration.status.stateDescription, + }); + } +} + +function appendIntegrationRows(rows: ConfigurationDisplayRow[], input: BuildConfigurationDisplayModelInput): void { + const { integrationName, integrationRef, integrations = [], allowIntegrations = true } = input; + if (!integrationName) { + return; + } + + const typeLabel = getIntegrationTypeDisplayName(undefined, integrationName) || integrationName; + + if (!allowIntegrations) { + rows.push({ + key: "integration.permission", + label: "Integration", + kind: "text", + displayText: "You don't have permission to view integrations.", + }); + return; + } + + const integrationsOfType = integrations.filter( + (integration) => integration.metadata?.integrationName === integrationName, + ); + const selectedIntegration = findSelectedIntegration(integrationsOfType, integrationRef); + if (!selectedIntegration) { + appendNotConnectedIntegrationRow(rows, typeLabel); + return; + } + + appendConnectedIntegrationRows(rows, typeLabel, selectedIntegration); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function shouldSkipField(field: ConfigurationField, ctx: FieldRowsContext): boolean { + if (!field.name || field.name === "customName") { + return true; + } + return !isFieldVisible(field, { ...ctx.rootConfiguration, ...ctx.values }); +} + +function appendObjectFieldRows( + field: ConfigurationField, + rawValue: Record, + fieldPath: string, + ctx: FieldRowsContext, +): void { + const objectSchema = field.typeOptions?.object?.schema; + if (!objectSchema) { + return; + } + + const schemaDefaults = parseDefaultValues(objectSchema); + const mergedValues = { ...schemaDefaults, ...rawValue }; + if (ctx.depth === 0) { + ctx.rows.push({ + key: `${fieldPath}.__group`, + label: formatConfigurationLabel(field), + kind: "text", + displayText: "", + depth: ctx.depth, + }); + } + + appendFieldRows({ + ...ctx, + fields: objectSchema, + values: mergedValues, + parentPath: fieldPath, + depth: ctx.depth + 1, + }); +} + +function appendListFieldRows( + field: ConfigurationField, + rawValue: unknown[], + fieldPath: string, + ctx: FieldRowsContext, +): void { + const listItemSchema = field.typeOptions?.list?.itemDefinition?.schema; + if (!listItemSchema) { + return; + } + + ctx.rows.push({ + key: `${fieldPath}.__group`, + label: formatConfigurationLabel(field), + kind: rawValue.length === 0 ? "empty" : "list", + displayText: + rawValue.length === 0 ? EMPTY_DISPLAY_VALUE : `${rawValue.length} item${rawValue.length === 1 ? "" : "s"}`, + depth: ctx.depth, + }); + + const itemLabel = field.typeOptions?.list?.itemLabel ?? "Item"; + rawValue.forEach((item, index) => { + if (!isRecord(item)) { + return; + } + + ctx.rows.push({ + key: `${fieldPath}[${index}].__header`, + label: `${itemLabel} ${index + 1}`, + kind: "text", + displayText: "", + depth: ctx.depth + 1, + }); + appendFieldRows({ + ...ctx, + fields: listItemSchema, + values: item, + parentPath: `${fieldPath}[${index}]`, + depth: ctx.depth + 2, + }); + }); +} + +function appendScalarFieldRow( + field: ConfigurationField, + rawValue: unknown, + fieldPath: string, + ctx: FieldRowsContext, +): void { + const formatted = formatConfigurationValue(field, rawValue); + ctx.rows.push({ + key: fieldPath, + label: formatConfigurationLabel(field), + depth: ctx.depth, + ...formatted, + }); +} + +function hasObjectFieldSchema(field: ConfigurationField): boolean { + return field.type === "object" && Boolean(field.typeOptions?.object?.schema); +} + +function isListFieldValue(field: ConfigurationField, rawValue: unknown): rawValue is unknown[] { + const listItemType = field.typeOptions?.list?.itemDefinition?.type; + return ( + field.type === "list" && + Array.isArray(rawValue) && + Boolean(field.typeOptions?.list?.itemDefinition?.schema) && + listItemType === "object" + ); +} + +function processField(field: ConfigurationField, ctx: FieldRowsContext): void { + const fieldPath = ctx.parentPath ? `${ctx.parentPath}.${field.name}` : field.name!; + const rawValue = ctx.values[field.name!]; + + if (hasObjectFieldSchema(field)) { + appendObjectFieldRows(field, isRecord(rawValue) ? rawValue : {}, fieldPath, ctx); + return; + } + + if (isListFieldValue(field, rawValue)) { + appendListFieldRows(field, rawValue, fieldPath, ctx); + return; + } + + appendScalarFieldRow(field, rawValue, fieldPath, ctx); +} + +function appendFieldRows(ctx: FieldRowsContext): void { + for (const field of ctx.fields) { + if (shouldSkipField(field, ctx)) { + continue; + } + + processField(field, ctx); + } +} + +export function buildConfigurationDisplayModel(input: BuildConfigurationDisplayModelInput): ConfigurationDisplayModel { + const rows: ConfigurationDisplayRow[] = []; + + appendCustomNameRow(rows, input.configuration, input.configurationFields); + appendIntegrationRows(rows, input); + appendFieldRows({ + fields: input.configurationFields, + values: input.configuration, + rootConfiguration: input.configuration, + parentPath: "", + depth: 0, + rows, + }); + + return { rows }; +} diff --git a/web_src/src/ui/componentSidebar/configurationView/configurationDisplayBlocks.spec.ts b/web_src/src/ui/componentSidebar/configurationView/configurationDisplayBlocks.spec.ts new file mode 100644 index 0000000000..33e3b4b096 --- /dev/null +++ b/web_src/src/ui/componentSidebar/configurationView/configurationDisplayBlocks.spec.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { isNestedGroupHeader, parseConfigurationDisplayBlocks } from "./configurationDisplayBlocks"; +import type { ConfigurationDisplayRow } from "./types"; + +describe("parseConfigurationDisplayBlocks", () => { + it("identifies structural group headers by key suffix", () => { + expect( + isNestedGroupHeader({ key: "authConfig.__group", label: "Auth", kind: "text", displayText: "2 items" }), + ).toBe(true); + expect(isNestedGroupHeader({ key: "headers[0].__header", label: "Header 1", kind: "text", displayText: "" })).toBe( + true, + ); + expect( + isNestedGroupHeader({ key: "environment", label: "Environment", kind: "text", displayText: "Production" }), + ).toBe(false); + }); + + it("groups nested object fields under a single block", () => { + const rows: ConfigurationDisplayRow[] = [ + { key: "authConfig.__group", label: "Authentication", kind: "text", displayText: "", depth: 0 }, + { key: "authConfig.authMethod", label: "Auth method", kind: "text", displayText: "API token", depth: 1 }, + { key: "authConfig.token", label: "Token", kind: "text", displayText: "••••••", depth: 1 }, + { key: "environment", label: "Environment", kind: "text", displayText: "Production", depth: 0 }, + ]; + + const blocks = parseConfigurationDisplayBlocks(rows); + expect(blocks).toHaveLength(2); + expect(blocks[0]).toMatchObject({ + type: "group", + header: { key: "authConfig.__group" }, + }); + expect(blocks[0].type === "group" && blocks[0].children).toHaveLength(2); + expect(blocks[1]).toMatchObject({ type: "row", row: { key: "environment" } }); + }); + + it("nests list item groups with their own vertical grouping", () => { + const rows: ConfigurationDisplayRow[] = [ + { key: "headers.__group", label: "Headers", kind: "list", displayText: "2 items", depth: 0 }, + { key: "headers[0].__header", label: "Header 1", kind: "text", displayText: "", depth: 1 }, + { key: "headers[0].key", label: "Key", kind: "text", displayText: "X-Environment", depth: 2 }, + { key: "headers[0].value", label: "Value", kind: "text", displayText: "production", depth: 2 }, + { key: "headers[1].__header", label: "Header 2", kind: "text", displayText: "", depth: 1 }, + { key: "headers[1].key", label: "Key", kind: "text", displayText: "X-Request-Source", depth: 2 }, + ]; + + const blocks = parseConfigurationDisplayBlocks(rows); + expect(blocks).toHaveLength(1); + expect(blocks[0].type).toBe("group"); + + if (blocks[0].type !== "group") { + return; + } + + expect(blocks[0].children).toHaveLength(2); + expect(blocks[0].children[0].type).toBe("group"); + expect(blocks[0].children[1].type).toBe("group"); + }); +}); diff --git a/web_src/src/ui/componentSidebar/configurationView/configurationDisplayBlocks.ts b/web_src/src/ui/componentSidebar/configurationView/configurationDisplayBlocks.ts new file mode 100644 index 0000000000..1dc36f1922 --- /dev/null +++ b/web_src/src/ui/componentSidebar/configurationView/configurationDisplayBlocks.ts @@ -0,0 +1,42 @@ +import type { ConfigurationDisplayRow } from "./types"; + +export type ConfigurationDisplayBlock = + | { type: "row"; row: ConfigurationDisplayRow } + | { type: "group"; header: ConfigurationDisplayRow; children: ConfigurationDisplayBlock[] }; + +/** Structural headers for object fields, list containers, and list item groups. */ +export function isNestedGroupHeader(row: ConfigurationDisplayRow): boolean { + return row.key.endsWith(".__group") || row.key.endsWith(".__header"); +} + +export function parseConfigurationDisplayBlocks(rows: ConfigurationDisplayRow[]): ConfigurationDisplayBlock[] { + const blocks: ConfigurationDisplayBlock[] = []; + let index = 0; + + while (index < rows.length) { + const row = rows[index]; + + if (isNestedGroupHeader(row)) { + const headerDepth = row.depth ?? 0; + index += 1; + const childRows: ConfigurationDisplayRow[] = []; + + while (index < rows.length && (rows[index].depth ?? 0) > headerDepth) { + childRows.push(rows[index]); + index += 1; + } + + blocks.push({ + type: "group", + header: row, + children: parseConfigurationDisplayBlocks(childRows), + }); + continue; + } + + blocks.push({ type: "row", row }); + index += 1; + } + + return blocks; +} diff --git a/web_src/src/ui/componentSidebar/configurationView/formatConfigurationValue.spec.ts b/web_src/src/ui/componentSidebar/configurationView/formatConfigurationValue.spec.ts new file mode 100644 index 0000000000..43cfe6b732 --- /dev/null +++ b/web_src/src/ui/componentSidebar/configurationView/formatConfigurationValue.spec.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest"; +import type { ConfigurationField } from "@/api-client"; +import { EMPTY_DISPLAY_VALUE, formatConfigurationValue } from "./formatConfigurationValue"; + +describe("formatConfigurationValue", () => { + it("returns empty display for unset values", () => { + const field: ConfigurationField = { name: "serviceName", label: "Service name", type: "string" }; + expect(formatConfigurationValue(field, undefined)).toEqual({ + kind: "empty", + displayText: EMPTY_DISPLAY_VALUE, + }); + }); + + it("formats boolean values as Yes/No", () => { + const field: ConfigurationField = { name: "enabled", label: "Enabled", type: "boolean" }; + expect(formatConfigurationValue(field, true).displayText).toBe("Yes"); + expect(formatConfigurationValue(field, false).displayText).toBe("No"); + }); + + it("resolves select option labels", () => { + const field: ConfigurationField = { + name: "environment", + label: "Environment", + type: "select", + typeOptions: { + select: { + options: [ + { label: "Development", value: "development" }, + { label: "Production", value: "production" }, + ], + }, + }, + }; + expect(formatConfigurationValue(field, "production")).toEqual({ + kind: "text", + displayText: "Production", + }); + }); + + it("formats multi-select values as chips", () => { + const field: ConfigurationField = { + name: "channels", + label: "Channels", + type: "multi-select", + typeOptions: { + multiSelect: { + options: [ + { label: "Slack", value: "slack" }, + { label: "Webhook", value: "webhook" }, + ], + }, + }, + }; + expect(formatConfigurationValue(field, ["slack", "webhook"])).toEqual({ + kind: "list", + displayText: "Slack, Webhook", + chips: ["Slack", "Webhook"], + }); + }); + + it("masks secret-key references", () => { + const field: ConfigurationField = { name: "credential", label: "Credential", type: "secret-key" }; + expect(formatConfigurationValue(field, { secret: "prod", key: "api-token" }).displayText).toBe( + "secret:prod / api-token", + ); + }); + + it("masks sensitive string fields", () => { + const field: ConfigurationField = { + name: "token", + label: "Token", + type: "string", + sensitive: true, + }; + expect(formatConfigurationValue(field, "sp_live_token").displayText).toBe("••••••"); + }); + + it("marks url fields as links", () => { + const field: ConfigurationField = { name: "endpoint", label: "Endpoint", type: "url" }; + const formatted = formatConfigurationValue(field, "https://api.example.com/hook"); + expect(formatted.kind).toBe("url"); + expect(formatted.href).toBe("https://api.example.com/hook"); + }); + + it("does not link url fields with non-http(s) values", () => { + const field: ConfigurationField = { name: "endpoint", label: "Endpoint", type: "url" }; + expect(formatConfigurationValue(field, "javascript:alert(1)")).toEqual({ + kind: "text", + displayText: "javascript:alert(1)", + }); + expect(formatConfigurationValue(field, "ftp://files.example.com/data")).toEqual({ + kind: "text", + displayText: "ftp://files.example.com/data", + }); + }); + + it("uses monospace kind for expressions", () => { + const field: ConfigurationField = { name: "filter", label: "Filter", type: "expression" }; + expect(formatConfigurationValue(field, '$["trigger"].payload.id').kind).toBe("expression"); + }); + + it("does not treat expression values as urls", () => { + const field: ConfigurationField = { name: "endpoint", label: "Endpoint", type: "expression" }; + expect(formatConfigurationValue(field, "https://api.example.com/hook")).toEqual({ + kind: "expression", + displayText: "https://api.example.com/hook", + }); + }); +}); diff --git a/web_src/src/ui/componentSidebar/configurationView/formatConfigurationValue.ts b/web_src/src/ui/componentSidebar/configurationView/formatConfigurationValue.ts new file mode 100644 index 0000000000..7e1f8657ac --- /dev/null +++ b/web_src/src/ui/componentSidebar/configurationView/formatConfigurationValue.ts @@ -0,0 +1,175 @@ +import type { ConfigurationField } from "@/api-client"; +import { isUrl } from "@/lib/utils"; +import type { ConfigurationDisplayKind } from "./types"; + +export const EMPTY_DISPLAY_VALUE = "—"; + +export type FormattedConfigurationValue = { + kind: ConfigurationDisplayKind; + displayText: string; + href?: string; + chips?: string[]; +}; + +function isEmptyValue(value: unknown): boolean { + if (value === null || value === undefined) { + return true; + } + if (typeof value === "string") { + return value.trim() === ""; + } + if (Array.isArray(value)) { + return value.length === 0; + } + if (typeof value === "object") { + return Object.keys(value).length === 0; + } + return false; +} + +function resolveSelectLabel(field: ConfigurationField, value: string): string { + const options = field.typeOptions?.select?.options ?? []; + const match = options.find((option) => option.value === value); + return match?.label ?? value; +} + +function resolveMultiSelectLabels(field: ConfigurationField, values: string[]): string[] { + const options = field.typeOptions?.multiSelect?.options ?? []; + return values.map((value) => { + const match = options.find((option) => option.value === value); + return match?.label ?? value; + }); +} + +function formatSecretKeyValue(value: unknown): string { + if (!value || typeof value !== "object") { + return EMPTY_DISPLAY_VALUE; + } + const record = value as { secret?: string; key?: string }; + if (!record.secret || !record.key) { + return EMPTY_DISPLAY_VALUE; + } + return `secret:${record.secret} / ${record.key}`; +} + +function formatBooleanValue(value: unknown): FormattedConfigurationValue { + return { + kind: "boolean", + displayText: value === true || value === "true" ? "Yes" : "No", + }; +} + +function formatSelectValue(field: ConfigurationField, value: unknown): FormattedConfigurationValue { + return { + kind: "text", + displayText: resolveSelectLabel(field, String(value)), + }; +} + +function formatMultiValueField(field: ConfigurationField, value: unknown): FormattedConfigurationValue { + const values = Array.isArray(value) ? value.map(String) : [String(value)]; + const labels = field.type === "multi-select" ? resolveMultiSelectLabels(field, values) : values; + return { + kind: "list", + displayText: labels.join(", "), + chips: labels, + }; +} + +function formatStringValue(value: unknown, field: ConfigurationField): FormattedConfigurationValue { + const stringValue = typeof value === "object" ? JSON.stringify(value, null, 2) : String(value); + + if (field.sensitive && stringValue.trim() !== "") { + return { kind: "text", displayText: "••••••" }; + } + + if (field.type === "expression") { + return { + kind: "expression", + displayText: stringValue, + }; + } + + if (isUrl(stringValue)) { + return { + kind: "url", + displayText: stringValue, + href: stringValue, + }; + } + + if (field.type === "url") { + return { + kind: "text", + displayText: stringValue, + }; + } + + if (field.type === "text" || field.type === "xml" || field.type === "object") { + const isMultiline = stringValue.includes("\n") || stringValue.length > 80; + return { + kind: isMultiline ? "code" : "text", + displayText: stringValue, + }; + } + + return { + kind: "text", + displayText: stringValue, + }; +} + +function formatArrayValue(value: unknown[]): FormattedConfigurationValue { + const labels = value.map((item) => (typeof item === "object" ? JSON.stringify(item) : String(item))); + return { + kind: "list", + displayText: labels.join(", "), + chips: labels, + }; +} + +function formatTypedFieldValue(value: unknown, field: ConfigurationField): FormattedConfigurationValue | null { + switch (field.type) { + case "boolean": + return formatBooleanValue(value); + case "select": + return formatSelectValue(field, value); + case "multi-select": + case "days-of-week": + return formatMultiValueField(field, value); + case "secret-key": + return { kind: "text", displayText: formatSecretKeyValue(value) }; + case "expression": + return { + kind: "expression", + displayText: typeof value === "object" ? JSON.stringify(value, null, 2) : String(value), + }; + default: + return null; + } +} + +function formatPrimitiveValue(value: unknown, field: ConfigurationField): FormattedConfigurationValue { + if (isEmptyValue(value)) { + return { kind: "empty", displayText: EMPTY_DISPLAY_VALUE }; + } + + const typedValue = formatTypedFieldValue(value, field); + if (typedValue) { + return typedValue; + } + + if (Array.isArray(value)) { + return formatArrayValue(value); + } + + return formatStringValue(value, field); +} + +export function formatConfigurationValue(field: ConfigurationField, value: unknown): FormattedConfigurationValue { + return formatPrimitiveValue(value, field); +} + +export function formatConfigurationLabel(field: ConfigurationField): string { + return field.label?.trim() || field.name || "Field"; +} diff --git a/web_src/src/ui/componentSidebar/configurationView/types.ts b/web_src/src/ui/componentSidebar/configurationView/types.ts new file mode 100644 index 0000000000..f901835fe7 --- /dev/null +++ b/web_src/src/ui/componentSidebar/configurationView/types.ts @@ -0,0 +1,30 @@ +export type ConfigurationDisplayKind = + | "text" + | "url" + | "boolean" + | "expression" + | "code" + | "list" + | "empty" + | "integration"; + +export type ConfigurationDisplayRow = { + key: string; + label: string; + kind: ConfigurationDisplayKind; + /** Plain-text or formatted display value. */ + displayText: string; + /** When kind is url, the href to link to. */ + href?: string; + /** Compact chip labels for list-style values. */ + chips?: string[]; + /** Nesting depth for object/list child rows. */ + depth?: number; + /** Integration status badge label (Ready, Error, etc.). */ + integrationStatus?: string; + integrationStatusVariant?: "ready" | "error" | "pending"; +}; + +export type ConfigurationDisplayModel = { + rows: ConfigurationDisplayRow[]; +}; diff --git a/web_src/src/ui/componentSidebar/index.tsx b/web_src/src/ui/componentSidebar/index.tsx index 0c4004ca03..948abdc739 100644 --- a/web_src/src/ui/componentSidebar/index.tsx +++ b/web_src/src/ui/componentSidebar/index.tsx @@ -145,6 +145,9 @@ interface ComponentSidebarProps { executionChainRequestId?: number; onExecutionChainHandled?: () => void; readOnly?: boolean; + onEnterEditMode?: () => void; + enterEditModeDisabled?: boolean; + enterEditModeDisabledTooltip?: string; } export const ComponentSidebar = ({ @@ -215,6 +218,9 @@ export const ComponentSidebar = ({ executionChainRequestId, onExecutionChainHandled, readOnly = false, + onEnterEditMode, + enterEditModeDisabled, + enterEditModeDisabledTooltip, }: ComponentSidebarProps) => { const sidebarWidth = useSidebarLayoutStore((state) => state.rightWidth); const isResizing = useSidebarLayoutStore((state) => state.isRightResizing); @@ -597,7 +603,7 @@ export const ComponentSidebar = ({ className="flex-1" > {showSettingsTab && ( -
+
{shouldShowRunsTab && (