diff --git a/portals/management-portal/src/context/validationContext.tsx b/portals/management-portal/src/context/validationContext.tsx index d8936ae9d..b99540736 100644 --- a/portals/management-portal/src/context/validationContext.tsx +++ b/portals/management-portal/src/context/validationContext.tsx @@ -10,8 +10,11 @@ import React, { } from "react"; import { useGithubProjectValidation, + useApiUniquenessValidation, type GithubProjectValidationRequest, type GithubProjectValidationResponse, + type ApiNameVersionValidationRequest, + type ApiUniquenessValidationResponse, } from "../hooks/validation"; /** Keep the name “githubprojectvalidation” semantic in value keys & exports */ @@ -27,21 +30,39 @@ type GithubProjectValidationContextValue = { setBranch: (b: string | null) => void; setPath: (p: string | null) => void; - // state + // state (github project validation) loading: boolean; error: string | null; result: GithubProjectValidationResponse | null; - // actions + // actions (github project validation) validate: ( override?: Partial ) => Promise; - // convenience + // convenience (github project validation) isValid: boolean | null; // null when no result yet errors: string[]; // [] when valid or no result - // NEW: allow consumers to clear error/result/loading + // name+version uniqueness validation + nameVersionLoading: boolean; + nameVersionError: string | null; + nameVersionResult: ApiUniquenessValidationResponse | null; + validateNameVersion: ( + payload: ApiNameVersionValidationRequest + ) => Promise; + isNameVersionUnique: boolean | null; + + // identifier uniqueness validation + identifierLoading: boolean; + identifierError: string | null; + identifierResult: ApiUniquenessValidationResponse | null; + validateIdentifier: ( + identifier: string + ) => Promise; + isIdentifierUnique: boolean | null; + + // reset reset: () => void; }; @@ -51,8 +72,20 @@ const Ctx = createContext( type Props = { children: ReactNode }; -export const GithubProjectValidationProvider: React.FC = ({ children }) => { +/** --- Type guards (avoid any / avoid TS2367) --- */ +const isGithubValidationErr = ( + r: GithubProjectValidationResponse +): r is Extract< + GithubProjectValidationResponse, + { isAPIProjectValid: false } +> => r.isAPIProjectValid === false; + +export const GithubProjectValidationProvider: React.FC = ({ + children, +}) => { const { validateGithubApiProject } = useGithubProjectValidation(); + const { validateApiNameVersion, validateApiIdentifier } = + useApiUniquenessValidation(); // inputs const [repoUrl, setRepoUrl] = useState(""); @@ -60,22 +93,39 @@ export const GithubProjectValidationProvider: React.FC = ({ children }) = const [branch, setBranch] = useState(null); const [path, setPath] = useState(null); - // state + // state (github project) const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const [result, setResult] = useState(null); + const [result, setResult] = useState( + null + ); + + // state (name+version) + const [nameVersionLoading, setNameVersionLoading] = useState(false); + const [nameVersionError, setNameVersionError] = useState(null); + const [nameVersionResult, setNameVersionResult] = + useState(null); + + // state (identifier) + const [identifierLoading, setIdentifierLoading] = useState(false); + const [identifierError, setIdentifierError] = useState(null); + const [identifierResult, setIdentifierResult] = + useState(null); // lifecycle guards const mountedRef = useRef(true); const pendingRef = useRef(0); + const begin = () => { pendingRef.current += 1; setLoading(true); }; + const end = () => { pendingRef.current = Math.max(0, pendingRef.current - 1); if (pendingRef.current === 0 && mountedRef.current) setLoading(false); }; + useEffect(() => { mountedRef.current = true; return () => { @@ -101,7 +151,9 @@ export const GithubProjectValidationProvider: React.FC = ({ children }) = setError(null); try { - const res = await validateGithubApiProject(effective, { signal: ac.signal }); + const res = await validateGithubApiProject(effective, { + signal: ac.signal, + }); if (mountedRef.current) setResult(res); return res; } catch (e) { @@ -119,27 +171,96 @@ export const GithubProjectValidationProvider: React.FC = ({ children }) = [repoUrl, provider, branch, path, validateGithubApiProject] ); + const validateNameVersion = useCallback( + async (payload: ApiNameVersionValidationRequest) => { + setNameVersionLoading(true); + setNameVersionError(null); + + try { + const res = await validateApiNameVersion(payload); + if (mountedRef.current) setNameVersionResult(res); + return res; + } catch (e) { + const msg = + e instanceof Error + ? e.message + : "Failed to validate API name & version"; + if (mountedRef.current) { + setNameVersionError(msg); + setNameVersionResult(null); + } + throw e; + } finally { + if (mountedRef.current) setNameVersionLoading(false); + } + }, + [validateApiNameVersion] + ); + + const validateIdentifier = useCallback( + async (identifier: string) => { + setIdentifierLoading(true); + setIdentifierError(null); + + try { + const res = await validateApiIdentifier(identifier); + if (mountedRef.current) setIdentifierResult(res); + return res; + } catch (e) { + const msg = + e instanceof Error ? e.message : "Failed to validate API identifier"; + if (mountedRef.current) { + setIdentifierError(msg); + setIdentifierResult(null); + } + throw e; + } finally { + if (mountedRef.current) setIdentifierLoading(false); + } + }, + [validateApiIdentifier] + ); + + /** ✅ FIX: no impossible comparisons; use discriminant narrowing */ const isValid = useMemo(() => { if (!result) return null; - return ( - !!(result as any).isAPIProjectValid && - !!(result as any).isAPIConfigValid && - !!(result as any).isAPIDefinitionValid - ); + return !isGithubValidationErr(result); }, [result]); + /** ✅ Type-safe errors extraction */ const errors = useMemo(() => { if (!result) return []; - return Array.isArray((result as any).errors) ? (result as any).errors : []; + return isGithubValidationErr(result) ? result.errors : []; }, [result]); - // NEW: reset helper + const isNameVersionUnique = useMemo(() => { + if (!nameVersionResult) return null; + return nameVersionResult.valid; + }, [nameVersionResult]); + + const isIdentifierUnique = useMemo(() => { + if (!identifierResult) return null; + return identifierResult.valid; + }, [identifierResult]); + const reset = useCallback(() => { pendingRef.current = 0; if (!mountedRef.current) return; + + // github project setLoading(false); setError(null); setResult(null); + + // name+version + setNameVersionLoading(false); + setNameVersionError(null); + setNameVersionResult(null); + + // identifier + setIdentifierLoading(false); + setIdentifierError(null); + setIdentifierResult(null); }, []); const value = useMemo( @@ -153,16 +274,34 @@ export const GithubProjectValidationProvider: React.FC = ({ children }) = setProvider, setBranch, setPath, - // state + + // github project state loading, error, result, - // actions + + // github project actions validate, - // convenience + + // github project convenience isValid, errors, - // new + + // name+version uniqueness + nameVersionLoading, + nameVersionError, + nameVersionResult, + validateNameVersion, + isNameVersionUnique, + + // identifier uniqueness + identifierLoading, + identifierError, + identifierResult, + validateIdentifier, + isIdentifierUnique, + + // reset reset, }), [ @@ -176,6 +315,16 @@ export const GithubProjectValidationProvider: React.FC = ({ children }) = validate, isValid, errors, + nameVersionLoading, + nameVersionError, + nameVersionResult, + validateNameVersion, + isNameVersionUnique, + identifierLoading, + identifierError, + identifierResult, + validateIdentifier, + isIdentifierUnique, reset, ] ); @@ -185,9 +334,10 @@ export const GithubProjectValidationProvider: React.FC = ({ children }) = export const useGithubProjectValidationContext = () => { const ctx = useContext(Ctx); - if (!ctx) + if (!ctx) { throw new Error( "useGithubProjectValidationContext must be used within GithubProjectValidationProvider" ); + } return ctx; }; diff --git a/portals/management-portal/src/hooks/apis.ts b/portals/management-portal/src/hooks/apis.ts index c60b76666..4937c170d 100644 --- a/portals/management-portal/src/hooks/apis.ts +++ b/portals/management-portal/src/hooks/apis.ts @@ -75,6 +75,7 @@ export type ImportOpenApiRequest = { context: string; version: string; projectId: string; + displayName?: string; target?: string; description?: string; backendServices?: ApiBackendService[]; diff --git a/portals/management-portal/src/hooks/validation.ts b/portals/management-portal/src/hooks/validation.ts index 0ed052595..6015f5ba7 100644 --- a/portals/management-portal/src/hooks/validation.ts +++ b/portals/management-portal/src/hooks/validation.ts @@ -30,7 +30,7 @@ export type GithubProjectValidationResponse = | GithubProjectValidationOK | GithubProjectValidationErr; - export type OpenApiValidationOK = { +export type OpenApiValidationOK = { isAPIDefinitionValid: true; api: Record; }; @@ -44,6 +44,23 @@ export type OpenApiValidationResponse = | OpenApiValidationOK | OpenApiValidationErr; +/** NEW: Uniqueness validation (name+version / identifier) */ + +export type ApiValidateError = { + code: string; + message: string; +}; + +export type ApiUniquenessValidationResponse = { + valid: boolean; + error: ApiValidateError | null; +}; + +export type ApiNameVersionValidationRequest = { + name: string; + version: string; +}; + /** ----- Helpers ----- */ const parseError = async (res: Response) => { @@ -99,8 +116,7 @@ export const useGithubProjectValidation = () => { ); } - const data = (await res.json()) as GithubProjectValidationResponse; - return data; + return (await res.json()) as GithubProjectValidationResponse; }, [] ); @@ -121,9 +137,7 @@ export const useOpenApiValidation = () => { const res = await fetch(`${baseUrl}/api/v1/validate/open-api`, { method: "POST", - headers: { - Authorization: `Bearer ${token}`, - }, + headers: { Authorization: `Bearer ${token}` }, body: formData, signal: opts?.signal, }); @@ -134,8 +148,7 @@ export const useOpenApiValidation = () => { ); } - const data = (await res.json()) as OpenApiValidationResponse; - return data; + return (await res.json()) as OpenApiValidationResponse; }, [] ); @@ -152,9 +165,7 @@ export const useOpenApiValidation = () => { const res = await fetch(`${baseUrl}/api/v1/validate/open-api`, { method: "POST", - headers: { - Authorization: `Bearer ${token}`, - }, + headers: { Authorization: `Bearer ${token}` }, body: formData, signal: opts?.signal, }); @@ -165,11 +176,66 @@ export const useOpenApiValidation = () => { ); } - const data = (await res.json()) as OpenApiValidationResponse; - return data; + return (await res.json()) as OpenApiValidationResponse; }, [] ); return { validateOpenApiUrl, validateOpenApiFile }; }; + +/** NEW: API uniqueness validation hook */ +export const useApiUniquenessValidation = () => { + /** GET: /api/v1/apis/validate?name=...&version=... */ + const validateApiNameVersion = useCallback( + async ( + payload: ApiNameVersionValidationRequest, + opts?: { signal?: AbortSignal } + ): Promise => { + const qs = new URLSearchParams({ + name: payload.name, + version: payload.version, + }).toString(); + + const res = await authedFetch(`/api/v1/apis/validate?${qs}`, { + method: "GET", + signal: opts?.signal, + }); + + if (!res.ok) { + throw new Error( + `Failed to validate API name & version: ${await parseError(res)}` + ); + } + + return (await res.json()) as ApiUniquenessValidationResponse; + }, + [] + ); + + /** GET: /api/v1/apis/validate?identifier=... */ + const validateApiIdentifier = useCallback( + async ( + identifier: string, + opts?: { signal?: AbortSignal } + ): Promise => { + const qs = new URLSearchParams({ identifier }).toString(); + + const res = await authedFetch(`/api/v1/apis/validate?${qs}`, { + method: "GET", + signal: opts?.signal, + }); + + if (!res.ok) { + throw new Error( + `Failed to validate API identifier: ${await parseError(res)}` + ); + } + + return (await res.json()) as ApiUniquenessValidationResponse; + }, + [] + ); + + return { validateApiNameVersion, validateApiIdentifier }; +}; diff --git a/portals/management-portal/src/pages/apis/ApiOverview.tsx b/portals/management-portal/src/pages/apis/ApiOverview.tsx index 56c7fd368..8316c9664 100644 --- a/portals/management-portal/src/pages/apis/ApiOverview.tsx +++ b/portals/management-portal/src/pages/apis/ApiOverview.tsx @@ -51,23 +51,29 @@ const GatewayListItem: React.FC = ({ gateway, api }) => { const isDeployed = gateway.isDeployed === true; const vhost = gateway.vhost || ""; - + // Construct URLs - const httpUrl = vhost && api ? `http://${vhost}:8080${api.context}/${api.version}` : null; - const httpsUrl = vhost && api ? `https://${vhost}:5443${api.context}/${api.version}` : null; - + const httpUrl = + vhost && api ? `http://${vhost}:8080${api.context}/${api.version}` : null; + const httpsUrl = + vhost && api ? `https://${vhost}:5443${api.context}/${api.version}` : null; + // Get upstream URL (first default backend endpoint) const upstreamUrl = api?.backendServices?.find((s) => s.isDefault)?.endpoints?.[0]?.url ?? api?.backendServices?.[0]?.endpoints?.[0]?.url ?? ""; - const handleCopyUrl = (url: string) => { - navigator.clipboard?.writeText(url).then(() => { +const handleCopyUrl = (url: string) => { + navigator.clipboard + ?.writeText?.(url) + ?.then(() => { setCopiedUrl(url); setTimeout(() => setCopiedUrl(null), 2000); - }).catch(() => {}); - }; + }) + ?.catch(() => {}); +}; + return ( = ({ gateway, api }) => { gap: 2, cursor: "pointer", "&:hover": { - bgcolor: (t) => t.palette.mode === "dark" ? "rgba(255,255,255,0.03)" : "rgba(0,0,0,0.02)", + bgcolor: (t) => + t.palette.mode === "dark" + ? "rgba(255,255,255,0.03)" + : "rgba(0,0,0,0.02)", }, }} onClick={() => setExpanded(!expanded)} @@ -100,12 +109,22 @@ const GatewayListItem: React.FC = ({ gateway, api }) => { variant={isDeployed ? "filled" : "outlined"} sx={{ minWidth: 85 }} /> - - - + + + Gateway: - + {gateway.name} @@ -141,7 +160,9 @@ const GatewayListItem: React.FC = ({ gateway, api }) => { {httpsUrl} - + { @@ -169,12 +190,30 @@ const GatewayListItem: React.FC = ({ gateway, api }) => { - t.palette.mode === "dark" ? "rgba(255,255,255,0.02)" : "rgba(0,0,0,0.01)" }}> + + t.palette.mode === "dark" + ? "rgba(255,255,255,0.02)" + : "rgba(0,0,0,0.01)", + }} + > {/* Main URLs header + HTTP/HTTPS rows (prefix label + url + copy) */} {(httpUrl || httpsUrl) && ( - + Main URLs @@ -186,12 +225,22 @@ const GatewayListItem: React.FC = ({ gateway, api }) => { alignItems: "center", gap: 1, p: 1.25, - bgcolor: (t) => (t.palette.mode === "dark" ? "rgba(255,255,255,0.03)" : "#F9FAFB"), + bgcolor: (t) => + t.palette.mode === "dark" + ? "rgba(255,255,255,0.03)" + : "#F9FAFB", borderRadius: 1, border: (t) => `1px solid ${t.palette.divider}`, }} > - + HTTP URL @@ -211,8 +260,15 @@ const GatewayListItem: React.FC = ({ gateway, api }) => { - - handleCopyUrl(httpUrl)}> + + handleCopyUrl(httpUrl)} + > @@ -228,12 +284,22 @@ const GatewayListItem: React.FC = ({ gateway, api }) => { alignItems: "center", gap: 1, p: 1.25, - bgcolor: (t) => (t.palette.mode === "dark" ? "rgba(255,255,255,0.03)" : "#F9FAFB"), + bgcolor: (t) => + t.palette.mode === "dark" + ? "rgba(255,255,255,0.03)" + : "#F9FAFB", borderRadius: 1, border: (t) => `1px solid ${t.palette.divider}`, }} > - + HTTPS URL @@ -253,8 +319,15 @@ const GatewayListItem: React.FC = ({ gateway, api }) => { - - handleCopyUrl(httpsUrl)}> + + handleCopyUrl(httpsUrl)} + > @@ -267,10 +340,20 @@ const GatewayListItem: React.FC = ({ gateway, api }) => { {upstreamUrl && ( <> - t.palette.divider }} /> + t.palette.divider }} + /> - + Upstream URL = ({ gateway, api }) => { alignItems: "center", gap: 1, p: 1.5, - bgcolor: (t) => t.palette.mode === "dark" ? "rgba(255,255,255,0.05)" : "#F9FAFB", + bgcolor: (t) => + t.palette.mode === "dark" + ? "rgba(255,255,255,0.05)" + : "#F9FAFB", borderRadius: 1, border: (t) => `1px solid ${t.palette.divider}`, }} @@ -299,8 +385,17 @@ const GatewayListItem: React.FC = ({ gateway, api }) => { {upstreamUrl} - - handleCopyUrl(upstreamUrl)}> + + handleCopyUrl(upstreamUrl)} + > @@ -329,20 +424,23 @@ const ApiOverviewContent: React.FC = () => { }>(); const [searchParams] = useSearchParams(); const navigate = useNavigate(); - const { apis, fetchApiById, fetchGatewaysForApi, loading, selectApi } = useApisContext(); + const { apis, fetchApiById, fetchGatewaysForApi, loading, selectApi } = + useApisContext(); const [apiId, setApiId] = React.useState( searchParams.get("apiId") ?? legacyApiId ?? null ); const [api, setApi] = React.useState(null); - const [associatedGateways, setAssociatedGateways] = React.useState([]); + const [associatedGateways, setAssociatedGateways] = React.useState< + ApiGatewaySummary[] + >([]); const [gatewaysLoading, setGatewaysLoading] = React.useState(false); const [detailsLoading, setDetailsLoading] = React.useState(false); const [error, setError] = React.useState(null); const sortedGateways = React.useMemo(() => { - return [...associatedGateways].sort((a, b) => - +(b.isDeployed === true) - +(a.isDeployed === true) + return [...associatedGateways].sort( + (a, b) => +(b.isDeployed === true) - +(a.isDeployed === true) ); }, [associatedGateways]); @@ -470,6 +568,12 @@ const ApiOverviewContent: React.FC = () => { return `${day} day${day > 1 ? "s" : ""} ago`; }; + const initials = (name: string) => { + const letters = name.replace(/[^A-Za-z]/g, ""); + if (!letters) return "API"; + return (letters[0] + (letters[1] || "")).toUpperCase(); + }; + const ProtocolBadge = ({ label }: { label: string }) => ( { } }, [navigate, orgHandle, projectHandle]); - if (loading || detailsLoading) { return ( @@ -572,7 +675,7 @@ const ApiOverviewContent: React.FC = () => { lineHeight: 1, }} > - {api.displayName || api.name} + {initials(api.name)} @@ -708,7 +811,11 @@ const ApiOverviewContent: React.FC = () => { p: 2.25, }} > - + { {gatewaysLoading ? ( - + ) : associatedGateways.length === 0 ? ( @@ -892,8 +1004,6 @@ const ApiOverviewContent: React.FC = () => { ); }; -const ApiOverview: React.FC = () => ( - -); +const ApiOverview: React.FC = () => ; export default ApiOverview; diff --git a/portals/management-portal/src/pages/apis/CreationFlows/ContractCreationFlows/GithubCreationFlow.tsx b/portals/management-portal/src/pages/apis/CreationFlows/ContractCreationFlows/GithubCreationFlow.tsx index 224baba07..82807cd39 100644 --- a/portals/management-portal/src/pages/apis/CreationFlows/ContractCreationFlows/GithubCreationFlow.tsx +++ b/portals/management-portal/src/pages/apis/CreationFlows/ContractCreationFlows/GithubCreationFlow.tsx @@ -19,10 +19,11 @@ import Refresh from "../../../../components/src/Icons/generated/Refresh"; import { IconButton } from "../../../../components/src/components/IconButton"; import Edit from "../../../../components/src/Icons/generated/Edit"; import CreationMetaData from "../CreationMetaData"; -import { isValidMajorMinorVersion, formatVersionToMajorMinor } from "../../../../helpers/openApiHelpers"; +import { + isValidMajorMinorVersion, + formatVersionToMajorMinor, +} from "../../../../helpers/openApiHelpers"; import type { ApiSummary } from "../../../../hooks/apis"; - -// Contexts import { useGithubAPICreationContext } from "../../../../context/GithubAPICreationContext"; import { useCreateComponentBuildpackContext } from "../../../../context/CreateComponentBuildpackContext"; import { useGithubProjectValidationContext } from "../../../../context/validationContext"; @@ -31,18 +32,16 @@ import { ApiOperationsList } from "../../../../components/src/components/Common/ import { useGithubAPICreation } from "../../../../hooks/GithubAPICreation"; import { useNotifications } from "../../../../context/NotificationContext"; -/* ---------- Types ---------- */ type Props = { open: boolean; onClose: () => void; - selectedProjectId?: string; // must be provided to enable Create + selectedProjectId?: string; refreshApis: (projectId?: string) => Promise; }; type BranchOption = { label: string; value: string }; type Step = "form" | "details"; -/* ---------- Utils ---------- */ const isLikelyGithubRepo = (url: string) => /^https:\/\/github\.com\/[^\/\s]+\/[^\/\s#]+$/i.test(url.trim()); @@ -51,6 +50,25 @@ const spin = keyframes` 100% { transform: rotate(360deg); } `; +const slugify = (val: string) => + (val || "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .trim(); + +const majorFromVersion = (v: string) => { + const m = (v || "").trim().match(/\d+/); + return m?.[0] ?? ""; +}; + +const buildIdentifierFromNameAndVersion = (name: string, version: string) => { + const base = slugify(name); + const major = majorFromVersion(version); + return major ? `${base}-v${major}` : base; +}; + const GithubCreationFlow: React.FC = ({ open, onClose, @@ -78,11 +96,8 @@ const GithubCreationFlow: React.FC = ({ reset: resetValidation, } = useGithubProjectValidationContext(); - // 👇 We will read meta (name, context, version, target, description) for the POST const { contractMeta, setContractMeta } = useCreateComponentBuildpackContext(); - - // 👇 POST /api/v1/import/api-project const { importApiProject } = useGithubAPICreation(); const { showNotification } = useNotifications(); @@ -92,6 +107,7 @@ const GithubCreationFlow: React.FC = ({ const [dirError, setDirError] = React.useState(null); const [isDirValid, setIsDirValid] = React.useState(false); + const [metaHasErrors, setMetaHasErrors] = React.useState(false); const [validatedOps, setValidatedOps] = React.useState< Array<{ @@ -101,11 +117,9 @@ const GithubCreationFlow: React.FC = ({ }> >([]); - // Create flow state const [creating, setCreating] = React.useState(false); const [createError, setCreateError] = React.useState(null); - // Reset when closed React.useEffect(() => { if (!open) { setApiDir("/"); @@ -117,6 +131,7 @@ const GithubCreationFlow: React.FC = ({ resetValidation?.(); setCreating(false); setCreateError(null); + setMetaHasErrors(false); } }, [open, resetValidation]); @@ -124,7 +139,6 @@ const GithubCreationFlow: React.FC = ({ const showInitial = (repoUrl ?? "").trim().length === 0; - // options for branches const branchOptions: BranchOption[] = React.useMemo( () => branches.map((b) => ({ label: b.name, value: b.name })), [branches] @@ -136,7 +150,6 @@ const GithubCreationFlow: React.FC = ({ [selectedBranch] ); - // Debounced fetch-branches on repoUrl change React.useEffect(() => { if (!repoUrl || !isLikelyGithubRepo(repoUrl)) return; const t = setTimeout(() => { @@ -146,7 +159,6 @@ const GithubCreationFlow: React.FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [repoUrl]); - // Auto-select default branch (or first) once branches are available React.useEffect(() => { if (!branches.length || selectedBranch) return; const def = @@ -155,7 +167,6 @@ const GithubCreationFlow: React.FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [branches, selectedBranch]); - // Fetch content when branch changes and clear selections React.useEffect(() => { if (!selectedBranch) return; setApiDir("/"); @@ -202,7 +213,6 @@ const GithubCreationFlow: React.FC = ({ setSelectedBranch(opt ? opt.value : null); }; - // ----- Local directory validation for config.yaml presence ----- const normalizePath = (p: string) => p.replace(/^\/+/, "").replace(/\/+$/, ""); @@ -249,7 +259,9 @@ const GithubCreationFlow: React.FC = ({ } } setIsDirValid(found); - setDirError(found ? null : 'Selected directory must contain a "config.yaml".'); + setDirError( + found ? null : 'Selected directory must contain a "config.yaml".' + ); return; } @@ -264,16 +276,8 @@ const GithubCreationFlow: React.FC = ({ setDirError(ok ? null : 'Selected directory must contain a "config.yaml".'); }, [apiDir, content, findNodeByPath, nodeHasConfigYaml]); - // ----- Validate on Next, prefill meta, move to details ----- const onNext = async () => { - if ( - !repoUrl.trim() || - !selectedBranch || - !apiDir || - !isDirValid - ) { - return; - } + if (!repoUrl.trim() || !selectedBranch || !apiDir || !isDirValid) return; try { const path = apiDir === "/" ? "/" : normalizePath(apiDir); @@ -285,18 +289,33 @@ const GithubCreationFlow: React.FC = ({ }); const api = (res as any)?.api; + if (api) { const target = api["backend-services"]?.[0]?.endpoints?.[0]?.url?.trim() || ""; - // Prefill Meta - setContractMeta((prev: any) => ({ - ...prev, - name: api.name || prev?.name || "", - context: api.context || prev?.context || "", - version: formatVersionToMajorMinor(api.version ?? prev?.version ?? "1.0.0"), - description: api.description || prev?.description || "", - target: target || prev?.target || "", - })); + + const displayName = (api.name || "").trim(); + const version = formatVersionToMajorMinor(api.version ?? "1.0.0"); + const identifier = buildIdentifierFromNameAndVersion( + displayName, + version + ); + + setContractMeta((prev: any) => { + const base = prev || {}; + return { + ...base, + displayName: displayName || base.displayName || "", + name: identifier || base.name || "", + identifier, + identifierEdited: false, + context: api.context || base.context || "", + version: version || base.version || "1.0.0", + description: api.description || base.description || "", + target: target || base.target || "", + }; + }); + setValidatedOps(Array.isArray(api.operations) ? api.operations : []); } else { setValidatedOps([]); @@ -309,19 +328,18 @@ const GithubCreationFlow: React.FC = ({ } }; - // ----- Create: POST /api/v1/import/api-project ----- const onCreate = async () => { setCreateError(null); - // Guard required fields - const name = (contractMeta?.name || "").trim(); + const displayName = (contractMeta?.displayName || "").trim(); + const identifier = (contractMeta?.identifier || "").trim(); const context = (contractMeta?.context || "").trim(); const version = (contractMeta?.version || "").trim(); const description = (contractMeta?.description || "").trim() || undefined; const target = (contractMeta?.target || "").trim(); - if (!name || !context || !version) { - setCreateError("Please complete Name, Context, and Version."); + if (!displayName || !identifier || !context || !version) { + setCreateError("Please complete Name, Identifier, Context, and Version."); return; } if (!repoUrl?.trim() || !selectedBranch) { @@ -339,15 +357,14 @@ const GithubCreationFlow: React.FC = ({ return; } - // build payload const payload = { repoUrl: repoUrl.trim(), provider: "github" as const, branch: selectedBranch, - path: apiDir === "/" ? "/" : normalizePath(apiDir), // e.g., "/" or "apis/test-api" + path: apiDir === "/" ? "/" : normalizePath(apiDir), api: { - name, - displayName: name, // or customize if you prefer a separate display name + name: identifier, + displayName, description, context: context.startsWith("/") ? context : `/${context}`, version, @@ -373,7 +390,10 @@ const GithubCreationFlow: React.FC = ({ console.warn("Failed to refresh API list after import:", rErr); } try { - showNotification(`API "${name}" created successfully!`, "success"); + showNotification( + `API "${displayName}" created successfully!`, + "success" + ); } catch (nErr) { console.warn("Failed to show notification:", nErr); } @@ -393,13 +413,13 @@ const GithubCreationFlow: React.FC = ({ !!repoUrl?.trim() && !!selectedBranch && !!isDirValid && - !!(contractMeta?.name || "").trim() && + !!(contractMeta?.displayName || "").trim() && + !!(contractMeta?.identifier || "").trim() && !!(contractMeta?.context || "").trim() && isValidMajorMinorVersion((contractMeta?.version || "").trim()); return ( - {/* ------------ Initial card ------------ */} {showInitial && step === "form" && ( @@ -413,7 +433,6 @@ const GithubCreationFlow: React.FC = ({ testId="" size="medium" /> - = ({ )} - {/* ------------ Form (URL/Branch/Dir) ------------ */} {!showInitial && step === "form" && ( - {/* Row 1: URL | Branch */} = ({ /> - {/* Row 2: API directory | Edit */} @@ -546,6 +562,7 @@ const GithubCreationFlow: React.FC = ({ + {!!dirError && ( {dirError} @@ -553,7 +570,6 @@ const GithubCreationFlow: React.FC = ({ )} - {/* Actions row */} + {!!validateError && ( {validateError} @@ -595,10 +612,8 @@ const GithubCreationFlow: React.FC = ({ )} - {/* ------------ Details ------------ */} {step === "details" && ( - {/* Validation banner */} {validationResult && validationResult.isAPIProjectValid === false && ( @@ -621,40 +636,39 @@ const GithubCreationFlow: React.FC = ({ )} - - - + setMetaHasErrors(hasError)} + /> - {!!createError && ( - - {createError} - - )} - + {!!createError && ( + + {createError} + + )} - - - - - - + + + + @@ -666,7 +680,6 @@ const GithubCreationFlow: React.FC = ({ )} - {/* Directory picker modal */} + (val || "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .trim(); + +const majorFromVersion = (v: string) => { + const m = (v || "").trim().match(/\d+/); + return m?.[0] ?? ""; +}; + +const buildIdentifierFromNameAndVersion = (name: string, version: string) => { + const base = slugify(name); + const major = majorFromVersion(version); + return major ? `${base}-v${major}` : base; +}; + type Props = { open: boolean; selectedProjectId: string; @@ -33,8 +51,6 @@ type Props = { type Step = "url" | "details"; -/* ---------- component ---------- */ - const URLCreationFlow: React.FC = ({ open, selectedProjectId, @@ -54,6 +70,7 @@ const URLCreationFlow: React.FC = ({ useCreateComponentBuildpackContext(); const { validateOpenApiUrl } = useOpenApiValidation(); const abortControllerRef = React.useRef(null); + const [metaHasErrors, setMetaHasErrors] = React.useState(false); React.useEffect(() => { return () => { @@ -69,6 +86,7 @@ const URLCreationFlow: React.FC = ({ setValidationResult(null); setError(null); setValidating(false); + setMetaHasErrors(false); } }, [open, resetContractMeta]); @@ -79,13 +97,23 @@ const URLCreationFlow: React.FC = ({ const description = api?.description || ""; const targetUrl = firstServerUrl(api); - setContractMeta((prev: any) => ({ - ...prev, - name: title || prev?.name || "Sample API", + const identifier = buildIdentifierFromNameAndVersion(title, version); + + const nextMeta = { + name: title || "Sample API", + displayName: title || "Sample API", version, description, context: deriveContext(api), - target: prev?.target || targetUrl || "", + target: targetUrl || "", + identifier, + identifierEdited: false, + }; + + setContractMeta((prev: any) => ({ + ...prev, + ...nextMeta, + target: prev?.target || nextMeta.target || "", })); }, [setContractMeta] @@ -106,6 +134,7 @@ const URLCreationFlow: React.FC = ({ const result = await validateOpenApiUrl(specUrl.trim(), { signal: abortController.signal, }); + setValidationResult(result); if (result.isAPIDefinitionValid) { @@ -143,16 +172,29 @@ const URLCreationFlow: React.FC = ({ }, [validationResult]); const onCreate = async () => { - const name = (contractMeta?.name || "").trim(); + const displayName = ( + contractMeta?.displayName || + contractMeta?.name || + "" + ).trim(); + const context = (contractMeta?.context || "").trim(); const version = (contractMeta?.version || "").trim(); const description = (contractMeta?.description || "").trim() || undefined; const target = (contractMeta?.target || "").trim(); - if (!name || !context || !version) { + const identifier = + (contractMeta as any)?.identifier?.trim() || + buildIdentifierFromNameAndVersion(displayName, version); + + if (!displayName || !context || !version) { setError("Please complete all required fields."); return; } + if (!identifier) { + setError("Identifier is required."); + return; + } if (target) { try { if (/^https?:\/\//i.test(target)) new URL(target); @@ -161,7 +203,6 @@ const URLCreationFlow: React.FC = ({ return; } } - if (!validationResult?.isAPIDefinitionValid) { setError("Please fetch and validate the OpenAPI definition first."); return; @@ -170,7 +211,7 @@ const URLCreationFlow: React.FC = ({ setCreating(true); setError(null); - const serviceName = defaultServiceName(name); + const serviceName = defaultServiceName(displayName); const backendServices = target ? [ { @@ -182,19 +223,22 @@ const URLCreationFlow: React.FC = ({ ] : []; + const payload: ImportOpenApiRequest = { + api: { + name: identifier, + displayName, + context, + version, + projectId: selectedProjectId, + target, + description, + backendServices, + }, + url: specUrl.trim(), + }; + try { - await importOpenApi({ - api: { - name, - context, - version, - projectId: selectedProjectId, - target, - description, - backendServices, - }, - url: specUrl.trim(), - }); + await importOpenApi(payload); } catch (e: any) { setError(e?.message || "Failed to create API"); setCreating(false); @@ -298,8 +342,12 @@ const URLCreationFlow: React.FC = ({ {step === "details" && ( - {/* */} - + setMetaHasErrors(hasError)} + /> + = ({ variant="outlined" onClick={() => setStep("url")} sx={{ textTransform: "none" }} + disabled={creating} > Back + @@ -290,7 +366,8 @@ const UploadCreationFlow: React.FC = ({ open, selectedProjectId, importOp setValidationResult(null); setFileName(""); setError(null); - if (fileInputRef.current) fileInputRef.current.value = ""; + if (fileInputRef.current) + fileInputRef.current.value = ""; setFileKey((k) => k + 1); }} > @@ -308,7 +385,11 @@ const UploadCreationFlow: React.FC = ({ open, selectedProjectId, importOp - - - - - {error && ( - - {error} - - )} - + + setMetaHasErrors(hasError) + } + /> + + + + + + + {error && ( + + {error} + + )} - + diff --git a/portals/management-portal/src/pages/apis/CreationFlows/CreationMetaData.tsx b/portals/management-portal/src/pages/apis/CreationFlows/CreationMetaData.tsx index 0cca74319..80f4527bd 100644 --- a/portals/management-portal/src/pages/apis/CreationFlows/CreationMetaData.tsx +++ b/portals/management-portal/src/pages/apis/CreationFlows/CreationMetaData.tsx @@ -10,14 +10,26 @@ import VersionInput from "../../../common/VersionInput"; import { Button } from "../../../components/src/components/Button"; import Edit from "../../../components/src/Icons/generated/Edit"; +import { useGithubProjectValidationContext } from "../../../context/validationContext"; + const slugify = (val: string) => - val + (val || "") .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, ""); + .replace(/^-+|-+$/g, "") + .trim(); + +const majorFromVersion = (v: string) => { + const m = (v || "").trim().match(/\d+/); + return m?.[0] ?? ""; +}; -const buildIdentifierFromName = (name: string) => slugify(name); +const buildIdentifierFromNameAndVersion = (name: string, version: string) => { + const base = slugify(name); + const major = majorFromVersion(version); + return major ? `${base}-v${major}` : base; +}; type Scope = "contract" | "endpoint"; @@ -27,16 +39,27 @@ type Props = { onChange?: (next: ProxyMetadata) => void; readOnlyFields?: Partial>; title?: string; + onValidationChange?: (state: { + nameVersionError: string | null; + identifierError: string | null; + hasError: boolean; + }) => void; }; +type NameVersionOverride = { name?: string; version?: string; force?: boolean }; +type IdentifierOverride = { identifier?: string; force?: boolean }; + const CreationMetaData: React.FC = ({ scope, value, onChange, readOnlyFields, title, + onValidationChange, }) => { const ctx = useCreateComponentBuildpackContext(); + const { validateNameVersion, validateIdentifier } = + useGithubProjectValidationContext(); const meta: ProxyMetadata & { identifier?: string; @@ -60,27 +83,237 @@ const CreationMetaData: React.FC = ({ const change = (patch: Partial) => setMeta({ ...meta, ...patch }); + const [isIdentifierEditing, setIsIdentifierEditing] = React.useState( !!meta.identifierEdited ); + const [nameVersionError, setNameVersionError] = React.useState( + null + ); + const [identifierError, setIdentifierError] = React.useState( + null + ); + + const [nameVersionValidating, setNameVersionValidating] = + React.useState(false); + const [identifierValidating, setIdentifierValidating] = React.useState(false); + + const lastCheckedNameVersionRef = React.useRef<{ + name: string; + version: string; + } | null>(null); + const lastCheckedIdentifierRef = React.useRef(null); + const nameVersionTimerRef = React.useRef(null); + const identifierTimerRef = React.useRef(null); + + const didInitValidateRef = React.useRef(false); + const debounceMs = 2000; + + const onValidationChangeRef = + React.useRef(onValidationChange); React.useEffect(() => { - if ( - meta.name && - !meta.identifier && - !meta.identifierEdited && - !isIdentifierEditing - ) { - change({ - identifier: buildIdentifierFromName(meta.name), + onValidationChangeRef.current = onValidationChange; + }, [onValidationChange]); + + const handleContextChange = (v: string) => { + change({ context: v, contextEdited: true }); + }; + + const handleIdentifierChange = (v: string) => { + change({ + identifier: slugify(v), + identifierEdited: true, + }); + }; + + const handleIdentifierEditClick = () => { + setIsIdentifierEditing(true); + change({ identifierEdited: true }); + }; + + const identifierDisplayValue = React.useMemo(() => { + return (meta.identifier ?? "").trim(); + }, [meta.identifier]); + + const identifierToValidate = React.useMemo(() => { + return identifierDisplayValue.trim(); + }, [identifierDisplayValue]); + + const identifierDisabled = + !!readOnlyFields?.["identifier"] || !isIdentifierEditing; + + const runNameVersionValidation = React.useCallback( + async (override?: NameVersionOverride) => { + const effectiveName = ( + override?.name ?? + (meta.displayName || meta.name || "") + ).trim(); + + const effectiveVersion = ( + override?.version ?? + (meta.version || "") + ).trim(); + + if (!effectiveName || !effectiveVersion) return; + + const last = lastCheckedNameVersionRef.current; + if ( + !override?.force && + last && + last.name === effectiveName && + last.version === effectiveVersion + ) { + return; + } + + try { + setNameVersionValidating(true); + + const res = await validateNameVersion({ + name: effectiveName, + version: effectiveVersion, + }); + lastCheckedNameVersionRef.current = { + name: effectiveName, + version: effectiveVersion, + }; + + if (!res.valid) { + setNameVersionError( + `API with name ${effectiveName} and version ${effectiveVersion} already exists.` + ); + } else { + setNameVersionError(null); + } + } catch (e) { + lastCheckedNameVersionRef.current = null; + const msg = + e instanceof Error + ? e.message + : "Failed to validate name and version."; + setNameVersionError(msg); + } finally { + setNameVersionValidating(false); + } + }, + [meta.displayName, meta.name, meta.version, validateNameVersion] + ); + + const scheduleNameVersionValidation = React.useCallback( + (override?: { name?: string; version?: string }) => { + if (nameVersionTimerRef.current) + window.clearTimeout(nameVersionTimerRef.current); + + nameVersionTimerRef.current = window.setTimeout(() => { + void runNameVersionValidation(override); + }, debounceMs); + }, + [runNameVersionValidation, debounceMs] + ); + + const flushNameVersionValidation = React.useCallback( + (override?: NameVersionOverride) => { + if (nameVersionTimerRef.current) { + window.clearTimeout(nameVersionTimerRef.current); + nameVersionTimerRef.current = null; + } + void runNameVersionValidation(override); + }, + [runNameVersionValidation] + ); + + const runIdentifierValidation = React.useCallback( + async (override?: IdentifierOverride) => { + const effectiveIdentifier = slugify( + (override?.identifier ?? identifierToValidate).trim() + ).trim(); + + if (!effectiveIdentifier) return; + + const last = lastCheckedIdentifierRef.current; + if (!override?.force && last === effectiveIdentifier) return; + + try { + setIdentifierValidating(true); + + const res = await validateIdentifier(effectiveIdentifier); + lastCheckedIdentifierRef.current = effectiveIdentifier; + + if (!res.valid) { + setIdentifierError( + `API with identifier ${effectiveIdentifier} already exists.` + ); + } else { + setIdentifierError(null); + } + } catch (e) { + lastCheckedIdentifierRef.current = null; + const msg = + e instanceof Error ? e.message : "Failed to validate identifier."; + setIdentifierError(msg); + } finally { + setIdentifierValidating(false); + } + }, + [identifierToValidate, validateIdentifier] + ); + + const scheduleIdentifierValidation = React.useCallback( + (override?: { identifier?: string }) => { + if (identifierTimerRef.current) + window.clearTimeout(identifierTimerRef.current); + + identifierTimerRef.current = window.setTimeout(() => { + void runIdentifierValidation(override); + }, debounceMs); + }, + [runIdentifierValidation, debounceMs] + ); + + const flushIdentifierValidation = React.useCallback( + (override?: IdentifierOverride) => { + if (identifierTimerRef.current) { + window.clearTimeout(identifierTimerRef.current); + identifierTimerRef.current = null; + } + void runIdentifierValidation(override); + }, + [runIdentifierValidation] + ); + + React.useEffect(() => { + return () => { + if (nameVersionTimerRef.current) + window.clearTimeout(nameVersionTimerRef.current); + if (identifierTimerRef.current) + window.clearTimeout(identifierTimerRef.current); + }; + }, []); + + React.useEffect(() => { + if (didInitValidateRef.current) return; + didInitValidateRef.current = true; + + const effectiveName = (meta.displayName || meta.name || "").trim(); + const effectiveVersion = (meta.version || "").trim(); + + if (effectiveName && effectiveVersion) { + void runNameVersionValidation({ + name: effectiveName, + version: effectiveVersion, }); } + + if (identifierToValidate) { + void runIdentifierValidation({ identifier: identifierToValidate }); + } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const handleNameChange = (v: string) => { - const slug = slugify(v); const trimmed = v.trim(); + const slug = slugify(v); const nextPatch: Partial & { identifier?: string; @@ -93,36 +326,64 @@ const CreationMetaData: React.FC = ({ if (!meta.contextEdited) { nextPatch.context = slug ? `/${slug}` : ""; } + if (!meta.identifierEdited && !isIdentifierEditing) { - nextPatch.identifier = buildIdentifierFromName(v); + nextPatch.identifier = buildIdentifierFromNameAndVersion( + v, + meta.version || "" + ); } + setNameVersionError(null); change(nextPatch); - }; - const handleContextChange = (v: string) => { - change({ context: v, contextEdited: true }); + scheduleNameVersionValidation({ name: v }); + scheduleIdentifierValidation(); }; - const handleIdentifierChange = (v: string) => { - change({ - identifier: slugify(v), - identifierEdited: true, + React.useEffect(() => { + onValidationChangeRef.current?.({ + nameVersionError, + identifierError, + hasError: + !!nameVersionError || + !!identifierError || + nameVersionValidating || + identifierValidating, }); - }; + }, [ + nameVersionError, + identifierError, + nameVersionValidating, + identifierValidating, + ]); - const handleIdentifierEditClick = () => { - setIsIdentifierEditing(true); - change({ - identifierEdited: true, - }); - }; - const identifierDisabled = - !!readOnlyFields?.["identifier"] || !isIdentifierEditing; + React.useEffect(() => { + if (meta.identifierEdited || isIdentifierEditing) return; + + const nameForId = (meta.displayName || meta.name || "").trim(); + const verForId = (meta.version || "").trim(); + + if (!nameForId || !verForId) return; + + const expected = buildIdentifierFromNameAndVersion(nameForId, verForId); + + if ((meta.identifier || "").trim() !== expected) { + change({ identifier: expected }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + meta.displayName, + meta.name, + meta.version, + meta.identifierEdited, + isIdentifierEditing, + ]); return ( {title ? {title} : null} + = ({ placeholder="Sample API" value={meta.displayName || meta.name || ""} onChange={(v: string) => handleNameChange(v)} + onBlur={(() => flushNameVersionValidation({ force: true })) as any} testId="" size="medium" disabled={!!readOnlyFields?.name} @@ -140,13 +402,19 @@ const CreationMetaData: React.FC = ({ handleIdentifierChange(v)} - testId="" + placeholder="reading-list-api-rw-v1" + value={identifierDisplayValue} + onChange={(v: string) => { + handleIdentifierChange(v); + setIdentifierError(null); + scheduleIdentifierValidation({ identifier: v }); + }} + onBlur={(() => flushIdentifierValidation({ force: true })) as any} + testId="Identifier" size="medium" readonly={identifierDisabled} /> +