mirror of
https://github.com/langflow-ai/langflow.git
synced 2026-07-28 04:17:58 +08:00
feat: validate duplicate tool names at deployment review step
Add early detection of duplicate WXO tool names before deployment, preventing users from hitting a 409 error only at deploy time. - Add `GET /snapshots/check-names` endpoint using SDK's `get_drafts_by_names()` for fast server-side lookup - Add `check_tool_names_exist()` to WXO service adapter - Show inline validation error with alert banner on the review step card when a tool name already exists in the provider - Block deploy button while duplicate names exist - Also detect batch duplicates (same name on multiple flows) - Use `keepPreviousData` to prevent error flicker on re-validation
This commit is contained in:
@ -904,6 +904,46 @@ async def list_deployment_snapshots(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/snapshots/check-names")
|
||||
async def check_snapshot_names(
|
||||
provider_id: DeploymentProviderAccountIdQuery,
|
||||
session: DbSessionReadOnly,
|
||||
current_user: CurrentActiveUser,
|
||||
names: Annotated[list[str], Query(description="Tool names to check for existence.")],
|
||||
):
|
||||
"""Check which tool names already exist in the provider.
|
||||
|
||||
Returns a mapping of each queried name to a boolean indicating existence.
|
||||
Uses the provider SDK's name-based lookup for efficient server-side filtering.
|
||||
"""
|
||||
if not names:
|
||||
return {"existing_names": []}
|
||||
|
||||
provider_account = await get_owned_provider_account_or_404(
|
||||
provider_id=provider_id,
|
||||
user_id=current_user.id,
|
||||
db=session,
|
||||
)
|
||||
deployment_adapter = resolve_deployment_adapter(provider_account.provider_key)
|
||||
with (
|
||||
handle_adapter_errors(mapper=get_deployment_mapper(provider_account.provider_key)),
|
||||
deployment_provider_scope(
|
||||
provider_account.id,
|
||||
),
|
||||
):
|
||||
try:
|
||||
existing = await deployment_adapter.check_tool_names_exist(
|
||||
user_id=current_user.id,
|
||||
names=names,
|
||||
db=session,
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
# If the adapter doesn't support name-check, return empty.
|
||||
existing = []
|
||||
|
||||
return {"existing_names": existing}
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/snapshots/{provider_snapshot_id}",
|
||||
response_model=SnapshotUpdateResponse,
|
||||
|
||||
@ -824,6 +824,23 @@ class WatsonxOrchestrateDeploymentService(BaseDeploymentService):
|
||||
)
|
||||
return snapshots
|
||||
|
||||
async def check_tool_names_exist(
|
||||
self,
|
||||
*,
|
||||
user_id: IdLike,
|
||||
names: list[str],
|
||||
db: AsyncSession,
|
||||
) -> list[str]:
|
||||
"""Return the subset of *names* that already exist as tools in the provider."""
|
||||
if not names:
|
||||
return []
|
||||
clients = await self._get_provider_clients(user_id=user_id, db=db)
|
||||
try:
|
||||
tools = await asyncio.to_thread(clients.tool.get_drafts_by_names, names)
|
||||
except Exception: # noqa: BLE001
|
||||
return []
|
||||
return [str(tool.get("name") or "") for tool in (tools or []) if isinstance(tool, dict) and tool.get("name")]
|
||||
|
||||
async def verify_credentials(
|
||||
self,
|
||||
*,
|
||||
|
||||
40
src/frontend/package-lock.json
generated
40
src/frontend/package-lock.json
generated
@ -216,6 +216,7 @@
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@ -1081,6 +1082,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@chakra-ui/styled-system/-/styled-system-2.9.2.tgz",
|
||||
"integrity": "sha512-To/Z92oHpIE+4nk11uVMWqo2GGRS86coeMmjxtpnErmWRdLcp1WVCVRAvn+ZwpLiNR+reWFr2FFqJRsREuZdAg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@chakra-ui/shared-utils": "2.0.5",
|
||||
"csstype": "^3.1.2",
|
||||
@ -1092,6 +1094,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@chakra-ui/system/-/system-2.6.2.tgz",
|
||||
"integrity": "sha512-EGtpoEjLrUu4W1fHD+a62XR+hzC5YfsWm+6lO0Kybcga3yYEij9beegO0jZgug27V+Rf7vns95VPVP6mFd/DEQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@chakra-ui/color-mode": "2.2.0",
|
||||
"@chakra-ui/object-utils": "2.1.0",
|
||||
@ -1198,6 +1201,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.2.tgz",
|
||||
"integrity": "sha512-jEPmz2nGGDxhRTg3lTpzmIyGKxz3Gp3SJES4b0nAuE5SWQoKdT5GoQ69cwMmFd+wvFUhYirtDTr0/DRHpQAyWg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.23.0",
|
||||
@ -1234,6 +1238,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.4.tgz",
|
||||
"integrity": "sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@marijn/find-cluster-break": "^1.0.0"
|
||||
}
|
||||
@ -1243,6 +1248,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.15.tgz",
|
||||
"integrity": "sha512-aCWjgweIIXLBHh7bY6cACvXuyrZ0xGafjQ2VInjp4RM4gMfscK5uESiNdrH0pE+e1lZr2B4ONGsjchl2KsKZzg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.5.0",
|
||||
"crelt": "^1.0.6",
|
||||
@ -1338,6 +1344,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@ -1361,6 +1368,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@ -6039,6 +6047,7 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@swc/counter": "^0.1.3",
|
||||
"@swc/types": "^0.1.25"
|
||||
@ -6409,6 +6418,7 @@
|
||||
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.10.4",
|
||||
"@babel/runtime": "^7.12.5",
|
||||
@ -7120,6 +7130,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@ -7130,6 +7141,7 @@
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
@ -7145,7 +7157,8 @@
|
||||
"version": "1.15.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/sortablejs/-/sortablejs-1.15.9.tgz",
|
||||
"integrity": "sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/stack-utils": {
|
||||
"version": "2.0.3",
|
||||
@ -7770,6 +7783,7 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@ -8456,6 +8470,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@ -8848,6 +8863,7 @@
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"anymatch": "~3.1.2",
|
||||
"braces": "~3.0.2",
|
||||
@ -9288,6 +9304,7 @@
|
||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
@ -9864,6 +9881,7 @@
|
||||
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
@ -10134,6 +10152,7 @@
|
||||
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"accepts": "^2.0.0",
|
||||
"body-parser": "^2.2.1",
|
||||
@ -11263,6 +11282,7 @@
|
||||
"integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
}
|
||||
@ -11415,6 +11435,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.6"
|
||||
},
|
||||
@ -11973,6 +11994,7 @@
|
||||
"integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jest/core": "30.2.0",
|
||||
"@jest/types": "30.2.0",
|
||||
@ -13561,6 +13583,7 @@
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
|
||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"jiti": "bin/jiti.js"
|
||||
}
|
||||
@ -13610,6 +13633,7 @@
|
||||
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cssstyle": "^4.2.1",
|
||||
"data-urls": "^5.0.0",
|
||||
@ -13649,6 +13673,7 @@
|
||||
"resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz",
|
||||
"integrity": "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 10.16.0"
|
||||
}
|
||||
@ -16144,6 +16169,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@ -16563,6 +16589,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@ -16635,6 +16662,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@ -16665,6 +16693,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.2.tgz",
|
||||
"integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
@ -17326,6 +17355,7 @@
|
||||
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
},
|
||||
@ -17844,7 +17874,8 @@
|
||||
"version": "1.15.7",
|
||||
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.7.tgz",
|
||||
"integrity": "sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
"version": "0.5.7",
|
||||
@ -18023,6 +18054,7 @@
|
||||
"integrity": "sha512-heMfJjOfbHvL+wlCAwFZlSxcakyJ5yQDam6e9k2RRArB1veJhRnsjO6lO1hOXjJYrqxfHA/ldIugbBVlCDqfvQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@storybook/global": "^5.0.0",
|
||||
"@storybook/icons": "^2.0.1",
|
||||
@ -18392,6 +18424,7 @@
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
|
||||
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
"arg": "^5.0.2",
|
||||
@ -18993,6 +19026,7 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@ -19505,6 +19539,7 @@
|
||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@ -20489,6 +20524,7 @@
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
export * from "./use-check-tool-names";
|
||||
export * from "./use-delete-deployment";
|
||||
export * from "./use-get-deployment";
|
||||
export * from "./use-get-deployment-attachments";
|
||||
|
||||
@ -0,0 +1,38 @@
|
||||
import type { useQueryFunctionType } from "@/types/api";
|
||||
import { api } from "../../api";
|
||||
import { getURL } from "../../helpers/constants";
|
||||
import { UseRequestProcessor } from "../../services/request-processor";
|
||||
|
||||
interface CheckToolNamesResponse {
|
||||
existing_names: string[];
|
||||
}
|
||||
|
||||
interface CheckToolNamesParams {
|
||||
providerId: string;
|
||||
names: string[];
|
||||
}
|
||||
|
||||
export const useCheckToolNames: useQueryFunctionType<
|
||||
CheckToolNamesParams,
|
||||
CheckToolNamesResponse
|
||||
> = ({ providerId, names }, options) => {
|
||||
const { query } = UseRequestProcessor();
|
||||
|
||||
const fn = async (): Promise<CheckToolNamesResponse> => {
|
||||
const { data } = await api.get<CheckToolNamesResponse>(
|
||||
`${getURL("DEPLOYMENTS")}/snapshots/check-names`,
|
||||
{
|
||||
params: { provider_id: providerId, names },
|
||||
paramsSerializer: { indexes: null },
|
||||
},
|
||||
);
|
||||
return data;
|
||||
};
|
||||
|
||||
const sortedNames = [...names].sort();
|
||||
return query(
|
||||
["useCheckToolNames", { providerId, names: sortedNames }],
|
||||
fn,
|
||||
options,
|
||||
);
|
||||
};
|
||||
@ -1,12 +1,18 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { keepPreviousData } from "@tanstack/react-query";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import ForwardedIconComponent from "@/components/common/genericIconComponent";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useCheckToolNames } from "@/controllers/API/queries/deployments";
|
||||
import { useGetRefreshFlowsQuery } from "@/controllers/API/queries/flows/use-get-refresh-flows-query";
|
||||
import { useFolderStore } from "@/stores/foldersStore";
|
||||
import { useDeploymentStepper } from "../contexts/deployment-stepper-context";
|
||||
|
||||
function normalizeWxoName(s: string): string {
|
||||
return s.replace(/[\s-]/g, "_").replace(/[^a-zA-Z0-9_]/g, "");
|
||||
}
|
||||
|
||||
function EditableToolName({
|
||||
value,
|
||||
placeholder,
|
||||
@ -98,6 +104,9 @@ export default function StepReview() {
|
||||
setToolNameByFlow,
|
||||
attachedConnectionByFlow,
|
||||
removedFlowIds,
|
||||
selectedInstance,
|
||||
preExistingFlowIds,
|
||||
setHasToolNameErrors,
|
||||
} = useDeploymentStepper();
|
||||
|
||||
const { folderId } = useParams();
|
||||
@ -144,6 +153,69 @@ export default function StepReview() {
|
||||
},
|
||||
);
|
||||
|
||||
// Collect normalized tool names to check against the provider.
|
||||
const toolNamesToCheck = useMemo(() => {
|
||||
const names: string[] = [];
|
||||
for (const item of reviewFlows) {
|
||||
// Skip pre-existing flows in edit mode — those already have tools in provider.
|
||||
if (isEditMode && preExistingFlowIds.has(item.flowId)) continue;
|
||||
const normalized = normalizeWxoName(item.toolName);
|
||||
if (normalized) names.push(normalized);
|
||||
}
|
||||
return names;
|
||||
}, [reviewFlows, isEditMode, preExistingFlowIds]);
|
||||
|
||||
const { data: checkNamesData } = useCheckToolNames(
|
||||
{ providerId: selectedInstance?.id ?? "", names: toolNamesToCheck },
|
||||
{
|
||||
enabled: !!selectedInstance?.id && toolNamesToCheck.length > 0,
|
||||
placeholderData: keepPreviousData,
|
||||
},
|
||||
);
|
||||
|
||||
const existingToolNames = useMemo(() => {
|
||||
if (!checkNamesData?.existing_names) return new Set<string>();
|
||||
return new Set(checkNamesData.existing_names.map((n) => n.toLowerCase()));
|
||||
}, [checkNamesData]);
|
||||
|
||||
const toolNameErrors = useMemo(() => {
|
||||
const errors = new Map<string, string>();
|
||||
const batchNames = new Map<string, string>(); // normalized -> flowId (first seen)
|
||||
|
||||
for (const item of reviewFlows) {
|
||||
const normalized = normalizeWxoName(item.toolName).toLowerCase();
|
||||
if (!normalized) continue;
|
||||
|
||||
// Check batch duplicates (two flows with same tool name in this deployment)
|
||||
const firstFlowId = batchNames.get(normalized);
|
||||
if (firstFlowId) {
|
||||
errors.set(item.flowId, "Duplicate tool name within this deployment");
|
||||
if (!errors.has(firstFlowId)) {
|
||||
errors.set(firstFlowId, "Duplicate tool name within this deployment");
|
||||
}
|
||||
} else {
|
||||
batchNames.set(normalized, item.flowId);
|
||||
}
|
||||
|
||||
// Check against existing provider tools (skip for pre-existing flows in edit mode)
|
||||
if (!errors.has(item.flowId) && existingToolNames.has(normalized)) {
|
||||
if (!(isEditMode && preExistingFlowIds.has(item.flowId))) {
|
||||
errors.set(item.flowId, "Tool name already exists in the provider");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}, [reviewFlows, existingToolNames, isEditMode, preExistingFlowIds]);
|
||||
|
||||
useEffect(() => {
|
||||
setHasToolNameErrors(toolNameErrors.size > 0);
|
||||
}, [toolNameErrors, setHasToolNameErrors]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => setHasToolNameErrors(false);
|
||||
}, [setHasToolNameErrors]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 py-3">
|
||||
<div>
|
||||
@ -226,122 +298,137 @@ export default function StepReview() {
|
||||
{/* Configuration section – scoped per flow */}
|
||||
{reviewFlows.length > 0 && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{reviewFlows.map((item) => (
|
||||
<div
|
||||
key={item.flowId}
|
||||
className="rounded-xl border border-border bg-background p-4"
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<ForwardedIconComponent
|
||||
name="Wrench"
|
||||
className="h-3.5 w-3.5 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<EditableToolName
|
||||
value={toolNameByFlow.get(item.flowId)?.trim() ?? ""}
|
||||
placeholder={item.flowName}
|
||||
onSave={(name) => {
|
||||
setToolNameByFlow((prev) => {
|
||||
const next = new Map(prev);
|
||||
if (name.trim()) {
|
||||
next.set(item.flowId, name.trim());
|
||||
} else {
|
||||
next.delete(item.flowId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{reviewFlows.map((item) => {
|
||||
const toolError = toolNameErrors.get(item.flowId);
|
||||
return (
|
||||
<div
|
||||
key={item.flowId}
|
||||
className={`rounded-xl border bg-background p-4 ${toolError ? "border-destructive/50" : "border-border"}`}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<ForwardedIconComponent
|
||||
name="Wrench"
|
||||
className={`h-3.5 w-3.5 shrink-0 ${toolError ? "text-destructive" : "text-muted-foreground"}`}
|
||||
/>
|
||||
<EditableToolName
|
||||
value={toolNameByFlow.get(item.flowId)?.trim() ?? ""}
|
||||
placeholder={item.flowName}
|
||||
onSave={(name) => {
|
||||
setToolNameByFlow((prev) => {
|
||||
const next = new Map(prev);
|
||||
if (name.trim()) {
|
||||
next.set(item.flowId, name.trim());
|
||||
} else {
|
||||
next.delete(item.flowId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pl-5">
|
||||
<ForwardedIconComponent
|
||||
name="Workflow"
|
||||
className="h-3 w-3 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{item.flowName}
|
||||
</span>
|
||||
<Badge
|
||||
variant="secondaryStatic"
|
||||
size="tag"
|
||||
className="bg-accent-purple-muted text-accent-purple-muted-foreground"
|
||||
>
|
||||
{item.versionLabel}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pl-5">
|
||||
<ForwardedIconComponent
|
||||
name="Workflow"
|
||||
className="h-3 w-3 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{item.flowName}
|
||||
</span>
|
||||
<Badge
|
||||
variant="secondaryStatic"
|
||||
size="tag"
|
||||
className="bg-accent-purple-muted text-accent-purple-muted-foreground"
|
||||
>
|
||||
{item.versionLabel}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{item.connectionDetails.length > 0 &&
|
||||
(() => {
|
||||
const newConns = item.connectionDetails.filter(
|
||||
(c) => c.isNew,
|
||||
);
|
||||
const existingConns = item.connectionDetails.filter(
|
||||
(c) => !c.isNew,
|
||||
);
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{existingConns.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Existing Connections
|
||||
</span>
|
||||
{existingConns.map((conn) => (
|
||||
<span
|
||||
key={conn.name}
|
||||
className="text-xs font-medium text-foreground"
|
||||
>
|
||||
{conn.name}
|
||||
{item.connectionDetails.length > 0 &&
|
||||
(() => {
|
||||
const newConns = item.connectionDetails.filter(
|
||||
(c) => c.isNew,
|
||||
);
|
||||
const existingConns = item.connectionDetails.filter(
|
||||
(c) => !c.isNew,
|
||||
);
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{existingConns.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Existing Connections
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{newConns.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
New Connections
|
||||
</span>
|
||||
{newConns.map((conn) => (
|
||||
<div
|
||||
key={conn.name}
|
||||
className="flex flex-col gap-1.5"
|
||||
>
|
||||
<span className="text-xs font-medium text-foreground">
|
||||
{existingConns.map((conn) => (
|
||||
<span
|
||||
key={conn.name}
|
||||
className="text-xs font-medium text-foreground"
|
||||
>
|
||||
{conn.name}
|
||||
</span>
|
||||
{conn.envVars.length > 0 && (
|
||||
<div className="flex flex-col divide-y divide-border overflow-hidden rounded-md border border-border">
|
||||
{conn.envVars.map(({ key, masked }) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center justify-between bg-muted/40 px-3 py-1.5"
|
||||
>
|
||||
<span className="font-mono text-xs text-foreground">
|
||||
{key}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">
|
||||
=
|
||||
</span>
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{masked}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{newConns.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
New Connections
|
||||
</span>
|
||||
{newConns.map((conn) => (
|
||||
<div
|
||||
key={conn.name}
|
||||
className="flex flex-col gap-1.5"
|
||||
>
|
||||
<span className="text-xs font-medium text-foreground">
|
||||
{conn.name}
|
||||
</span>
|
||||
{conn.envVars.length > 0 && (
|
||||
<div className="flex flex-col divide-y divide-border overflow-hidden rounded-md border border-border">
|
||||
{conn.envVars.map(({ key, masked }) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center justify-between bg-muted/40 px-3 py-1.5"
|
||||
>
|
||||
<span className="font-mono text-xs text-foreground">
|
||||
{key}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">
|
||||
=
|
||||
</span>
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{masked}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{toolError && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-destructive/20 bg-destructive/5 px-3 py-2">
|
||||
<ForwardedIconComponent
|
||||
name="AlertTriangle"
|
||||
className="h-3.5 w-3.5 shrink-0 text-destructive"
|
||||
/>
|
||||
<span className="text-xs text-destructive">
|
||||
{toolError}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@ -100,6 +100,10 @@ interface DeploymentStepperContextType {
|
||||
handleRemoveAttachedFlow: (flowId: string) => void;
|
||||
handleUndoRemoveFlow: (flowId: string) => void;
|
||||
|
||||
// Tool name validation
|
||||
hasToolNameErrors: boolean;
|
||||
setHasToolNameErrors: Dispatch<SetStateAction<boolean>>;
|
||||
|
||||
// Deploy / Update
|
||||
needsProviderAccountCreation: boolean;
|
||||
buildProviderAccountPayload: () => ProviderAccountCreateRequest | null;
|
||||
@ -161,6 +165,8 @@ export function DeploymentStepperProvider({
|
||||
Map<string, string[]>
|
||||
>(initialState?.initialConnectionsByFlow ?? new Map());
|
||||
|
||||
const [hasToolNameErrors, setHasToolNameErrors] = useState(false);
|
||||
|
||||
// Edit mode: track which pre-existing flows the user wants to detach.
|
||||
const [removedFlowIds, setRemovedFlowIds] = useState<Set<string>>(new Set());
|
||||
// Cache removed flow data so undo can restore it.
|
||||
@ -251,6 +257,9 @@ export function DeploymentStepperProvider({
|
||||
// In edit mode, user can proceed without new attachments (may just change desc/LLM).
|
||||
return isEditMode || selectedVersionByFlow.size > 0;
|
||||
}
|
||||
if (logical === 4) {
|
||||
return !hasToolNameErrors;
|
||||
}
|
||||
return true;
|
||||
}, [
|
||||
currentStep,
|
||||
@ -262,6 +271,7 @@ export function DeploymentStepperProvider({
|
||||
selectedLlm,
|
||||
selectedVersionByFlow,
|
||||
isEditMode,
|
||||
hasToolNameErrors,
|
||||
]);
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
@ -546,6 +556,8 @@ export function DeploymentStepperProvider({
|
||||
removedFlowIds,
|
||||
handleRemoveAttachedFlow,
|
||||
handleUndoRemoveFlow,
|
||||
hasToolNameErrors,
|
||||
setHasToolNameErrors,
|
||||
needsProviderAccountCreation,
|
||||
buildProviderAccountPayload,
|
||||
buildDeploymentPayload,
|
||||
@ -577,6 +589,7 @@ export function DeploymentStepperProvider({
|
||||
removedFlowIds,
|
||||
handleRemoveAttachedFlow,
|
||||
handleUndoRemoveFlow,
|
||||
hasToolNameErrors,
|
||||
needsProviderAccountCreation,
|
||||
buildProviderAccountPayload,
|
||||
buildDeploymentPayload,
|
||||
|
||||
Reference in New Issue
Block a user