diff --git a/public/i18n/en.yaml b/public/i18n/en.yaml index 96c06fd087..62a71962be 100644 --- a/public/i18n/en.yaml +++ b/public/i18n/en.yaml @@ -793,6 +793,7 @@ extensibility: error: The table widget configuration is incorrect. Path value must be an array type. modules: no-modules: No modules available + no-modules-installed: No modules installed documentation: DOCUMENTATION module-channel-label: Module channel overwrite module-channel-placeholder: Uses Default Kyma Channel @@ -952,6 +953,7 @@ kyma-modules: module-added: Module added module-uninstall: Module deleted module-documentation: Documentation + modules-channel: Modules Channel learn-more: Learn more add-module: Add Modules modify: Modify @@ -970,6 +972,9 @@ kyma-modules: channel-overridden: Overridden beta: Beta beta-alert: "CAUTION: The Service Level Agreements (SLAs) and Support obligations do not apply to Beta modules and functionalities. If Beta modules or functionalities directly or indirectly affect other modules, the Service Level Agreements and Support for these modules are limited to priority levels P3 (Medium) or P4 (Low). Thus, Beta releases are not intended for use in customer production environments." + change: Change + change-release-channel: Change Release Channel + change-release-channel-warning: The module's version in the fast channel is usually higher than the version in the regular channel. As downgrades are not supported, switching the channel back to regular will not affect the module's version <0>until the channel version is the same or higher. To use the lower version, you must delete the module and re-add it from the <0>regular channel. legal: copyright: Copyright legal-disclosure: Legal Disclosure diff --git a/src/components/KymaModules/KymaModulesCreate.js b/src/components/KymaModules/KymaModulesCreate.js index 8a2b1a9fdc..bad466a16c 100644 --- a/src/components/KymaModules/KymaModulesCreate.js +++ b/src/components/KymaModules/KymaModulesCreate.js @@ -1,28 +1,392 @@ +import { createPortal } from 'react-dom'; import { cloneDeep } from 'lodash'; import { useState } from 'react'; -import { useTranslation } from 'react-i18next'; +import { createPatch } from 'rfc6902'; +import { useRecoilState } from 'recoil'; +import { useTranslation, Trans } from 'react-i18next'; +import { useUrl } from 'hooks/useUrl'; + +import { useNotification } from 'shared/contexts/NotificationContext'; +import { useGet } from 'shared/hooks/BackendAPI/useGet'; +import { useUpdate } from 'shared/hooks/BackendAPI/useMutation'; +import { useSingleGet } from 'shared/hooks/BackendAPI/useGet'; +import { HttpError } from 'shared/hooks/BackendAPI/config'; +import { columnLayoutState } from 'state/columnLayoutAtom'; +import { ForceUpdateModalContent } from 'shared/ResourceForm/ForceUpdateModalContent'; + +import { + Button, + FlexBox, + Label, + MessageBox, + MessageStrip, + Option, + Select, +} from '@ui5/webcomponents-react'; +import { spacing } from '@ui5/webcomponents-react-base'; import { ResourceForm } from 'shared/ResourceForm'; import './KymaModulesCreate.scss'; +import { Spinner } from 'shared/components/Spinner/Spinner'; export default function KymaModulesCreate({ resource, ...props }) { const { t } = useTranslation(); const [kymaResource, setKymaResource] = useState(cloneDeep(resource)); const [initialResource] = useState(resource); - const [initialUnchangedResource] = useState(resource); + const [initialUnchangedResource] = useState(cloneDeep(resource)); + + const resourceName = kymaResource?.metadata.name; + const modulesResourceUrl = `/apis/operator.kyma-project.io/v1beta2/moduletemplates`; + + const { data: modules, loading } = useGet(modulesResourceUrl, { + pollingInterval: 3000, + skip: !resourceName, + }); + + const [layoutColumn, setLayoutColumn] = useRecoilState(columnLayoutState); + const notification = useNotification(); + const { scopedUrl } = useUrl(); + + const getRequest = useSingleGet(); + const patchRequest = useUpdate(); + const [selectedModules] = useState( + cloneDeep(initialResource?.spec?.modules) ?? [], + ); + const [isEdited, setIsEdited] = useState(false); + const [showMessageBox, setShowMessageBox] = useState({ + isOpen: false, + hide: false, + }); + + if (loading) { + return ( +
+ +
+ ); + } + + const setChannel = (module, channel, index) => { + if ( + selectedModules.find( + selectedModule => selectedModule.name === module.name, + ) + ) { + if (channel === 'predefined') { + delete selectedModules[index].channel; + } else selectedModules[index].channel = channel; + } else { + selectedModules.push({ + name: module.name, + }); + if (channel !== 'predefined') + selectedModules[selectedModules.length - 1].channel = channel; + } + + setKymaResource({ + ...kymaResource, + spec: { + ...kymaResource.spec, + modules: selectedModules, + }, + }); + setIsEdited(true); + }; + const installedModules = modules?.items.filter(module => { + const name = + module.metadata?.labels['operator.kyma-project.io/module-name']; + return ( + selectedModules?.findIndex(kymaResourceModule => { + return kymaResourceModule.name === name; + }) !== -1 + ); + }); + + const modulesEditData = (installedModules || []).reduce((acc, module) => { + const name = + module.metadata?.labels['operator.kyma-project.io/module-name']; + const existingModule = acc.find(item => item.name === name); + + if (!existingModule) { + acc.push({ + name: name, + channels: [ + { + channel: module.spec.channel, + version: module.spec.descriptor.component.version, + isBeta: + module.metadata.labels['operator.kyma-project.io/beta'] === + 'true', + }, + ], + docsUrl: + module.metadata.annotations['operator.kyma-project.io/doc-url'], + }); + } else { + existingModule.channels?.push({ + channel: module.spec.channel, + version: module.spec.descriptor.component.version, + isBeta: + module.metadata.labels['operator.kyma-project.io/beta'] === 'true', + }); + } + return acc; + }, []); + + const findStatus = moduleName => { + return kymaResource?.status?.modules?.find( + module => moduleName === module.name, + ); + }; + + const findSpec = moduleName => { + return kymaResource?.spec.modules?.find( + module => moduleName === module.name, + ); + }; + + const checkIfSelectedModuleIsBeta = moduleName => { + return selectedModules.some(({ name, channel }) => { + if (moduleName && name !== moduleName) { + return false; + } + const moduleData = modulesEditData?.find(module => module.name === name); + return moduleData + ? moduleData.channels.some( + ({ channel: ch, isBeta }) => ch === channel && isBeta, + ) + : false; + }); + }; + + const renderModules = () => { + const modulesList = []; + modulesEditData?.forEach((module, i) => { + const index = selectedModules?.findIndex(selectedModule => { + return selectedModule.name === module?.name; + }); + + const mod = ( + + + + + ); + modulesList.push(mod); + }); + + return
{modulesList}
; + }; + + const showError = error => { + console.error(error); + notification.notifyError({ + content: t('common.create-form.messages.patch-failure', { + resourceType: t('kyma-modules.kyma'), + error: error.message, + }), + }); + }; + + const onSuccess = () => { + notification.notifySuccess({ + content: t('common.create-form.messages.patch-success', { + resourceType: t('kyma-modules.kyma'), + }), + }); + setLayoutColumn({ + ...layoutColumn, + layout: 'OneColumn', + showCreate: null, + endColumn: { + resourceName: kymaResource.metadata.name, + resourceType: kymaResource.kind, + namespaceId: kymaResource.metadata.namespace, + }, + }); + window.history.pushState( + window.history.state, + '', + `${scopedUrl(`kymas/${encodeURIComponent(kymaResource.metadata.name)}`)}`, + ); + }; + const handleCreate = async () => { + try { + const diff = createPatch(initialUnchangedResource, kymaResource); + await patchRequest(props.resourceUrl, diff); + + onSuccess(); + } catch (e) { + const isConflict = e instanceof HttpError && e.code === 409; + if (isConflict) { + const response = await getRequest(props.resourceUrl); + const updatedResource = await response.json(); + + const makeForceUpdateFn = closeModal => { + return async () => { + kymaResource.metadata.resourceVersion = + initialUnchangedResource?.metadata.resourceVersion; + try { + await patchRequest( + props.resourceUrl, + createPatch(initialUnchangedResource, kymaResource), + ); + closeModal(); + onSuccess(); + } catch (e) { + showError(e); + } + }; + }; + + notification.notifyError({ + content: ( + + ), + actions: (closeModal, defaultCloseButton) => [ + , + defaultCloseButton(closeModal), + ], + wider: true, + }); + } else { + showError(e); + return false; + } + } + }; return ( - + <> + {createPortal( + { + setShowMessageBox({ isOpen: false, hide: true }); + }} + titleText={t('kyma-modules.change-release-channel')} + actions={[ + , + , + ]} + > + + + + , + document.body, + )} + { + if (isEdited && !showMessageBox.hide) { + setShowMessageBox({ ...showMessageBox, isOpen: true, hide: true }); + return true; + } + return false; + }} + > + + {modulesEditData?.length !== 0 ? ( + <> + {checkIfSelectedModuleIsBeta() ? ( + + {t('kyma-modules.beta-alert')} + + ) : null} + {renderModules()} + + ) : ( + + {t('extensibility.widgets.modules.no-modules-installed')} + + )} + + + ); } diff --git a/src/components/KymaModules/KymaModulesCreate.scss b/src/components/KymaModules/KymaModulesCreate.scss index 3103671bea..61690ba05e 100644 --- a/src/components/KymaModules/KymaModulesCreate.scss +++ b/src/components/KymaModules/KymaModulesCreate.scss @@ -16,5 +16,23 @@ .yaml-form { height: calc(100vh - 20rem); } + + .gridbox-editModule { + display: grid; + grid-template-columns: 3fr 3fr; + gap: 0.5rem 1rem; + } + + .channel-select { + width: 100%; + } + + .collapsible-margins { + padding: 0; + padding-left: 0; + padding-right: 0; + margin-left: -1rem; + margin-right: -1rem; + } } } diff --git a/src/components/KymaModules/ModulesCard.js b/src/components/KymaModules/ModulesCard.js index 08411eb1aa..1ae7618a65 100644 --- a/src/components/KymaModules/ModulesCard.js +++ b/src/components/KymaModules/ModulesCard.js @@ -9,6 +9,7 @@ import { Text, Title, } from '@ui5/webcomponents-react'; +import '@ui5/webcomponents/dist/features/InputElementsFormSupport.js'; import { ExternalLink } from 'shared/components/ExternalLink/ExternalLink'; import { useTranslation } from 'react-i18next'; import { spacing } from '@ui5/webcomponents-react-base'; diff --git a/src/shared/ResourceForm/components/CollapsibleSection.js b/src/shared/ResourceForm/components/CollapsibleSection.js index 4874aa7bdd..d4c5bd0b52 100644 --- a/src/shared/ResourceForm/components/CollapsibleSection.js +++ b/src/shared/ResourceForm/components/CollapsibleSection.js @@ -10,6 +10,7 @@ export function CollapsibleSection({ defaultOpen = undefined, canChangeState = true, title, + defaultTitleType = false, actions, children, resource, @@ -68,13 +69,16 @@ export function CollapsibleSection({ onClick={toggle} aria-label={`expand ${title}`} > - + {!defaultTitleType && ( + <Title + tooltipContent={tooltipContent} + title={title} + disabled={disabled} + canChangeState={canChangeState} + required={required} + /> + )} + {defaultTitleType && title} {actions && ( <> <ToolbarSpacer /> diff --git a/src/shared/ResourceForm/components/ResourceForm.js b/src/shared/ResourceForm/components/ResourceForm.js index 6801bbc233..f6fa38bd32 100644 --- a/src/shared/ResourceForm/components/ResourceForm.js +++ b/src/shared/ResourceForm/components/ResourceForm.js @@ -47,6 +47,7 @@ export function ResourceForm({ onPresetSelected, renderEditor, onSubmit, + skipCreateFn, afterCreatedFn, afterCreatedCustomMessage, className, @@ -124,6 +125,7 @@ export function ResourceForm({ resource, initialUnchangedResource, createUrl, + skipCreateFn, afterCreatedFn, urlPath, layoutNumber, diff --git a/src/shared/ResourceForm/useCreateResource.js b/src/shared/ResourceForm/useCreateResource.js index 00544e5622..1e584415d6 100644 --- a/src/shared/ResourceForm/useCreateResource.js +++ b/src/shared/ResourceForm/useCreateResource.js @@ -21,6 +21,7 @@ export function useCreateResource({ resource, initialUnchangedResource, createUrl, + skipCreateFn, afterCreatedFn, urlPath, layoutNumber, @@ -130,11 +131,7 @@ export function useCreateResource({ }); }; - return async e => { - if (e) { - e.preventDefault(); - } - + const handleCreate = async () => { try { if (isEdit) { const diff = createPatch(initialUnchangedResource, resource); @@ -190,4 +187,13 @@ export function useCreateResource({ } } }; + + return async e => { + if (e) { + e.preventDefault(); + } + if (skipCreateFn && skipCreateFn()) { + return null; + } else handleCreate(); + }; }