fix(ui): refactor connection panel and fix search empty state (#12659)

* fix(ui): refactor connection panel and fix search empty state rendering

Extract connection state management into useConnectionPanelState hook
and search/list UI into ConnectionSearchList component. Fixes bug where
blank list items rendered before the empty state when search returned
no results.

* fix(ui): prevent modal jump when clicking connection items in Chrome

Chrome scrolls the nearest scrollable ancestor when focusing sr-only
inputs inside labels. Adding relative + overflow-hidden to the label
contains the scroll-into-view behavior within the label bounds.
This commit is contained in:
Viktor Avelino
2026-04-13 15:10:07 -04:00
committed by GitHub
parent 73a48b5810
commit a216aa9060
7 changed files with 399 additions and 265 deletions

View File

@ -7,6 +7,7 @@ export interface DeploymentConfigItem {
connection_id: string;
app_id: string;
type?: string;
environment?: string;
}
export interface DeploymentConfigListResponse {

View File

@ -0,0 +1,95 @@
import { memo, useMemo, useState } from "react";
import ForwardedIconComponent from "@/components/common/genericIconComponent";
import { Input } from "@/components/ui/input";
import type { ConnectionItem } from "../types";
import { CheckboxSelectItem } from "./radio-select-item";
interface ConnectionSearchListProps {
connections: ConnectionItem[];
selectedConnections: Set<string>;
onToggleConnection: (id: string) => void;
onSwitchToCreate: () => void;
}
export const ConnectionSearchList = memo(function ConnectionSearchList({
connections,
selectedConnections,
onToggleConnection,
onSwitchToCreate,
}: ConnectionSearchListProps) {
const [searchQuery, setSearchQuery] = useState("");
const filteredConnections = useMemo(() => {
const sorted = [...connections].sort((a, b) => {
if (a.isNew && !b.isNew) return -1;
if (!a.isNew && b.isNew) return 1;
return 0;
});
if (!searchQuery.trim()) return sorted;
const q = searchQuery.toLowerCase();
return sorted.filter(
(c) => c.name.toLowerCase().includes(q) || c.id.toLowerCase().includes(q),
);
}, [connections, searchQuery]);
if (connections.length === 0) {
return (
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center">
<ForwardedIconComponent
name="PlugZap"
className="h-8 w-8 text-muted-foreground/50"
/>
<div>
<p className="text-sm font-medium text-muted-foreground">
No connections yet
</p>
<p className="mt-0.5 text-xs text-muted-foreground/70">
Create a connection to attach credentials to this flow.
</p>
</div>
<button
type="button"
onClick={onSwitchToCreate}
className="text-xs font-medium text-primary hover:underline"
>
Create your first connection
</button>
</div>
);
}
return (
<>
<div className="min-w-0">
<Input
icon="Search"
placeholder="Search connections..."
className="bg-muted"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
{filteredConnections.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">
No connections match &ldquo;{searchQuery}&rdquo;
</p>
) : (
filteredConnections.map((conn) => (
<CheckboxSelectItem
key={conn.connectionId}
value={conn.id}
checked={selectedConnections.has(conn.id)}
onChange={() => onToggleConnection(conn.id)}
data-testid={`connection-item-${conn.id}`}
>
<div className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium leading-tight">
{conn.name}
</span>
</div>
</CheckboxSelectItem>
))
)}
</>
);
});

View File

@ -31,7 +31,7 @@ export function CheckboxSelectItem({
<label
data-testid={testId}
className={cn(
"flex w-full cursor-pointer items-center gap-4 rounded-xl border bg-muted p-3 text-left transition-colors",
"relative flex w-full cursor-pointer items-center gap-4 overflow-hidden rounded-xl border bg-muted p-3 text-left transition-colors",
checked ? "border-primary" : "border-transparent hover:border-border",
)}
>
@ -72,7 +72,7 @@ export function RadioSelectItem({
<label
data-testid={testId}
className={cn(
"flex w-full cursor-pointer items-center gap-4 rounded-xl border bg-muted p-3 text-left transition-colors",
"relative flex w-full cursor-pointer items-center gap-4 overflow-hidden rounded-xl border bg-muted p-3 text-left transition-colors",
selected ? "border-primary" : "border-transparent hover:border-border",
className,
)}

View File

@ -1,11 +1,10 @@
import { memo, useMemo, useState } from "react";
import ForwardedIconComponent from "@/components/common/genericIconComponent";
import { memo } from "react";
import InputComponent from "@/components/core/parameterRenderComponent/components/inputComponent";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/utils/utils";
import type { ConnectionItem, EnvVarEntry } from "../types";
import { CheckboxSelectItem } from "./radio-select-item";
import { ConnectionSearchList } from "./connection-search-list";
export type ConnectionTab = "available" | "create";
@ -48,21 +47,6 @@ export const ConnectionPanel = memo(function ConnectionPanel({
onCreateConnection: () => void;
isDuplicateName?: boolean;
}) {
const [searchQuery, setSearchQuery] = useState("");
const filteredConnections = useMemo(() => {
// Sort newly created connections to the top
const sorted = [...connections].sort((a, b) => {
if (a.isNew && !b.isNew) return -1;
if (!a.isNew && b.isNew) return 1;
return 0;
});
if (!searchQuery.trim()) return sorted;
const q = searchQuery.toLowerCase();
return sorted.filter(
(c) => c.name.toLowerCase().includes(q) || c.id.toLowerCase().includes(q),
);
}, [connections, searchQuery]);
return (
<>
<div className="border-b border-border p-4 text-sm text-muted-foreground">
@ -93,65 +77,18 @@ export const ConnectionPanel = memo(function ConnectionPanel({
</div>
{/* Tab content */}
<div className="mt-4 flex-1 overflow-x-hidden overflow-y-auto">
<div
className="mt-4 flex-1 overflow-x-hidden overflow-y-auto"
key={connectionTab}
>
{connectionTab === "available" ? (
<div className="min-w-0 space-y-3">
{connections.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center">
<ForwardedIconComponent
name="PlugZap"
className="h-8 w-8 text-muted-foreground/50"
/>
<div>
<p className="text-sm font-medium text-muted-foreground">
No connections yet
</p>
<p className="mt-0.5 text-xs text-muted-foreground/70">
Create a connection to attach credentials to this flow.
</p>
</div>
<button
type="button"
onClick={() => onTabChange("create")}
className="text-xs font-medium text-primary hover:underline"
>
Create your first connection
</button>
</div>
) : (
<>
<div className="min-w-0">
<Input
icon="Search"
placeholder="Search connections..."
className="bg-muted"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
{filteredConnections.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">
No connections match &ldquo;{searchQuery}&rdquo;
</p>
) : (
filteredConnections.map((conn) => (
<CheckboxSelectItem
key={conn.id}
value={conn.id}
checked={selectedConnections.has(conn.id)}
onChange={() => onToggleConnection(conn.id)}
data-testid={`connection-item-${conn.id}`}
>
<div className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium leading-tight">
{conn.name}
</span>
</div>
</CheckboxSelectItem>
))
)}
</>
)}
<ConnectionSearchList
connections={connections}
selectedConnections={selectedConnections}
onToggleConnection={onToggleConnection}
onSwitchToCreate={() => onTabChange("create")}
/>
</div>
) : (
<div className="flex flex-col gap-4">

View File

@ -8,8 +8,8 @@ import { usePostDetectEnvVars } from "@/controllers/API/queries/variables/use-po
import useAlertStore from "@/stores/alertStore";
import { useFolderStore } from "@/stores/foldersStore";
import { useDeploymentStepper } from "../contexts/deployment-stepper-context";
import type { ConnectionItem, EnvVarEntry } from "../types";
import type { ConnectionTab } from "./step-attach-flows-connection-panel";
import { useConnectionPanelState } from "../hooks/use-connection-panel-state";
import type { ConnectionItem } from "../types";
import { ConnectionPanel } from "./step-attach-flows-connection-panel";
import { FlowListPanel } from "./step-attach-flows-flow-list-panel";
import { VersionPanel } from "./step-attach-flows-version-panel";
@ -81,6 +81,7 @@ export default function StepAttachFlows() {
const existingConnections: ConnectionItem[] = configsData.configs.map(
(cfg) => ({
id: cfg.app_id,
connectionId: cfg.connection_id,
name: cfg.app_id,
variableCount: 0,
isNew: false,
@ -106,16 +107,53 @@ export default function StepAttachFlows() {
versionTag: string;
} | null>(null);
const [rightPanel, setRightPanel] = useState<RightPanelView>("versions");
const [connectionTab, setConnectionTab] =
useState<ConnectionTab>("available");
const [selectedConnections, setSelectedConnections] = useState<Set<string>>(
new Set(),
);
const [newConnectionName, setNewConnectionName] = useState("");
const [envVars, setEnvVars] = useState<EnvVarEntry[]>(() => [
{ id: crypto.randomUUID(), key: "", value: "" },
]);
const [detectedVarCount, setDetectedVarCount] = useState(0);
const commitPendingAttachment = useCallback(() => {
if (pendingAttachment) {
onSelectVersion(
pendingAttachment.flowId,
pendingAttachment.versionId,
pendingAttachment.versionTag,
);
setPendingAttachment(null);
}
}, [pendingAttachment, onSelectVersion]);
const resetPendingAttachment = useCallback(() => {
setPendingAttachment(null);
}, []);
const effectiveFlowId = selectedFlowId ?? flows[0]?.id ?? null;
const {
connectionTab,
setConnectionTab,
selectedConnections,
setSelectedConnections,
newConnectionName,
setNewConnectionName,
envVars,
detectedVarCount,
isDuplicateConnectionName,
handleAttachConnection,
handleCreateConnection,
handleSkipConnection,
handleChangeFlow,
handleAddEnvVar,
handleEnvVarChange,
handleEnvVarSelectGlobalVar,
initConnectionsForFlow,
updateDetectedEnvVars,
} = useConnectionPanelState({
connections,
setConnections,
effectiveFlowId,
attachedConnectionByFlow,
onAttachConnection,
commitPendingAttachment,
resetPendingAttachment,
setRightPanel,
});
const { mutateAsync: detectEnvVars } = usePostDetectEnvVars();
const { data: globalVariables } = useGetGlobalVariables();
@ -123,6 +161,8 @@ export default function StepAttachFlows() {
// When a flow+version are pre-selected from outside (e.g., canvas deploy button),
// auto-advance to the connections panel and detect env vars for the pre-selected version.
// biome-ignore lint/correctness/useExhaustiveDependencies: intentionally run only on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => {
const preSelected = initialFlowId
? selectedVersionByFlow.get(initialFlowId)
@ -137,17 +177,7 @@ export default function StepAttachFlows() {
flow_version_ids: [preSelected.versionId],
});
const detected = result.variables ?? [];
if (detected.length > 0) {
setDetectedVarCount(detected.length);
setEnvVars(
detected.map((variableName) => ({
id: crypto.randomUUID(),
key: variableName,
value: variableName,
globalVar: true,
})),
);
}
updateDetectedEnvVars(detected);
} catch {
setErrorData({
title: "Could not auto-detect environment variables",
@ -156,17 +186,8 @@ export default function StepAttachFlows() {
}
};
detect();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const isDuplicateConnectionName = useMemo(() => {
const trimmed = newConnectionName.trim().toLowerCase();
if (!trimmed) return false;
return connections.some((c) => c.name.trim().toLowerCase() === trimmed);
}, [newConnectionName, connections]);
const effectiveFlowId = selectedFlowId ?? flows[0]?.id ?? null;
const { data: versionResponse, isLoading: isLoadingVersions } =
useGetFlowVersions(
{ flowId: effectiveFlowId ?? "" },
@ -187,13 +208,7 @@ export default function StepAttachFlows() {
versionTag: version?.version_tag ?? "",
});
setRightPanel("connections");
setSelectedConnections(
new Set(attachedConnectionByFlow.get(effectiveFlowId) ?? []),
);
// Default to "create" tab when there are no existing connections
if (connections.length === 0) {
setConnectionTab("create");
}
initConnectionsForFlow(effectiveFlowId);
// Auto-detect global variable references via the backend detection endpoint
try {
@ -201,23 +216,9 @@ export default function StepAttachFlows() {
flow_version_ids: [versionId],
});
const detected = result.variables ?? [];
if (detected.length > 0) {
setDetectedVarCount(detected.length);
setEnvVars(
detected.map((variableName) => ({
id: crypto.randomUUID(),
key: variableName,
value: variableName,
globalVar: true,
})),
);
} else {
setDetectedVarCount(0);
setEnvVars([{ id: crypto.randomUUID(), key: "", value: "" }]);
}
updateDetectedEnvVars(detected);
} catch {
setDetectedVarCount(0);
setEnvVars([{ id: crypto.randomUUID(), key: "", value: "" }]);
updateDetectedEnvVars([]);
setErrorData({
title: "Could not auto-detect environment variables",
list: ["Add them manually in the connection form."],
@ -227,98 +228,13 @@ export default function StepAttachFlows() {
[
effectiveFlowId,
versions,
connections,
detectEnvVars,
attachedConnectionByFlow,
setErrorData,
initConnectionsForFlow,
updateDetectedEnvVars,
],
);
const commitPendingAttachment = useCallback(() => {
if (pendingAttachment) {
onSelectVersion(
pendingAttachment.flowId,
pendingAttachment.versionId,
pendingAttachment.versionTag,
);
setPendingAttachment(null);
}
}, [pendingAttachment, onSelectVersion]);
const handleAttachConnection = useCallback(() => {
if (!effectiveFlowId) return;
if (connectionTab === "available" && selectedConnections.size > 0) {
commitPendingAttachment();
onAttachConnection((prev) => {
const next = new Map(prev);
next.set(effectiveFlowId, Array.from(selectedConnections));
return next;
});
setRightPanel("versions");
setSelectedConnections(new Set());
}
}, [
effectiveFlowId,
connectionTab,
selectedConnections,
onAttachConnection,
commitPendingAttachment,
]);
const handleCreateConnection = useCallback(() => {
const filteredVars = envVars.filter((v) => v.key.trim());
const environmentVariables: Record<string, string> = {};
const globalVarKeys = new Set<string>();
for (const v of filteredVars) {
const key = v.key.trim();
environmentVariables[key] = v.value;
if (v.globalVar) {
globalVarKeys.add(key);
}
}
const sanitizedId = newConnectionName
.trim()
.toLowerCase()
.replace(/\s+/g, "_")
.replace(/[^a-z0-9_]/g, "");
const newConn = {
id: sanitizedId,
name: newConnectionName.trim(),
variableCount: filteredVars.length,
isNew: true,
environmentVariables,
globalVarKeys,
};
setConnections((prev) => [...prev, newConn]);
setSelectedConnections(
(prev) => new Set([...Array.from(prev), newConn.id]),
);
setConnectionTab("available");
setNewConnectionName("");
setEnvVars([{ id: crypto.randomUUID(), key: "", value: "" }]);
}, [envVars, newConnectionName, setConnections]);
const handleSkipConnection = useCallback(() => {
commitPendingAttachment();
if (effectiveFlowId) {
onAttachConnection((prev) => {
const next = new Map(prev);
next.delete(effectiveFlowId);
return next;
});
}
setRightPanel("versions");
setSelectedConnections(new Set());
}, [effectiveFlowId, onAttachConnection, commitPendingAttachment]);
const handleChangeFlow = useCallback(() => {
setPendingAttachment(null);
setRightPanel("versions");
setSelectedConnections(new Set());
setDetectedVarCount(0);
setEnvVars([{ id: crypto.randomUUID(), key: "", value: "" }]);
}, []);
const handleDetachFlow = useCallback(
(flowId: string) => {
handleRemoveAttachedFlow(flowId);
@ -333,53 +249,13 @@ export default function StepAttachFlows() {
[handleRemoveAttachedFlow, setToolNameByFlow],
);
const handleSelectFlow = useCallback((flowId: string) => {
setSelectedFlowId(flowId);
setRightPanel("versions");
setSelectedConnections(new Set());
setDetectedVarCount(0);
setEnvVars([{ id: crypto.randomUUID(), key: "", value: "" }]);
}, []);
const handleAddEnvVar = useCallback(() => {
setEnvVars((prev) => [
...prev,
{ id: crypto.randomUUID(), key: "", value: "" },
]);
}, []);
const handleEnvVarChange = useCallback(
(id: string, field: "key" | "value", val: string) => {
setEnvVars((prev) =>
prev.map((item) =>
item.id === id ? { ...item, [field]: val, globalVar: false } : item,
),
);
const handleSelectFlow = useCallback(
(flowId: string) => {
setSelectedFlowId(flowId);
setRightPanel("versions");
setSelectedConnections(new Set());
},
[],
);
const handleEnvVarSelectGlobalVar = useCallback(
(id: string, selected: string) => {
setEnvVars((prev) =>
prev.map((item) =>
item.id === id
? {
...item,
key:
selected !== "" &&
(item.key.trim() === "" ||
(item.globalVar && item.key === item.value))
? selected
: item.key,
value: selected,
globalVar: selected !== "",
}
: item,
),
);
},
[],
[setSelectedConnections],
);
return (

View File

@ -0,0 +1,223 @@
import {
type Dispatch,
type SetStateAction,
useCallback,
useMemo,
useState,
} from "react";
import type { ConnectionTab } from "../components/step-attach-flows-connection-panel";
import type { ConnectionItem, EnvVarEntry } from "../types";
interface UseConnectionPanelStateParams {
connections: ConnectionItem[];
setConnections: Dispatch<SetStateAction<ConnectionItem[]>>;
effectiveFlowId: string | null;
attachedConnectionByFlow: Map<string, string[]>;
onAttachConnection: Dispatch<SetStateAction<Map<string, string[]>>>;
commitPendingAttachment: () => void;
resetPendingAttachment: () => void;
setRightPanel: (panel: "versions" | "connections") => void;
}
export function useConnectionPanelState({
connections,
setConnections,
effectiveFlowId,
attachedConnectionByFlow,
onAttachConnection,
commitPendingAttachment,
resetPendingAttachment,
setRightPanel,
}: UseConnectionPanelStateParams) {
const [connectionTab, setConnectionTab] =
useState<ConnectionTab>("available");
const [selectedConnections, setSelectedConnections] = useState<Set<string>>(
new Set(),
);
const [newConnectionName, setNewConnectionName] = useState("");
const [envVars, setEnvVars] = useState<EnvVarEntry[]>(() => [
{ id: crypto.randomUUID(), key: "", value: "" },
]);
const [detectedVarCount, setDetectedVarCount] = useState(0);
const isDuplicateConnectionName = useMemo(() => {
const trimmed = newConnectionName.trim().toLowerCase();
if (!trimmed) return false;
return connections.some((c) => c.name.trim().toLowerCase() === trimmed);
}, [newConnectionName, connections]);
const handleAttachConnection = useCallback(() => {
if (!effectiveFlowId) return;
if (connectionTab === "available" && selectedConnections.size > 0) {
commitPendingAttachment();
onAttachConnection((prev) => {
const next = new Map(prev);
next.set(effectiveFlowId, Array.from(selectedConnections));
return next;
});
setRightPanel("versions");
setSelectedConnections(new Set());
}
}, [
effectiveFlowId,
connectionTab,
selectedConnections,
onAttachConnection,
commitPendingAttachment,
setRightPanel,
]);
const handleCreateConnection = useCallback(() => {
const filteredVars = envVars.filter((v) => v.key.trim());
const environmentVariables: Record<string, string> = {};
const globalVarKeys = new Set<string>();
for (const v of filteredVars) {
const key = v.key.trim();
environmentVariables[key] = v.value;
if (v.globalVar) {
globalVarKeys.add(key);
}
}
const sanitizedId = newConnectionName
.trim()
.toLowerCase()
.replace(/\s+/g, "_")
.replace(/[^a-z0-9_]/g, "");
const newConn: ConnectionItem = {
id: sanitizedId,
connectionId: sanitizedId,
name: newConnectionName.trim(),
variableCount: filteredVars.length,
isNew: true,
environmentVariables,
globalVarKeys,
};
setConnections((prev) => [...prev, newConn]);
setSelectedConnections(
(prev) => new Set([...Array.from(prev), newConn.id]),
);
setConnectionTab("available");
setNewConnectionName("");
setEnvVars([{ id: crypto.randomUUID(), key: "", value: "" }]);
}, [envVars, newConnectionName, setConnections]);
const handleSkipConnection = useCallback(() => {
commitPendingAttachment();
if (effectiveFlowId) {
onAttachConnection((prev) => {
const next = new Map(prev);
next.delete(effectiveFlowId);
return next;
});
}
setRightPanel("versions");
setSelectedConnections(new Set());
}, [
effectiveFlowId,
onAttachConnection,
commitPendingAttachment,
setRightPanel,
]);
const handleChangeFlow = useCallback(() => {
resetPendingAttachment();
setRightPanel("versions");
setSelectedConnections(new Set());
setDetectedVarCount(0);
setEnvVars([{ id: crypto.randomUUID(), key: "", value: "" }]);
}, [resetPendingAttachment, setRightPanel]);
const handleAddEnvVar = useCallback(() => {
setEnvVars((prev) => [
...prev,
{ id: crypto.randomUUID(), key: "", value: "" },
]);
}, []);
const handleEnvVarChange = useCallback(
(id: string, field: "key" | "value", val: string) => {
setEnvVars((prev) =>
prev.map((item) =>
item.id === id ? { ...item, [field]: val, globalVar: false } : item,
),
);
},
[],
);
const handleEnvVarSelectGlobalVar = useCallback(
(id: string, selected: string) => {
setEnvVars((prev) =>
prev.map((item) =>
item.id === id
? {
...item,
key:
selected !== "" &&
(item.key.trim() === "" ||
(item.globalVar && item.key === item.value))
? selected
: item.key,
value: selected,
globalVar: selected !== "",
}
: item,
),
);
},
[],
);
const initConnectionsForFlow = useCallback(
(flowId: string) => {
setSelectedConnections(
new Set(attachedConnectionByFlow.get(flowId) ?? []),
);
if (connections.length === 0) {
setConnectionTab("create");
}
},
[attachedConnectionByFlow, connections.length],
);
const updateDetectedEnvVars = useCallback(
(vars: Array<{ key: string; global_variable_name?: string | null }>) => {
if (vars.length > 0) {
setDetectedVarCount(vars.length);
setEnvVars(
vars.map((v) => ({
id: crypto.randomUUID(),
key: v.key,
value: v.global_variable_name ?? "",
globalVar: Boolean(v.global_variable_name),
})),
);
} else {
setDetectedVarCount(0);
setEnvVars([{ id: crypto.randomUUID(), key: "", value: "" }]);
}
},
[],
);
return {
connectionTab,
setConnectionTab,
selectedConnections,
setSelectedConnections,
newConnectionName,
setNewConnectionName,
envVars,
detectedVarCount,
isDuplicateConnectionName,
handleAttachConnection,
handleCreateConnection,
handleSkipConnection,
handleChangeFlow,
handleAddEnvVar,
handleEnvVarChange,
handleEnvVarSelectGlobalVar,
initConnectionsForFlow,
updateDetectedEnvVars,
};
}

View File

@ -9,7 +9,9 @@ export interface EnvVarEntry {
export interface ConnectionItem {
id: string;
connectionId: string;
name: string;
environment?: string;
variableCount: number;
isNew: boolean;
environmentVariables: Record<string, string>;