From cfde3adfad35e640e19b53d73de01b6bdccd8ea3 Mon Sep 17 00:00:00 2001 From: Anton-Fil Date: Mon, 31 Aug 2026 15:46:14 +0100 Subject: [PATCH 1/4] fix(mcp): make Step 1 draft Gateway selectable as HTTPRoute parentRef Signed-off-by: Anton-Fil --- .../httproute/HTTPRouteCreatePage.tsx | 16 +++++++++++++--- src/components/mcp/MCPSetupWizard.tsx | 15 +++++++++++++++ src/utils/ParentReferencesSelect.tsx | 19 ++++++++++++++----- 3 files changed, 42 insertions(+), 8 deletions(-) diff --git a/src/components/httproute/HTTPRouteCreatePage.tsx b/src/components/httproute/HTTPRouteCreatePage.tsx index 4c304173..dd620799 100644 --- a/src/components/httproute/HTTPRouteCreatePage.tsx +++ b/src/components/httproute/HTTPRouteCreatePage.tsx @@ -31,7 +31,7 @@ import { } from '@openshift-console/dynamic-plugin-sdk'; import { useLocation, useNavigate } from 'react-router'; import * as yaml from 'js-yaml'; -import ParentReferencesSelect from '../../utils/ParentReferencesSelect'; +import ParentReferencesSelect, { GatewayForSelect } from '../../utils/ParentReferencesSelect'; import { Table, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table'; import { HTTPRouteResource, HTTPRouteMatch } from './types'; import { @@ -62,9 +62,15 @@ interface ParentReference { interface HTTPRouteCreatePageProps { onFormChange?: (resource: HTTPRouteResource, isValid: boolean) => void; + // Additional, not-yet-persisted Gateways to offer as parentRef options (e.g. a + // draft Gateway from an enclosing wizard). Not passed by the standalone page. + extraGateways?: GatewayForSelect[]; } -const HTTPRouteCreatePage: React.FC = ({ onFormChange }) => { +const HTTPRouteCreatePage: React.FC = ({ + onFormChange, + extraGateways, +}) => { const { t } = useTranslation('plugin__kuadrant-console-plugin'); const [createView, setCreateView] = React.useState<'form' | 'yaml'>('form'); const [routeName, setRouteName] = React.useState(''); @@ -457,7 +463,11 @@ const HTTPRouteCreatePage: React.FC = ({ onFormChange - + { @@ -95,6 +96,19 @@ const MCPSetupWizard: React.FC = () => { return (gateways || []).find((gw) => gw.metadata?.name === formState.selectedGatewayName); }, [gateways, formState.gatewayMode, formState.selectedGatewayName]); + // Expose the Step 1 draft Gateway to Step 2's parentRef selector before it is + // persisted (created only at the Verify step). Force the frozen wizard namespace + // so it passes the selector's allowed-namespace validation. See issue #795. + const draftGateways = React.useMemo(() => { + if (formState.gatewayMode !== 'new' || !newGatewayResource) return []; + return [ + { + ...newGatewayResource, + metadata: { ...newGatewayResource.metadata, namespace: selectedNamespace }, + } as GatewayForSelect, + ]; + }, [formState.gatewayMode, newGatewayResource, selectedNamespace]); + const extensionNamespace = formState.extensionNamespace || selectedNamespace; const gatewayNamespace = formState.selectedGatewayNamespace || selectedNamespace; const isCrossNamespace = extensionNamespace !== gatewayNamespace; @@ -484,6 +498,7 @@ const MCPSetupWizard: React.FC = () => {
{ setNewRouteResource(resource); setNewRouteValid(isValid); diff --git a/src/utils/ParentReferencesSelect.tsx b/src/utils/ParentReferencesSelect.tsx index 0d704726..fc7e8db3 100644 --- a/src/utils/ParentReferencesSelect.tsx +++ b/src/utils/ParentReferencesSelect.tsx @@ -22,7 +22,7 @@ import { K8sResourceCommon, } from '@openshift-console/dynamic-plugin-sdk'; -interface GatewayForSelect extends K8sResourceCommon { +export interface GatewayForSelect extends K8sResourceCommon { spec?: { listeners?: Array<{ name: string; @@ -66,12 +66,17 @@ interface ParentReferencesSelectProps { parentRefs: ParentReference[]; onChange: (parentRefs: ParentReference[]) => void; isDisabled?: boolean; + // Additional Gateways to include in the selector that aren't yet persisted in + // the cluster (e.g. a draft Gateway defined earlier in a wizard). Merged with + // the live watch results, deduped by namespace/name (real Gateways win). + extraGateways?: GatewayForSelect[]; } const ParentReferencesSelect: React.FC = ({ parentRefs, onChange, isDisabled = false, + extraGateways = [], }) => { const { t } = useTranslation('plugin__kuadrant-console-plugin'); const [availableGateways, setAvailableGateways] = React.useState([]); @@ -93,10 +98,14 @@ const ParentReferencesSelect: React.FC = ({ useK8sWatchResource(gatewayResource); React.useEffect(() => { - if (gatewayLoaded && !gatewayError && Array.isArray(gatewayData)) { - setAvailableGateways(gatewayData); - } - }, [gatewayData, gatewayLoaded, gatewayError]); + const watched = gatewayLoaded && !gatewayError && Array.isArray(gatewayData) ? gatewayData : []; + // Merge in any draft Gateways, deduped by namespace/name. A real Gateway from + // the watch takes precedence over a draft with the same key. + const keyOf = (gw: GatewayForSelect) => `${gw.metadata?.namespace}/${gw.metadata?.name}`; + const watchedKeys = new Set(watched.map(keyOf)); + const drafts = extraGateways.filter((gw) => !watchedKeys.has(keyOf(gw))); + setAvailableGateways([...watched, ...drafts]); + }, [gatewayData, gatewayLoaded, gatewayError, extraGateways]); // Gateway validation function const validateGateway = (gateway: GatewayForSelect): string | null => { From ee39c118fbf912d33d4f4d0df69db1c2a416e62a Mon Sep 17 00:00:00 2001 From: Anton-Fil Date: Tue, 1 Sep 2026 12:19:19 +0100 Subject: [PATCH 2/4] fix Reconcile the HTTPRoute parent reference after a draft Gateway change Signed-off-by: Anton-Fil --- src/components/mcp/MCPSetupWizard.tsx | 21 +++++++++++---------- src/utils/ParentReferencesSelect.tsx | 19 +++++++++++++++++++ 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/src/components/mcp/MCPSetupWizard.tsx b/src/components/mcp/MCPSetupWizard.tsx index e33e8b4e..9e7bb1d0 100644 --- a/src/components/mcp/MCPSetupWizard.tsx +++ b/src/components/mcp/MCPSetupWizard.tsx @@ -97,17 +97,12 @@ const MCPSetupWizard: React.FC = () => { }, [gateways, formState.gatewayMode, formState.selectedGatewayName]); // Expose the Step 1 draft Gateway to Step 2's parentRef selector before it is - // persisted (created only at the Verify step). Force the frozen wizard namespace - // so it passes the selector's allowed-namespace validation. See issue #795. + // persisted (created only at the Verify step). newGatewayResource is already + // pinned to the frozen wizard namespace at storage time. See issue #795. const draftGateways = React.useMemo(() => { if (formState.gatewayMode !== 'new' || !newGatewayResource) return []; - return [ - { - ...newGatewayResource, - metadata: { ...newGatewayResource.metadata, namespace: selectedNamespace }, - } as GatewayForSelect, - ]; - }, [formState.gatewayMode, newGatewayResource, selectedNamespace]); + return [newGatewayResource as GatewayForSelect]; + }, [formState.gatewayMode, newGatewayResource]); const extensionNamespace = formState.extensionNamespace || selectedNamespace; const gatewayNamespace = formState.selectedGatewayNamespace || selectedNamespace; @@ -381,7 +376,13 @@ const MCPSetupWizard: React.FC = () => {
{ - setNewGatewayResource(resource); + // Pin the namespace to the frozen wizard namespace so the + // Gateway created at Verify and the draft shown in Step 2 + // never diverge if the active namespace changes mid-wizard. + setNewGatewayResource({ + ...resource, + metadata: { ...resource.metadata, namespace: selectedNamespace }, + }); setNewGatewayValid(isValid); updateFormState({ newGatewayName: resource.metadata?.name || '', diff --git a/src/utils/ParentReferencesSelect.tsx b/src/utils/ParentReferencesSelect.tsx index fc7e8db3..baa66634 100644 --- a/src/utils/ParentReferencesSelect.tsx +++ b/src/utils/ParentReferencesSelect.tsx @@ -107,6 +107,25 @@ const ParentReferencesSelect: React.FC = ({ setAvailableGateways([...watched, ...drafts]); }, [gatewayData, gatewayLoaded, gatewayError, extraGateways]); + // Reconcile parentRefs against the available Gateways when draft Gateways are in + // play (wizard context only). If a selected Gateway disappears — e.g. a draft + // Gateway is renamed in an earlier wizard step — clear the stale selection so the + // form can't emit an HTTPRoute pointing at a Gateway that no longer exists. + // Gated on extraGateways so the standalone Create/Edit HTTPRoute page is untouched. + React.useEffect(() => { + if (extraGateways.length === 0 || !gatewayLoaded) return; + const validNames = new Set(availableGateways.map((gw) => gw.metadata?.name)); + let changed = false; + const reconciled = parentRefs.map((ref) => { + if (ref.gatewayName && !validNames.has(ref.gatewayName)) { + changed = true; + return { ...ref, gatewayName: '', gatewayNamespace: '', sectionName: '', port: 0 }; + } + return ref; + }); + if (changed) onChange(reconciled); + }, [availableGateways, gatewayLoaded, extraGateways.length, parentRefs, onChange]); + // Gateway validation function const validateGateway = (gateway: GatewayForSelect): string | null => { if (gateway.metadata?.deletionTimestamp) { From 7bbf91e88e2c8b2d4fcd2da10b37a0e00fe2eba5 Mon Sep 17 00:00:00 2001 From: Anton-Fil Date: Tue, 1 Sep 2026 12:44:04 +0100 Subject: [PATCH 3/4] add fix with save form info and buttons Signed-off-by: Anton-Fil --- .../en/plugin__kuadrant-console-plugin.json | 1 - src/components/gateway/GatewayCreatePage.tsx | 23 +++++++++++++++++- .../httproute/HTTPRouteCreatePage.tsx | 24 +++++++++++++++++++ src/components/mcp/MCPSetupWizard.tsx | 16 ++++++------- 4 files changed, 53 insertions(+), 11 deletions(-) diff --git a/locales/en/plugin__kuadrant-console-plugin.json b/locales/en/plugin__kuadrant-console-plugin.json index c10956f5..024dee7f 100644 --- a/locales/en/plugin__kuadrant-console-plugin.json +++ b/locales/en/plugin__kuadrant-console-plugin.json @@ -253,7 +253,6 @@ "DNSPolicy configures how North-South based traffic should be balanced and reach the gateways": "DNSPolicy configures how North-South based traffic should be balanced and reach the gateways", "Documentation": "Documentation", "Documentation URL": "Documentation URL", - "Done": "Done", "Draft": "Draft", "e.g. 1h, 60s, 500ms, 1h30m": "e.g. 1h, 60s, 500ms, 1h30m", "e.g. auth.identity.tier == \"gold\"": "e.g. auth.identity.tier == \"gold\"", diff --git a/src/components/gateway/GatewayCreatePage.tsx b/src/components/gateway/GatewayCreatePage.tsx index 88e3a146..e066437e 100644 --- a/src/components/gateway/GatewayCreatePage.tsx +++ b/src/components/gateway/GatewayCreatePage.tsx @@ -62,9 +62,13 @@ import '../css/gateway-api-plugin.css'; interface GatewayCreatePageProps { onFormChange?: (resource: GatewayResource, isValid: boolean) => void; + // Hydrate the form from a previously built resource on mount. Used when this page + // is embedded in a wizard step that unmounts on navigation, so returning to the + // step restores the user's input instead of showing a blank form. + initialResource?: GatewayResource; } -const GatewayCreatePage: React.FC = ({ onFormChange }) => { +const GatewayCreatePage: React.FC = ({ onFormChange, initialResource }) => { const { t } = useTranslation('plugin__kuadrant-console-plugin'); const [createView, setCreateView] = React.useState<'form' | 'yaml'>('form'); const [activeNamespace] = useActiveNamespace(); @@ -656,13 +660,30 @@ const GatewayCreatePage: React.FC = ({ onFormChange }) = } }, [gatewayObject]); + // Restore form state from a parent-provided draft on mount (wizard back-navigation). + // Only in create mode — edit mode hydrates from the cluster watch above. + const hasHydratedFromInitial = React.useRef(false); + React.useEffect(() => { + if (initialResource && !hasHydratedFromInitial.current && (!nameEdit || nameEdit === '~new')) { + populateFormFromGateway(initialResource); + hasHydratedFromInitial.current = true; + } + }, [initialResource]); + const onFormChangeRef = React.useRef(onFormChange); React.useEffect(() => { onFormChangeRef.current = onFormChange; }, [onFormChange]); + // Skip the first (empty) emit when hydrating from initialResource so the blank + // mount render can't clobber the parent's stored draft before hydration runs. + const skipInitialEmit = React.useRef(!!initialResource); React.useEffect(() => { + if (skipInitialEmit.current) { + skipInitialEmit.current = false; + return; + } if (onFormChangeRef.current) { onFormChangeRef.current(gatewayObject, formValidation()); } diff --git a/src/components/httproute/HTTPRouteCreatePage.tsx b/src/components/httproute/HTTPRouteCreatePage.tsx index dd620799..34c4f6dc 100644 --- a/src/components/httproute/HTTPRouteCreatePage.tsx +++ b/src/components/httproute/HTTPRouteCreatePage.tsx @@ -65,11 +65,16 @@ interface HTTPRouteCreatePageProps { // Additional, not-yet-persisted Gateways to offer as parentRef options (e.g. a // draft Gateway from an enclosing wizard). Not passed by the standalone page. extraGateways?: GatewayForSelect[]; + // Hydrate the form from a previously built resource on mount. Used when this page + // is embedded in a wizard step that unmounts on navigation, so returning to the + // step restores the user's input instead of showing a blank form. + initialResource?: HTTPRouteResource; } const HTTPRouteCreatePage: React.FC = ({ onFormChange, extraGateways, + initialResource, }) => { const { t } = useTranslation('plugin__kuadrant-console-plugin'); const [createView, setCreateView] = React.useState<'form' | 'yaml'>('form'); @@ -355,10 +360,29 @@ const HTTPRouteCreatePage: React.FC = ({ return !!(validateRouteName(routeName) === null && hasValidParentRef && hasValidRules); }; + // Hydrate the form once from a previously built resource when embedded in a wizard + // step that unmounts on navigation. Only in create mode — edit mode hydrates from + // the live cluster watch above. + const hasHydratedFromInitial = React.useRef(false); + React.useEffect(() => { + if (initialResource && !hasHydratedFromInitial.current && (!nameEdit || nameEdit === '~new')) { + populateFormFromHTTPRoute(initialResource); + hasHydratedFromInitial.current = true; + } + }, [initialResource]); + const onFormChangeRef = React.useRef(onFormChange); onFormChangeRef.current = onFormChange; + // Skip the first emit when hydrating from initialResource: the built object lags one + // render behind the setState calls in populateFormFromHTTPRoute, so emitting it would + // clobber the parent's stored draft with a blank/stale resource. + const skipInitialEmit = React.useRef(!!initialResource); React.useEffect(() => { + if (skipInitialEmit.current) { + skipInitialEmit.current = false; + return; + } if (onFormChangeRef.current) { onFormChangeRef.current(httpRouteObject as HTTPRouteResource, formValidation()); } diff --git a/src/components/mcp/MCPSetupWizard.tsx b/src/components/mcp/MCPSetupWizard.tsx index 9e7bb1d0..cc1cbbc5 100644 --- a/src/components/mcp/MCPSetupWizard.tsx +++ b/src/components/mcp/MCPSetupWizard.tsx @@ -375,6 +375,7 @@ const MCPSetupWizard: React.FC = () => {
{ // Pin the namespace to the frozen wizard namespace so the // Gateway created at Verify and the draft shown in Step 2 @@ -500,6 +501,7 @@ const MCPSetupWizard: React.FC = () => {
{ setNewRouteResource(resource); setNewRouteValid(isValid); @@ -533,15 +535,11 @@ const MCPSetupWizard: React.FC = () => { {/* Step 4: Verify configuration */} - navigate(`/kuadrant/mcp/overview/ns/${selectedNamespace}`), - isBackHidden: true, - }} - > + {/* No footer buttons: Done/Cancel both just navigated to the overview, which + is redundant with the in-card "View in overview" button below. Resources + persist as they are created, so the user can leave via that button or by + navigating away at any time. */} + }> Date: Tue, 1 Sep 2026 12:48:59 +0100 Subject: [PATCH 4/4] fix e2e test Signed-off-by: Anton-Fil --- src/utils/ParentReferencesSelect.tsx | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/utils/ParentReferencesSelect.tsx b/src/utils/ParentReferencesSelect.tsx index baa66634..0169f425 100644 --- a/src/utils/ParentReferencesSelect.tsx +++ b/src/utils/ParentReferencesSelect.tsx @@ -76,9 +76,14 @@ const ParentReferencesSelect: React.FC = ({ parentRefs, onChange, isDisabled = false, - extraGateways = [], + extraGateways, }) => { const { t } = useTranslation('plugin__kuadrant-console-plugin'); + // Stabilize the optional prop reference. A default `[]` literal would be a new + // array on every render, so effects that depend on it would re-run each render + // and, via setAvailableGateways, spin an infinite update loop in the standalone + // form (where no extraGateways are passed). + const stableExtraGateways = React.useMemo(() => extraGateways ?? [], [extraGateways]); const [availableGateways, setAvailableGateways] = React.useState([]); const [activeNamespace] = useActiveNamespace(); const isAllNamespaces = !activeNamespace || activeNamespace === '#ALL_NS#'; @@ -103,9 +108,9 @@ const ParentReferencesSelect: React.FC = ({ // the watch takes precedence over a draft with the same key. const keyOf = (gw: GatewayForSelect) => `${gw.metadata?.namespace}/${gw.metadata?.name}`; const watchedKeys = new Set(watched.map(keyOf)); - const drafts = extraGateways.filter((gw) => !watchedKeys.has(keyOf(gw))); + const drafts = stableExtraGateways.filter((gw) => !watchedKeys.has(keyOf(gw))); setAvailableGateways([...watched, ...drafts]); - }, [gatewayData, gatewayLoaded, gatewayError, extraGateways]); + }, [gatewayData, gatewayLoaded, gatewayError, stableExtraGateways]); // Reconcile parentRefs against the available Gateways when draft Gateways are in // play (wizard context only). If a selected Gateway disappears — e.g. a draft @@ -113,7 +118,7 @@ const ParentReferencesSelect: React.FC = ({ // form can't emit an HTTPRoute pointing at a Gateway that no longer exists. // Gated on extraGateways so the standalone Create/Edit HTTPRoute page is untouched. React.useEffect(() => { - if (extraGateways.length === 0 || !gatewayLoaded) return; + if (stableExtraGateways.length === 0 || !gatewayLoaded) return; const validNames = new Set(availableGateways.map((gw) => gw.metadata?.name)); let changed = false; const reconciled = parentRefs.map((ref) => { @@ -124,7 +129,7 @@ const ParentReferencesSelect: React.FC = ({ return ref; }); if (changed) onChange(reconciled); - }, [availableGateways, gatewayLoaded, extraGateways.length, parentRefs, onChange]); + }, [availableGateways, gatewayLoaded, stableExtraGateways.length, parentRefs, onChange]); // Gateway validation function const validateGateway = (gateway: GatewayForSelect): string | null => {