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 4c304173..34c4f6dc 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,20 @@ 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[]; + // 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 }) => { +const HTTPRouteCreatePage: React.FC = ({ + onFormChange, + extraGateways, + initialResource, +}) => { const { t } = useTranslation('plugin__kuadrant-console-plugin'); const [createView, setCreateView] = React.useState<'form' | 'yaml'>('form'); const [routeName, setRouteName] = React.useState(''); @@ -349,10 +360,29 @@ const HTTPRouteCreatePage: React.FC = ({ onFormChange 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()); } @@ -457,7 +487,11 @@ const HTTPRouteCreatePage: React.FC = ({ onFormChange - + { @@ -95,6 +96,14 @@ 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). 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 as GatewayForSelect]; + }, [formState.gatewayMode, newGatewayResource]); + const extensionNamespace = formState.extensionNamespace || selectedNamespace; const gatewayNamespace = formState.selectedGatewayNamespace || selectedNamespace; const isCrossNamespace = extensionNamespace !== gatewayNamespace; @@ -366,8 +375,15 @@ 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 || '', @@ -484,6 +500,8 @@ const MCPSetupWizard: React.FC = () => {
{ setNewRouteResource(resource); setNewRouteValid(isValid); @@ -517,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. */} + }> 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'); + // 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#'; @@ -93,10 +103,33 @@ 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 = stableExtraGateways.filter((gw) => !watchedKeys.has(keyOf(gw))); + setAvailableGateways([...watched, ...drafts]); + }, [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 + // 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 (stableExtraGateways.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, stableExtraGateways.length, parentRefs, onChange]); // Gateway validation function const validateGateway = (gateway: GatewayForSelect): string | null => {