Skip to content

Commit 03ec511

Browse files
authored
feat: Edit ManagedControlPlane using the wizard (#287)
1 parent ff13804 commit 03ec511

24 files changed

+922
-260
lines changed

package-lock.json

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
"@xyflow/react": "12.8.4",
4646
"clsx": "2.1.1",
4747
"dagre": "0.8.5",
48+
"diff": "^8.0.2",
4849
"dotenv": "17.2.2",
4950
"fastify": "5.6.0",
5051
"fastify-plugin": "5.0.1",

public/locales/en.json

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,10 @@
5858
"loadingErrorMessage": "Failed to list mcps in workspace"
5959
},
6060
"ControlPlaneCard": {
61-
"deleteConfirmationDialog": "MCP deletion triggered. The list will refresh automatically once completed."
61+
"deleteConfirmationDialog": "MCP deletion triggered. The list will refresh automatically once completed.",
62+
"editMCP": "Edit Managed Control Plane",
63+
"deleteMCP": "Delete Managed Control Plane"
64+
6265
},
6366
"ControlPlaneListAllWorkspaces": {
6467
"emptyListTitleMessage": "No Workspaces created yet",
@@ -370,7 +373,8 @@
370373
"create": "Create",
371374
"close": "Close",
372375
"back": "Back",
373-
"cancel": "Cancel"
376+
"cancel": "Cancel",
377+
"update": "Update"
374378
},
375379
"yaml": {
376380
"copiedToClipboard": "YAML copied to clipboard!",
@@ -381,6 +385,11 @@
381385
"titleText": "Managed Control Plane Created Successfully!",
382386
"subtitleText": "Your Managed Control Plane is being set up. It will be ready to use in just a few minutes. You can safely close this window."
383387
},
388+
"editMCP": {
389+
"dialogTitle": "Edit Managed Control Plane",
390+
"titleText": "Managed Control Plane Updated Successfully!",
391+
"subtitleText": "Your Managed Control Plane is being updated. It will be ready to use in just a few minutes. You can safely close this window."
392+
},
384393
"componentsSelection": {
385394
"selectComponents": "Select Components",
386395
"selectedComponents": "Selected Components",

src/components/ComponentsSelection/ComponentsSelectionContainer.tsx

Lines changed: 82 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useEffect, useMemo, useRef, useState } from 'react';
1+
import React, { useEffect, useMemo, useState } from 'react';
22
import { ComponentsSelection } from './ComponentsSelection.tsx';
33

44
import IllustratedError from '../Shared/IllustratedError.tsx';
@@ -14,7 +14,11 @@ import { ManagedControlPlaneTemplate } from '../../lib/api/types/templates/mcpTe
1414
export interface ComponentsSelectionProps {
1515
componentsList: ComponentsListItem[];
1616
setComponentsList: (components: ComponentsListItem[]) => void;
17+
setInitialComponentsList: (components: ComponentsListItem[]) => void;
1718
managedControlPlaneTemplate?: ManagedControlPlaneTemplate;
19+
initialSelection?: Record<string, { isSelected: boolean; version: string }>;
20+
isOnMcpPage?: boolean;
21+
initializedComponents: React.RefObject<boolean>;
1822
}
1923

2024
/**
@@ -25,9 +29,7 @@ export const getSelectedComponents = (components: ComponentsListItem[]) => {
2529
const isCrossplaneSelected = components.some(({ name, isSelected }) => name === 'crossplane' && isSelected);
2630
return components.filter((component) => {
2731
if (!component.isSelected) return false;
28-
if (component.name?.includes('provider') && !isCrossplaneSelected) {
29-
return false;
30-
}
32+
if (component.name?.includes('provider') && !isCrossplaneSelected) return false;
3133
return true;
3234
});
3335
};
@@ -43,71 +45,112 @@ export const ComponentsSelectionContainer: React.FC<ComponentsSelectionProps> =
4345
setComponentsList,
4446
componentsList,
4547
managedControlPlaneTemplate,
48+
initialSelection,
49+
isOnMcpPage,
50+
setInitialComponentsList,
51+
initializedComponents,
4652
}) => {
47-
const { data: availableManagedComponentsListData, error, isLoading } = useApiResource(ListManagedComponents());
53+
const {
54+
data: availableManagedComponentsListData,
55+
error,
56+
isLoading,
57+
} = useApiResource(ListManagedComponents(), undefined, !!isOnMcpPage);
4858
const { t } = useTranslation();
49-
const initialized = useRef(false);
59+
5060
const [templateDefaultsError, setTemplateDefaultsError] = useState<string | null>(null);
5161
const defaultComponents = useMemo<TemplateDefaultComponent[]>(
5262
() => managedControlPlaneTemplate?.spec?.spec?.components?.defaultComponents ?? [],
5363
[managedControlPlaneTemplate],
5464
);
5565

5666
useEffect(() => {
57-
const items = availableManagedComponentsListData?.items ?? [];
58-
59-
if (!items.length) {
60-
if (!initialized.current) return;
61-
setTemplateDefaultsError(null);
67+
if (
68+
initializedComponents.current ||
69+
!availableManagedComponentsListData?.items ||
70+
availableManagedComponentsListData.items.length === 0
71+
) {
6272
return;
6373
}
6474

65-
if (!initialized.current) {
66-
const newComponentsList = items
67-
.map((item) => {
68-
const versions = sortVersions(item.status.versions);
69-
const template = defaultComponents.find((dc) => dc.name === item.metadata.name);
70-
const templateVersion = template?.version;
71-
const selectedVersion = template
72-
? templateVersion && versions.includes(templateVersion)
73-
? templateVersion
74-
: ''
75-
: (versions[0] ?? '');
76-
return {
77-
name: item.metadata.name,
78-
versions,
79-
selectedVersion,
80-
isSelected: !!template,
81-
documentationUrl: '',
82-
};
83-
})
84-
.filter((component) => !removeComponents.find((item) => item === component.name));
85-
86-
setComponentsList(newComponentsList);
87-
initialized.current = true;
88-
}
75+
const newComponentsList = availableManagedComponentsListData.items
76+
.map((item) => {
77+
const versions = sortVersions(item.status?.versions ?? []);
78+
const template = defaultComponents.find((dc) => dc.name === (item.metadata?.name ?? ''));
79+
const templateVersion = template?.version;
80+
let selectedVersion = template
81+
? templateVersion && versions.includes(templateVersion)
82+
? templateVersion
83+
: ''
84+
: (versions[0] ?? '');
85+
let isSelected = !!template;
86+
87+
const initSel = initialSelection?.[item.metadata?.name ?? ''];
88+
if (initSel) {
89+
// Override selection and version from initial selection if provided
90+
isSelected = Boolean(initSel.isSelected);
91+
selectedVersion = initSel.version && versions.includes(initSel.version) ? initSel.version : '';
92+
}
93+
return {
94+
name: item.metadata?.name ?? '',
95+
versions,
96+
selectedVersion,
97+
isSelected,
98+
documentationUrl: '',
99+
};
100+
})
101+
.filter((component) => !removeComponents.find((item) => item === component.name));
102+
setInitialComponentsList(newComponentsList);
103+
setComponentsList(newComponentsList);
104+
initializedComponents.current = true;
105+
// eslint-disable-next-line react-hooks/exhaustive-deps
106+
}, [setComponentsList, defaultComponents, initialSelection, availableManagedComponentsListData?.items]);
89107

90-
if (!defaultComponents.length) {
108+
useEffect(() => {
109+
const items = availableManagedComponentsListData?.items ?? [];
110+
if (items.length === 0 || !defaultComponents.length) {
91111
setTemplateDefaultsError(null);
92112
return;
93113
}
94114

95115
const errors: string[] = [];
96116
defaultComponents.forEach((dc: TemplateDefaultComponent) => {
97117
if (!dc?.name) return;
98-
const item = items.find((it) => it.metadata.name === dc.name);
118+
const item = items.find((it) => it.metadata?.name === dc.name);
99119
if (!item) {
100120
errors.push(`Component "${dc.name}" from template is not available.`);
101121
return;
102122
}
103-
const versions: string[] = Array.isArray(item.status?.versions) ? item.status.versions : [];
123+
const versions: string[] = Array.isArray(item.status?.versions) ? (item.status?.versions as string[]) : [];
104124
if (dc.version && !versions.includes(dc.version)) {
105125
errors.push(`Component "${dc.name}" version "${dc.version}" from template is not available.`);
106126
}
107127
});
108128

109129
setTemplateDefaultsError(errors.length ? errors.join('\n') : null);
110-
}, [availableManagedComponentsListData, defaultComponents, setComponentsList]);
130+
}, [availableManagedComponentsListData, defaultComponents]);
131+
132+
useEffect(() => {
133+
if (!initializedComponents.current) return;
134+
if (!defaultComponents?.length) return;
135+
if (!componentsList?.length) return;
136+
// If initialSelection is provided, do not auto-apply template defaults
137+
if (initialSelection && Object.keys(initialSelection).length > 0) return;
138+
139+
const anySelected = componentsList.some((c) => c.isSelected);
140+
if (anySelected) return;
141+
142+
const updated = componentsList.map((c) => {
143+
const template = defaultComponents.find((dc) => dc.name === c.name);
144+
if (!template) return c;
145+
const templateVersion = template.version;
146+
const selectedVersion =
147+
templateVersion && Array.isArray(c.versions) && c.versions.includes(templateVersion) ? templateVersion : '';
148+
return { ...c, isSelected: true, selectedVersion };
149+
});
150+
151+
setComponentsList(updated);
152+
// eslint-disable-next-line react-hooks/exhaustive-deps
153+
}, [defaultComponents, componentsList, setComponentsList, initialSelection]);
111154

112155
if (isLoading) {
113156
return <Loading />;
@@ -117,7 +160,6 @@ export const ComponentsSelectionContainer: React.FC<ComponentsSelectionProps> =
117160
return <IllustratedError compact={true} />;
118161
}
119162

120-
// Defensive: If the API returned no items, show error
121163
if (!componentsList || componentsList.length === 0) {
122164
return <IllustratedError title={t('componentsSelection.cannotLoad')} compact={true} />;
123165
}

src/components/ControlPlanes/ControlPlaneCard/ControlPlaneCard.tsx

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Button, Card, FlexBox, Label, Title } from '@ui5/webcomponents-react';
1+
import { Card, FlexBox, Label, Title } from '@ui5/webcomponents-react';
22
import '@ui5/webcomponents-fiori/dist/illustrations/NoData.js';
33
import '@ui5/webcomponents-fiori/dist/illustrations/EmptyList.js';
44
import '@ui5/webcomponents-icons/dist/delete';
@@ -27,6 +27,11 @@ import { useToast } from '../../../context/ToastContext.tsx';
2727
import { canConnectToMCP } from '../controlPlanes.ts';
2828
import { Infobox } from '../../Ui/Infobox/Infobox.tsx';
2929

30+
import { ControlPlaneCardMenu } from './ControlPlaneCardMenu.tsx';
31+
32+
import { EditManagedControlPlaneWizardDataLoader } from '../../Wizards/CreateManagedControlPlane/EditManagedControlPlaneWizardDataLoader.tsx';
33+
import { DISPLAY_NAME_ANNOTATION } from '../../../lib/api/types/shared/keyNames.ts';
34+
3035
interface Props {
3136
controlPlane: ListControlPlanesType;
3237
workspace: ListWorkspacesType;
@@ -37,7 +42,7 @@ export function ControlPlaneCard({ controlPlane, workspace, projectName }: Props
3742
const [dialogDeleteMcpIsOpen, setDialogDeleteMcpIsOpen] = useState(false);
3843
const toast = useToast();
3944
const { t } = useTranslation();
40-
45+
const [isEditManagedControlPlaneWizardOpen, setIsEditManagedControlPlaneWizardOpen] = useState(false);
4146
const { trigger: patchTrigger } = useApiResourceMutation<DeleteMCPType>(
4247
PatchMCPResourceForDeletion(controlPlane.metadata.namespace, controlPlane.metadata.name),
4348
);
@@ -46,6 +51,9 @@ export function ControlPlaneCard({ controlPlane, workspace, projectName }: Props
4651
);
4752

4853
const name = controlPlane.metadata.name;
54+
const displayName =
55+
controlPlane?.metadata?.annotations?.[DISPLAY_NAME_ANNOTATION as keyof typeof controlPlane.metadata.annotations];
56+
4957
const namespace = controlPlane.metadata.namespace;
5058

5159
const isSystemIdentityProviderEnabled = Boolean(controlPlane.spec?.authentication?.enableSystemIdentityProvider);
@@ -61,7 +69,7 @@ export function ControlPlaneCard({ controlPlane, workspace, projectName }: Props
6169
<FlexBox direction="Column">
6270
<FlexBox direction="Row" justifyContent="SpaceBetween">
6371
<FlexBox direction="Column">
64-
<Title level={TitleLevel.H5}>{name}</Title>
72+
<Title level={TitleLevel.H5}>{displayName ? displayName : name}</Title>
6573
<Label>{workspace.metadata.name} </Label>
6674
</FlexBox>
6775
<div>
@@ -74,13 +82,10 @@ export function ControlPlaneCard({ controlPlane, workspace, projectName }: Props
7482
</div>
7583
</FlexBox>
7684
<FlexBox direction="Row" justifyContent="SpaceBetween" alignItems="Center" className={styles.row}>
77-
<Button
78-
design={'Transparent'}
79-
icon="delete"
80-
disabled={controlPlane.status?.status === ReadyStatus.InDeletion}
81-
onClick={() => {
82-
setDialogDeleteMcpIsOpen(true);
83-
}}
85+
<ControlPlaneCardMenu
86+
setDialogDeleteMcpIsOpen={setDialogDeleteMcpIsOpen}
87+
isDeleteMcpButtonDisabled={controlPlane.status?.status === ReadyStatus.InDeletion}
88+
setIsEditManagedControlPlaneWizardOpen={setIsEditManagedControlPlaneWizardOpen}
8489
/>
8590
<FlexBox direction="Row" justifyContent="SpaceBetween" alignItems="Center" gap={10}>
8691
<YamlViewButtonWithLoader
@@ -124,6 +129,12 @@ export function ControlPlaneCard({ controlPlane, workspace, projectName }: Props
124129
toast.show(t('ControlPlaneCard.deleteConfirmationDialog'));
125130
}}
126131
/>
132+
<EditManagedControlPlaneWizardDataLoader
133+
isOpen={isEditManagedControlPlaneWizardOpen}
134+
setIsOpen={setIsEditManagedControlPlaneWizardOpen}
135+
workspaceName={namespace}
136+
resourceName={name}
137+
/>
127138
</>
128139
);
129140
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { Button, Menu, MenuItem } from '@ui5/webcomponents-react';
2+
3+
import { Dispatch, FC, SetStateAction, useRef, useState } from 'react';
4+
import '@ui5/webcomponents-icons/dist/copy';
5+
import '@ui5/webcomponents-icons/dist/accept';
6+
7+
import { useTranslation } from 'react-i18next';
8+
9+
type ControlPlanesListMenuProps = {
10+
setDialogDeleteMcpIsOpen: Dispatch<SetStateAction<boolean>>;
11+
isDeleteMcpButtonDisabled: boolean;
12+
setIsEditManagedControlPlaneWizardOpen: Dispatch<SetStateAction<boolean>>;
13+
};
14+
15+
export const ControlPlaneCardMenu: FC<ControlPlanesListMenuProps> = ({
16+
setDialogDeleteMcpIsOpen,
17+
isDeleteMcpButtonDisabled,
18+
setIsEditManagedControlPlaneWizardOpen,
19+
}) => {
20+
const buttonRef = useRef(null);
21+
const [menuIsOpen, setMenuIsOpen] = useState(false);
22+
const { t } = useTranslation();
23+
24+
const handleOpenerClick = () => {
25+
setMenuIsOpen(true);
26+
};
27+
28+
return (
29+
<>
30+
<Button ref={buttonRef} icon="overflow" icon-end onClick={handleOpenerClick} />
31+
<Menu
32+
open={menuIsOpen}
33+
opener={buttonRef.current}
34+
onItemClick={(event) => {
35+
const action = (event.detail.item as HTMLElement).dataset.action;
36+
if (action === 'editMcp') {
37+
setIsEditManagedControlPlaneWizardOpen(true);
38+
}
39+
if (action === 'deleteMcp') {
40+
setDialogDeleteMcpIsOpen(true);
41+
}
42+
43+
setMenuIsOpen(false);
44+
}}
45+
onClose={() => {
46+
setMenuIsOpen(false);
47+
}}
48+
>
49+
<MenuItem
50+
key={'delete'}
51+
text={t('ControlPlaneCard.deleteMCP')}
52+
data-action="deleteMcp"
53+
icon="delete"
54+
disabled={isDeleteMcpButtonDisabled}
55+
/>
56+
<MenuItem
57+
key={'edit'}
58+
text={t('ControlPlaneCard.editMCP')}
59+
data-action="editMcp"
60+
icon="edit"
61+
disabled={isDeleteMcpButtonDisabled}
62+
/>
63+
</Menu>
64+
</>
65+
);
66+
};

0 commit comments

Comments
 (0)