diff --git a/public/locales/en.json b/public/locales/en.json index f1a83b47..4192371f 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -308,6 +308,7 @@ "componentsSelection": { "selectComponents": "Select Components", "selectedComponents": "Selected Components", - "pleaseSelectComponents": "Choose the components you want to add to your Managed Control Plane." + "pleaseSelectComponents": "Choose the components you want to add to your Managed Control Plane.", + "cannotLoad": "Cannot load components list" } } diff --git a/src/components/ComponentsSelection/ComponentsSelection.tsx b/src/components/ComponentsSelection/ComponentsSelection.tsx index 1abf0d67..99cb29b6 100644 --- a/src/components/ComponentsSelection/ComponentsSelection.tsx +++ b/src/components/ComponentsSelection/ComponentsSelection.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useMemo, useCallback } from 'react'; import { CheckBox, Select, @@ -20,58 +20,78 @@ import { import styles from './ComponentsSelection.module.css'; import { Infobox } from '../Ui/Infobox/Infobox.tsx'; import { useTranslation } from 'react-i18next'; -import { ComponentSelectionItem } from '../../lib/api/types/crate/createManagedControlPlane.ts'; +import { ComponentsListItem } from '../../lib/api/types/crate/createManagedControlPlane.ts'; +import { getSelectedComponents } from './ComponentsSelectionContainer.tsx'; export interface ComponentsSelectionProps { - components: ComponentSelectionItem[]; - setSelectedComponents: React.Dispatch< - React.SetStateAction - >; + componentsList: ComponentsListItem[]; + setComponentsList: (components: ComponentsListItem[]) => void; } export const ComponentsSelection: React.FC = ({ - components, - setSelectedComponents, + componentsList, + setComponentsList, }) => { const [searchTerm, setSearchTerm] = useState(''); const { t } = useTranslation(); - const handleSelectionChange = ( - e: Ui5CustomEvent, - ) => { - const id = e.target?.id; - setSelectedComponents((prev) => - prev.map((component) => - component.name === id - ? { ...component, isSelected: !component.isSelected } - : component, - ), - ); - }; - const handleSearch = (e: Ui5CustomEvent) => { - setSearchTerm(e.target.value.trim()); - }; + const selectedComponents = useMemo( + () => getSelectedComponents(componentsList), + [componentsList], + ); - const handleVersionChange = ( - e: Ui5CustomEvent, - ) => { - const selectedOption = e.detail.selectedOption as HTMLElement; - const name = selectedOption.dataset.name; - const version = selectedOption.dataset.version; - setSelectedComponents((prev) => - prev.map((component) => - component.name === name - ? { ...component, selectedVersion: version || '' } - : component, - ), + const searchResults = useMemo(() => { + const lowerSearch = searchTerm.toLowerCase(); + return componentsList.filter(({ name }) => + name.toLowerCase().includes(lowerSearch), ); - }; + }, [componentsList, searchTerm]); - const filteredComponents = components.filter(({ name }) => - name.includes(searchTerm), + const handleSelectionChange = useCallback( + (e: Ui5CustomEvent) => { + const id = e.target?.id; + if (!id) return; + setComponentsList( + componentsList.map((component) => + component.name === id + ? { ...component, isSelected: !component.isSelected } + : component, + ), + ); + }, + [componentsList, setComponentsList], ); - const selectedComponents = components.filter( - (component) => component.isSelected, + + const handleSearch = useCallback((e: Ui5CustomEvent) => { + setSearchTerm(e.target.value.trim()); + }, []); + + const handleVersionChange = useCallback( + (e: Ui5CustomEvent) => { + const selectedOption = e.detail.selectedOption as HTMLElement; + const name = selectedOption.dataset.name; + const version = selectedOption.dataset.version; + if (!name) return; + setComponentsList( + componentsList.map((component) => + component.name === name + ? { ...component, selectedVersion: version || '' } + : component, + ), + ); + }, + [componentsList, setComponentsList], + ); + + const isProviderDisabled = useCallback( + (component: ComponentsListItem) => { + if (!component.name?.includes('provider')) return false; + const crossplane = componentsList.find( + ({ name }) => name === 'crossplane', + ); + return crossplane?.isSelected === false; + }, + [componentsList], ); return ( @@ -83,54 +103,75 @@ export const ComponentsSelection: React.FC = ({ id="search" showClearIcon icon={} + value={searchTerm} + aria-label={t('common.search')} onInput={handleSearch} />
- {filteredComponents.map((component) => ( - - - - {/*This button will be implemented later*/} - {component.documentationUrl && ( - - )} - - {version} - - ))} - - - - ))} + {component.versions.map((version) => ( + + ))} + + + + ); + }) + ) : ( + + {t('componentsSelection.pleaseSelectComponents')} + + )}
{selectedComponents.length > 0 ? ( @@ -144,7 +185,7 @@ export const ComponentsSelection: React.FC = ({ ))} ) : ( - + {t('componentsSelection.pleaseSelectComponents')} )} diff --git a/src/components/ComponentsSelection/ComponentsSelectionContainer.tsx b/src/components/ComponentsSelection/ComponentsSelectionContainer.tsx index d489578e..1e0e4fbf 100644 --- a/src/components/ComponentsSelection/ComponentsSelectionContainer.tsx +++ b/src/components/ComponentsSelection/ComponentsSelectionContainer.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useRef } from 'react'; import { ComponentsSelection } from './ComponentsSelection.tsx'; import IllustratedError from '../Shared/IllustratedError.tsx'; @@ -7,64 +7,85 @@ import { sortVersions } from '../../utils/componentsVersions.ts'; import { ListManagedComponents } from '../../lib/api/types/crate/listManagedComponents.ts'; import useApiResource from '../../lib/api/useApiResource.ts'; import Loading from '../Shared/Loading.tsx'; -import { ComponentSelectionItem } from '../../lib/api/types/crate/createManagedControlPlane.ts'; - -export interface ComponentItem { - name: string; - versions: string[]; -} +import { ComponentsListItem } from '../../lib/api/types/crate/createManagedControlPlane.ts'; +import { useTranslation } from 'react-i18next'; export interface ComponentsSelectionProps { - selectedComponents: ComponentSelectionItem[]; - setSelectedComponents: React.Dispatch< - React.SetStateAction - >; + componentsList: ComponentsListItem[]; + setComponentsList: (components: ComponentsListItem[]) => void; } + +/** + * Returns the selected components. If Crossplane is not selected, + * provider components are excluded. + */ +export const getSelectedComponents = (components: ComponentsListItem[]) => { + const isCrossplaneSelected = components.some( + ({ name, isSelected }) => name === 'crossplane' && isSelected, + ); + return components.filter((component) => { + if (!component.isSelected) return false; + if (component.name?.includes('provider') && !isCrossplaneSelected) { + return false; + } + return true; + }); +}; + export const ComponentsSelectionContainer: React.FC< ComponentsSelectionProps -> = ({ setSelectedComponents, selectedComponents }) => { +> = ({ setComponentsList, componentsList }) => { const { - data: allManagedComponents, + data: availableManagedComponentsListData, error, isLoading, } = useApiResource(ListManagedComponents()); - const [isReady, setIsReady] = useState(false); + const { t } = useTranslation(); + const initialized = useRef(false); + useEffect(() => { if ( - allManagedComponents?.items.length === 0 || - !allManagedComponents?.items || - isReady - ) + initialized.current || + !availableManagedComponentsListData?.items || + availableManagedComponentsListData.items.length === 0 + ) { return; + } - setSelectedComponents( - allManagedComponents?.items?.map((item) => { + const newComponentsList = availableManagedComponentsListData.items.map( + (item) => { const versions = sortVersions(item.status.versions); return { name: item.metadata.name, - versions: versions, - selectedVersion: versions[0], + versions, + selectedVersion: versions[0] ?? '', isSelected: false, documentationUrl: '', }; - }) ?? [], + }, ); - setIsReady(true); - }, [allManagedComponents, isReady, setSelectedComponents]); + + setComponentsList(newComponentsList); + initialized.current = true; + }, [availableManagedComponentsListData, setComponentsList]); + if (isLoading) { return ; } - if (error) return ; + + if (error) { + return ; + } + + // Defensive: If the API returned no items, show error + if (!componentsList || componentsList.length === 0) { + return ; + } + return ( - <> - {selectedComponents.length > 0 ? ( - - ) : ( - - )} - + ); }; diff --git a/src/components/ControlPlanes/List/ControlPlaneListWorkspaceGridTile.tsx b/src/components/ControlPlanes/List/ControlPlaneListWorkspaceGridTile.tsx index 47b9a283..bd014ac9 100644 --- a/src/components/ControlPlanes/List/ControlPlaneListWorkspaceGridTile.tsx +++ b/src/components/ControlPlanes/List/ControlPlaneListWorkspaceGridTile.tsx @@ -39,7 +39,7 @@ import { useLink } from '../../../lib/shared/useLink.ts'; import IllustrationMessageType from '@ui5/webcomponents-fiori/dist/types/IllustrationMessageType.js'; import styles from './WorkspacesList.module.css'; import { ControlPlanesListMenu } from '../ControlPlanesListMenu.tsx'; -import { CreateManagedControlPlaneWizardContainer } from '../../Wizards/CreateManagedControlPlaneWizardContainer.tsx'; +import { CreateManagedControlPlaneWizardContainer } from '../../Wizards/CreateManagedControlPlane/CreateManagedControlPlaneWizardContainer.tsx'; interface Props { projectName: string; diff --git a/src/components/Dialogs/CreateWorkspaceDialogContainer.tsx b/src/components/Dialogs/CreateWorkspaceDialogContainer.tsx index 9b9991d4..3b794a92 100644 --- a/src/components/Dialogs/CreateWorkspaceDialogContainer.tsx +++ b/src/components/Dialogs/CreateWorkspaceDialogContainer.tsx @@ -23,6 +23,7 @@ import { useTranslation } from 'react-i18next'; import { zodResolver } from '@hookform/resolvers/zod'; import { useForm } from 'react-hook-form'; import { validationSchemaProjectWorkspace } from '../../lib/api/validations/schemas.ts'; +import { ComponentsListItem } from '../../lib/api/types/crate/createManagedControlPlane.ts'; export type CreateDialogProps = { name: string; @@ -30,6 +31,7 @@ export type CreateDialogProps = { chargingTarget?: string; chargingTargetType?: string; members: Member[]; + componentsList?: ComponentsListItem[]; }; export function CreateWorkspaceDialogContainer({ diff --git a/src/components/Wizards/CreateManagedControlPlaneWizardContainer.tsx b/src/components/Wizards/CreateManagedControlPlane/CreateManagedControlPlaneWizardContainer.tsx similarity index 68% rename from src/components/Wizards/CreateManagedControlPlaneWizardContainer.tsx rename to src/components/Wizards/CreateManagedControlPlane/CreateManagedControlPlaneWizardContainer.tsx index 6f4e43ed..ebef018e 100644 --- a/src/components/Wizards/CreateManagedControlPlaneWizardContainer.tsx +++ b/src/components/Wizards/CreateManagedControlPlane/CreateManagedControlPlaneWizardContainer.tsx @@ -1,53 +1,47 @@ import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useApiResourceMutation } from '../../lib/api/useApiResource'; + import IllustrationMessageType from '@ui5/webcomponents-fiori/dist/types/IllustrationMessageType.js'; -import { useAuthOnboarding } from '../../spaces/onboarding/auth/AuthContextOnboarding.tsx'; -import { Member, MemberRoles } from '../../lib/api/types/shared/members.ts'; + import type { WizardStepChangeEventDetail } from '@ui5/webcomponents-fiori/dist/Wizard.js'; import { zodResolver } from '@hookform/resolvers/zod'; import { useForm } from 'react-hook-form'; -import { validationSchemaCreateManagedControlPlane } from '../../lib/api/validations/schemas.ts'; -import { OnCreatePayload } from '../Dialogs/CreateProjectWorkspaceDialog.tsx'; + import { Bar, Button, Dialog, Form, FormGroup, - Grid, - List, - ListItemStandard, - Title, Ui5CustomEvent, Wizard, WizardDomRef, WizardStep, } from '@ui5/webcomponents-react'; -import YamlViewer from '../Yaml/YamlViewer.tsx'; -import { stringify } from 'yaml'; -import { APIError } from '../../lib/api/error.ts'; + +import { SummarizeStep } from './SummarizeStep.tsx'; +import { useTranslation } from 'react-i18next'; +import { useAuthOnboarding } from '../../../spaces/onboarding/auth/AuthContextOnboarding.tsx'; +import { + ErrorDialog, + ErrorDialogHandle, +} from '../../Shared/ErrorMessageBox.tsx'; +import { CreateDialogProps } from '../../Dialogs/CreateWorkspaceDialogContainer.tsx'; +import { validationSchemaCreateManagedControlPlane } from '../../../lib/api/validations/schemas.ts'; +import { Member, MemberRoles } from '../../../lib/api/types/shared/members.ts'; +import { useApiResourceMutation } from '../../../lib/api/useApiResource.ts'; import { - ComponentSelectionItem, + ComponentsListItem, CreateManagedControlPlane, CreateManagedControlPlaneResource, CreateManagedControlPlaneType, -} from '../../lib/api/types/crate/createManagedControlPlane.ts'; -import { ErrorDialog, ErrorDialogHandle } from '../Shared/ErrorMessageBox.tsx'; - -import { EditMembers } from '../Members/EditMembers.tsx'; -import { useTranslation } from 'react-i18next'; -import { MetadataForm } from '../Dialogs/MetadataForm.tsx'; -import { IllustratedBanner } from '../Ui/IllustratedBanner/IllustratedBanner.tsx'; -import { ComponentsSelectionContainer } from '../ComponentsSelection/ComponentsSelectionContainer.tsx'; -import { idpPrefix } from '../../utils/idpPrefix.ts'; - -export type CreateDialogProps = { - name: string; - displayName?: string; - chargingTarget?: string; - chargingTargetType?: string; - members: Member[]; -}; +} from '../../../lib/api/types/crate/createManagedControlPlane.ts'; +import { OnCreatePayload } from '../../Dialogs/CreateProjectWorkspaceDialog.tsx'; +import { idpPrefix } from '../../../utils/idpPrefix.ts'; +import { APIError } from '../../../lib/api/error.ts'; +import { MetadataForm } from '../../Dialogs/MetadataForm.tsx'; +import { EditMembers } from '../../Members/EditMembers.tsx'; +import { ComponentsSelectionContainer } from '../../ComponentsSelection/ComponentsSelectionContainer.tsx'; +import { IllustratedBanner } from '../../Ui/IllustratedBanner/IllustratedBanner.tsx'; type CreateManagedControlPlaneWizardContainerProps = { isOpen: boolean; @@ -79,9 +73,7 @@ export const CreateManagedControlPlaneWizardContainer: FC< const errorDialogRef = useRef(null); const [selectedStep, setSelectedStep] = useState('metadata'); - const [selectedComponents, setSelectedComponents] = useState< - ComponentSelectionItem[] - >([]); + const { register, handleSubmit, @@ -99,6 +91,7 @@ export const CreateManagedControlPlaneWizardContainer: FC< chargingTarget: '', chargingTargetType: '', members: [], + componentsList: [], }, mode: 'onChange', }); @@ -141,6 +134,7 @@ export const CreateManagedControlPlaneWizardContainer: FC< const { trigger } = useApiResourceMutation( CreateManagedControlPlaneResource(projectName, workspaceName), ); + const componentsList = watch('componentsList'); const handleCreateManagedControlPlane = useCallback( async ({ @@ -158,7 +152,7 @@ export const CreateManagedControlPlaneWizardContainer: FC< displayName, chargingTarget, members, - selectedComponents, + componentsList, }, idpPrefix, ), @@ -176,7 +170,7 @@ export const CreateManagedControlPlaneWizardContainer: FC< return false; } }, - [trigger, projectName, workspaceName, selectedComponents], + [trigger, projectName, workspaceName, componentsList], ); const handleStepChange = useCallback( @@ -223,6 +217,13 @@ export const CreateManagedControlPlaneWizardContainer: FC< [setValue], ); + const setComponentsList = useCallback( + (components: ComponentsListItem[]) => { + setValue('componentsList', components, { shouldValidate: false }); + }, + [setValue], + ); + const isStepDisabled = useCallback( (step: WizardStepType) => { switch (step) { @@ -240,6 +241,7 @@ export const CreateManagedControlPlaneWizardContainer: FC< return ( selectedStep === 'metadata' || selectedStep === 'members' || + selectedStep === 'componentSelection' || !isValid ); @@ -259,7 +261,9 @@ export const CreateManagedControlPlaneWizardContainer: FC< } }, [selectedStep]); - return isOpen ? ( + if (!isOpen) return null; + + return ( - {t('common.summarize')} - -
- - - - - - -
- - {getValues('members').map((member) => ( - - ))} - -
- - {selectedComponents - .filter(({ isSelected }) => isSelected) - .map((component) => ( - - ))} - -
-
- -
-
+
- ) : null; + ); }; diff --git a/src/components/Wizards/CreateManagedControlPlane/SummarizeStep.tsx b/src/components/Wizards/CreateManagedControlPlane/SummarizeStep.tsx new file mode 100644 index 00000000..b2e11710 --- /dev/null +++ b/src/components/Wizards/CreateManagedControlPlane/SummarizeStep.tsx @@ -0,0 +1,93 @@ +import { useTranslation } from 'react-i18next'; +import { Grid, List, ListItemStandard, Title } from '@ui5/webcomponents-react'; +import { stringify } from 'yaml'; +import { getSelectedComponents } from '../../ComponentsSelection/ComponentsSelectionContainer.tsx'; +import { + ComponentsListItem, + CreateManagedControlPlane, +} from '../../../lib/api/types/crate/createManagedControlPlane.ts'; +import YamlViewer from '../../Yaml/YamlViewer.tsx'; +import { idpPrefix } from '../../../utils/idpPrefix.ts'; +import { UseFormGetValues } from 'react-hook-form'; +import { CreateDialogProps } from '../../Dialogs/CreateWorkspaceDialogContainer.tsx'; + +interface SummarizeStepProps { + getValues: UseFormGetValues; + projectName: string; + workspaceName: string; + componentsList?: ComponentsListItem[]; +} + +export const SummarizeStep: React.FC = ({ + getValues, + projectName, + workspaceName, + componentsList, +}) => { + const { t } = useTranslation(); + return ( + <> + {t('common.summarize')} + +
+ + + + + + +
+ + {getValues('members').map((member) => ( + + ))} + +
+ + {getSelectedComponents(componentsList ?? []).map((component) => ( + + ))} + +
+
+ +
+
+ + ); +}; diff --git a/src/lib/api/types/crate/createManagedControlPlane.ts b/src/lib/api/types/crate/createManagedControlPlane.ts index e9a33b5d..7720edf8 100644 --- a/src/lib/api/types/crate/createManagedControlPlane.ts +++ b/src/lib/api/types/crate/createManagedControlPlane.ts @@ -9,7 +9,7 @@ import { Member } from '../shared/members'; export type Annotations = Record; export type Labels = Record; -export interface ComponentSelectionItem { +export interface ComponentsListItem { name: string; versions: string[]; isSelected: boolean; @@ -25,6 +25,11 @@ interface Subject { kind: 'User' | 'Group' | 'ServiceAccount'; name: string; } + +interface Provider { + name: string; + version: string; +} interface Spec { authentication: { enableSystemIdentityProvider: boolean; @@ -39,7 +44,8 @@ interface Components { | { version: string; } - | { type: 'GardenerDedicated' }; + | { type: 'GardenerDedicated' } + | { version: string; providers: Provider[] }; } export interface CreateManagedControlPlaneType { @@ -62,17 +68,41 @@ export const CreateManagedControlPlane = ( chargingTarget?: string; chargingTargetType?: string; members?: Member[]; - selectedComponents?: ComponentSelectionItem[]; + componentsList?: ComponentsListItem[]; }, idpPrefix?: string, ): CreateManagedControlPlaneType => { - const componentsObject: Components = - optional?.selectedComponents - ?.filter((component) => component.isSelected) + const selectedComponentsListObject: Components = + optional?.componentsList + ?.filter( + (component) => + component.isSelected && + !component.name.includes('provider') && + !component.name.includes('crossplane'), + ) .reduce((acc, item) => { acc[item.name] = { version: item.selectedVersion }; return acc; }, {} as Components) ?? {}; + const crossplaneComponent = optional?.componentsList?.find( + ({ name, isSelected }) => name === 'crossplane' && isSelected, + ); + + const providersListObject: Provider[] = + optional?.componentsList + ?.filter( + ({ name, isSelected }) => name.includes('provider') && isSelected, + ) + .map(({ name, selectedVersion }) => ({ + name: name, + version: selectedVersion, + })) ?? []; + const crossplaneWithProvidersListObject = { + crossplane: { + version: crossplaneComponent?.selectedVersion ?? '', + providers: providersListObject, + }, + }; return { apiVersion: 'core.openmcp.cloud/v1alpha1', @@ -91,8 +121,9 @@ export const CreateManagedControlPlane = ( spec: { authentication: { enableSystemIdentityProvider: true }, components: { - ...componentsObject, + ...selectedComponentsListObject, apiServer: { type: 'GardenerDedicated' }, + ...(crossplaneComponent ? crossplaneWithProvidersListObject : {}), }, authorization: { roleBindings: