diff --git a/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/create-vm-wizard.scss b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/create-vm-wizard.scss new file mode 100644 index 00000000000..f213e451b4e --- /dev/null +++ b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/create-vm-wizard.scss @@ -0,0 +1,21 @@ +.kubevirt-create-vm-modal__container { + height: 100%; + display: flex; + flex-direction: column; +} + +.kubevirt-create-vm-modal__wizard-content { + flex-flow: wrap +} + +.pf-c-wizard__outer-wrap { + min-height: inherit; +} + +.pf-c-wizard__nav { + z-index: calc(var(--pf-global--ZIndex--xs) + 1); +} + +.pf-c-wizard__toggle { + z-index: calc(var(--pf-global--ZIndex--xs) + 2); +} diff --git a/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/create-vm-wizard.tsx b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/create-vm-wizard.tsx new file mode 100644 index 00000000000..4af2ea47dd0 --- /dev/null +++ b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/create-vm-wizard.tsx @@ -0,0 +1,330 @@ +import * as React from 'react'; +import * as _ from 'lodash'; +import { connect } from 'react-redux'; +import { createVm, createVmTemplate } from 'kubevirt-web-ui-components'; +import { Wizard } from '@patternfly/react-core'; +import { + PersistentVolumeClaimModel, + StorageClassModel, + TemplateModel, +} from '@console/internal/models'; +import { + Firehose, + history, + makeQuery, + makeReduxID, + units, +} from '@console/internal/components/utils'; +import { withReduxID } from '../../utils/redux/common'; +import { + DataVolumeModel, + NetworkAttachmentDefinitionModel, + VirtualMachineModel, +} from '../../models'; +import { TEMPLATE_TYPE_BASE, TEMPLATE_TYPE_LABEL, TEMPLATE_TYPE_VM } from '../../constants/vm'; +import { getResource } from '../../utils'; +import { EnhancedK8sMethods } from '../../k8s/enhancedK8sMethods/enhancedK8sMethods'; +import { + cleanupAndGetResults, + errorsFirstSort, + getResults, +} from '../../k8s/enhancedK8sMethods/k8sMethodsUtils'; +import { + concatImmutableLists, + iGetIn, + iGetLoadedData, + immutableListToShallowJS, +} from '../../utils/immutable'; +import { getTemplateOperatingSystems } from '../../selectors/vm-template/advanced'; +import { + ChangedCommonData, + CommonData, + CreateVMWizardComponentProps, + DetectCommonDataChanges, + VMSettingsField, + VMWizardProps, + VMWizardTab, +} from './types'; +import { + CREATE_VM, + CREATE_VM_TEMPLATE, + REVIEW_AND_CREATE, + TabTitleResolver, +} from './strings/strings'; +import { vmWizardActions } from './redux/actions'; +import { ActionType } from './redux/types'; +import { iGetCommonData, iGetCreateVMWizardTabs } from './selectors/immutable/selectors'; +import { isStepLocked, isStepValid } from './selectors/immutable/wizard-selectors'; +import { VMSettingsTab } from './tabs/vm-settings-tab/vm-settings-tab'; +import { ResourceLoadErrors } from './resource-load-errors'; + +import './create-vm-wizard.scss'; + +export class CreateVMWizardComponent extends React.Component { + private isClosed = false; + + constructor(props) { + super(props); + props.onInitialize(); + } + + componentDidUpdate(prevProps) { + if (this.isClosed) { + return; + } + const changedProps = [...DetectCommonDataChanges].reduce((changedPropsAcc, propName) => { + if (prevProps[propName] !== this.props[propName]) { + changedPropsAcc.add(propName); + } + return changedPropsAcc; + }, new Set()) as ChangedCommonData; + + if (changedProps.size > 0) { + this.props.onCommonDataChanged( + { dataIDReferences: this.props.dataIDReferences }, + changedProps, + ); + } + } + + onClose = () => { + this.isClosed = true; + this.props.onClose(); + }; + + finish() { + const create = this.props.isCreateTemplate ? createVmTemplate : createVm; + + const enhancedK8sMethods = new EnhancedK8sMethods(); + const vmSettings = iGetIn(this.props.stepData, [VMWizardTab.VM_SETTINGS, 'value']).toJS(); + const templates = immutableListToShallowJS( + concatImmutableLists( + iGetLoadedData(this.props[VMWizardProps.commonTemplates]), + iGetLoadedData(this.props[VMWizardProps.userTemplates]), + ), + ); + + // TODO remove after moving create functions from kubevirt-web-ui-components + /** * + * BEGIN kubevirt-web-ui-components InterOP + */ + vmSettings.namespace = { value: this.props.activeNamespace }; + const operatingSystems = getTemplateOperatingSystems(templates); + const osField = vmSettings[VMSettingsField.OPERATING_SYSTEM]; + const osID = osField.value; + osField.value = operatingSystems.find(({ id }) => id === osID); + /** + * END kubevirt-web-ui-components InterOP + */ + + create( + enhancedK8sMethods, + templates, + vmSettings, + iGetIn(this.props.stepData, [VMWizardTab.NETWORKS, 'value']).toJS(), + iGetIn(this.props.stepData, [VMWizardTab.STORAGE, 'value']).toJS(), + immutableListToShallowJS(iGetLoadedData(this.props[VMWizardProps.persistentVolumeClaims])), + units, + ) + .then(() => getResults(enhancedK8sMethods)) + .catch((error) => cleanupAndGetResults(enhancedK8sMethods, error)) + .then(({ results, valid }) => this.props.onResultsChanged(errorsFirstSort(results), valid)) // TODO should distinguish between errors and objects in redux + .catch((e) => console.error(e)); // eslint-disable-line no-console + } + + render() { + const { isCreateTemplate, stepData } = this.props; + const createVmText = isCreateTemplate ? CREATE_VM_TEMPLATE : CREATE_VM; + + if (this.isClosed) { + return null; + } + + const steps = [ + { + id: VMWizardTab.VM_SETTINGS, + component: ( + <> + + + + ), + nextButtonText: REVIEW_AND_CREATE, + }, + // { + // id: VMWizardTab.NETWORKS, + // }, + // { + // id: VMWizardTab.STORAGE, + // }, + { + id: VMWizardTab.REVIEW, + nextButtonText: createVmText, + component: <>VM review, + }, + { + id: VMWizardTab.RESULT, + component: ( + <> + {isStepValid(stepData, VMWizardTab.RESULT) ? 'Created Successfully' : 'Creation Failed'} + + ), + isFinishedStep: isStepValid(stepData, VMWizardTab.RESULT), + }, + ]; + + const isLocked = _.some(steps, ({ id }) => isStepLocked(stepData, id)); + + return ( +
+ {!isStepValid(stepData, VMWizardTab.RESULT) && ( +
+

{createVmText}

+
+ )} + { + if (id === VMWizardTab.RESULT) { + this.finish(); + } + }} + steps={steps.map((step, idx) => { + const valid = isStepValid(stepData, step.id); + const prevStepValid = idx === 0 ? true : isStepValid(stepData, steps[idx - 1].id); + return { + ...step, + name: TabTitleResolver[step.id], + enableNext: !isLocked && valid, + canJumpTo: !isLocked && prevStepValid && step.id !== VMWizardTab.RESULT, + hideBackButton: isLocked, + component: step.component, + }; + })} + /> +
+ ); + } +} + +const wizardStateToProps = (state, { reduxID }) => ({ + stepData: iGetCreateVMWizardTabs(state, reduxID), + // fetch data from store to detect and fire changes + ...[...DetectCommonDataChanges].reduce((acc, propName) => { + acc[propName] = iGetCommonData(state, reduxID, propName); + return acc; + }, {}), +}); + +const wizardDispatchToProps = (dispatch, props) => ({ + onInitialize: () => { + dispatch( + vmWizardActions[ActionType.Create](props.reduxID, { + data: { + isCreateTemplate: props.isCreateTemplate, + }, + dataIDReferences: props.dataIDReferences, + }), + ); + }, + onCommonDataChanged: (commonData: CommonData, changedCommonData: ChangedCommonData) => { + dispatch( + vmWizardActions[ActionType.UpdateCommonData](props.reduxID, commonData, changedCommonData), + ); + }, + onResultsChanged: (results, isValid) => { + dispatch(vmWizardActions[ActionType.SetResults](props.reduxID, results, isValid)); + }, + onClose: () => { + if (props.onClose) { + props.onClose(); + } + dispatch(vmWizardActions[ActionType.Dispose](props.reduxID, props)); + }, +}); + +export const CreateVMWizard = connect( + wizardStateToProps, + wizardDispatchToProps, +)(CreateVMWizardComponent); + +export const CreateVMWizardPageComponent: React.FC = (props) => { + const { + reduxID, + match: { + params: { ns: activeNamespace }, + }, + } = props; + const resources = [ + getResource(VirtualMachineModel, { + namespace: activeNamespace, + prop: VMWizardProps.virtualMachines, + }), + getResource(TemplateModel, { + namespace: activeNamespace, + prop: VMWizardProps.userTemplates, + matchLabels: { [TEMPLATE_TYPE_LABEL]: TEMPLATE_TYPE_VM }, + }), + getResource(TemplateModel, { + namespace: 'openshift', + prop: VMWizardProps.commonTemplates, + matchLabels: { [TEMPLATE_TYPE_LABEL]: TEMPLATE_TYPE_BASE }, + }), + getResource(NetworkAttachmentDefinitionModel, { + namespace: activeNamespace, + prop: VMWizardProps.networkConfigs, + }), + getResource(StorageClassModel, { prop: VMWizardProps.storageClasses }), + getResource(PersistentVolumeClaimModel, { + namespace: activeNamespace, + prop: VMWizardProps.persistentVolumeClaims, + }), + getResource(DataVolumeModel, { + namespace: activeNamespace, + prop: VMWizardProps.dataVolumes, + }), + ]; + + const dataIDReferences = resources.reduce((acc, resource) => { + const query = makeQuery( + resource.namespace, + resource.selector, + resource.fieldSelector, + resource.name, + ); + acc[resource.prop] = ['k8s', makeReduxID(resource.model, query)]; + + return acc; + }, {}); + + dataIDReferences[VMWizardProps.activeNamespace] = ['UI', 'activeNamespace']; + + return ( + + history.goBack()} + /> + + ); +}; + +type CreateVMWizardPageComponentProps = { + match: { + params: { + ns: string; + }; + path: string; + isExact: boolean; + url: string; + }; + reduxID: string; +}; + +export const CreateVMWizardPage = withReduxID(CreateVMWizardPageComponent); diff --git a/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/form/form-field-context.tsx b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/form/form-field-context.tsx new file mode 100644 index 00000000000..270011164ef --- /dev/null +++ b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/form/form-field-context.tsx @@ -0,0 +1,7 @@ +import * as React from 'react'; + +export const FormFieldContext = React.createContext({ + field: null, + fieldType: null, + isLoading: false, +}); diff --git a/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/form/form-field-row.tsx b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/form/form-field-row.tsx new file mode 100644 index 00000000000..a1c5bfc2cb1 --- /dev/null +++ b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/form/form-field-row.tsx @@ -0,0 +1,58 @@ +import * as React from 'react'; +import * as _ from 'lodash'; +import { getFieldHelp, getFieldId, getFieldTitle } from '../utils/vm-settings-tab-utils'; +import { isFieldHidden, isFieldRequired } from '../selectors/immutable/vm-settings'; +import { iGet, iGetIn, iGetIsLoaded } from '../../../utils/immutable'; +import { FormRow } from '../../form/form-row'; +import { FormFieldContext } from './form-field-context'; +import { FormFieldType } from './form-field'; + +const isLoading = (loadingResources?: { [k: string]: any }) => + loadingResources && + _.some(Object.keys(loadingResources), (key) => !iGetIsLoaded(loadingResources[key])); + +export const FormFieldRow: React.FC = ({ + field, + fieldType, + children, + loadingResources, +}) => { + const fieldKey = iGet(field, 'key'); + + if (!field || !fieldKey || isFieldHidden(field)) { + return null; + } + + const loading = isLoading(loadingResources); + + return ( + + + {children} + + + ); +}; + +type FieldFormRowProps = { + field: any; + fieldType: FormFieldType; + children?: React.ReactNode; + loadingResources?: { [k: string]: any }; +}; + +export const FormFieldMemoRow = React.memo( + FormFieldRow, + (prevProps, nextProps) => + prevProps.field === nextProps.field && + prevProps.fieldType === nextProps.fieldType && + isLoading(prevProps.loadingResources) === isLoading(nextProps.loadingResources), +); diff --git a/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/form/form-field.tsx b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/form/form-field.tsx new file mode 100644 index 00000000000..6247bdfee50 --- /dev/null +++ b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/form/form-field.tsx @@ -0,0 +1,80 @@ +import * as React from 'react'; +import * as _ from 'lodash'; +import { inject } from '@console/internal/components/utils'; +import { getPlaceholder, getFieldId, getFieldTitle } from '../utils/vm-settings-tab-utils'; +import { iGetIn } from '../../../utils/immutable'; +import { + iGetFieldKey, + iGetFieldValue, + isFieldDisabled, + isFieldRequired, +} from '../selectors/immutable/vm-settings'; +import { ValidationErrorType } from '../../../utils/validations/types'; +import { FormFieldContext } from './form-field-context'; + +export enum FormFieldType { + TEXT = 'TEXT', + TEXT_AREA = 'TEXT_AREA', + SELECT = 'SELECT', + CHECKBOX = 'CHECKBOX', + INLINE_CHECKBOX = 'INLINE_CHECKBOX', +} + +const hasValue = new Set([FormFieldType.TEXT, FormFieldType.TEXT_AREA, FormFieldType.SELECT]); +const hasIsDisabled = new Set([ + FormFieldType.TEXT, + FormFieldType.SELECT, + FormFieldType.CHECKBOX, + FormFieldType.INLINE_CHECKBOX, +]); +const hasDisabled = new Set([FormFieldType.TEXT_AREA]); +const hasIsChecked = new Set([FormFieldType.CHECKBOX, FormFieldType.INLINE_CHECKBOX]); +const hasIsRequired = new Set([FormFieldType.TEXT, FormFieldType.TEXT_AREA]); +const hasLabel = new Set([FormFieldType.INLINE_CHECKBOX]); + +const setSupported = (fieldType: FormFieldType, supportedTypes: Set, value) => + supportedTypes.has(fieldType) ? value : undefined; + +// renders only when props change (shallow compare) +export const FormField: React.FC = ({ children, isDisabled }) => { + return ( + + {({ + field, + fieldType, + isLoading, + }: { + field: any; + fieldType: FormFieldType; + isLoading: boolean; + }) => { + const set = setSupported.bind(undefined, fieldType); + const value = iGetFieldValue(field); + const key = iGetFieldKey(field); + const disabled = isDisabled || isFieldDisabled(field) || isLoading; + + return inject( + children, + _.omitBy( + { + value: hasValue.has(fieldType) ? value || getPlaceholder(key) || '' : undefined, + isChecked: set(hasIsChecked, value), + isDisabled: set(hasIsDisabled, disabled), + disabled: set(hasDisabled, disabled), + isRequired: set(hasIsRequired, isFieldRequired(field)), + isValid: iGetIn(field, ['validation', 'type']) !== ValidationErrorType.Error, + id: getFieldId(key), + label: set(hasLabel, getFieldTitle(key)), + }, + _.isUndefined, + ), + ); + }} + + ); +}; + +type FormFieldProps = { + children: React.ReactNode; + isDisabled?: boolean; +}; diff --git a/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/index.ts b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/index.ts new file mode 100644 index 00000000000..f48b4027611 --- /dev/null +++ b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/index.ts @@ -0,0 +1 @@ +export { CreateVMWizardPage } from './create-vm-wizard'; diff --git a/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/redux/actions.ts b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/redux/actions.ts new file mode 100644 index 00000000000..fa43557c765 --- /dev/null +++ b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/redux/actions.ts @@ -0,0 +1,85 @@ +import { + ALL_VM_WIZARD_TABS, + ChangedCommonDataProp, + ChangedCommonData, + CommonData, + DetectCommonDataChanges, + VMSettingsField, +} from '../types'; +import { cleanup, updateAndValidateState } from './utils'; +import { getTabInitialState } from './initial-state'; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { ActionType, InternalActionType, WizardActionDispatcher } from './types'; +import { vmWizardInternalActions } from './internal-actions'; + +type VMWizardActions = { [key in ActionType]: WizardActionDispatcher }; + +export const vmWizardActions: VMWizardActions = { + [ActionType.Create]: (id, commonData: CommonData) => (dispatch, getState) => { + const prevState = getState(); // must be called before dispatch + + dispatch( + vmWizardInternalActions[InternalActionType.Create](id, { + tabs: ALL_VM_WIZARD_TABS.reduce((initial, tabKey) => { + initial[tabKey] = getTabInitialState(tabKey, commonData); + return initial; + }, {}), + commonData, + }), + ); + + updateAndValidateState({ + id, + changedCommonData: new Set(DetectCommonDataChanges), + dispatch, + getState, + prevState, + }); + }, + [ActionType.Dispose]: (id) => (dispatch, getState) => { + const prevState = getState(); // must be called before dispatch + cleanup({ + id, + changedCommonData: new Set(), + dispatch, + prevState, + getState, + }); + + dispatch(vmWizardInternalActions[InternalActionType.Dispose](id)); + }, + [ActionType.SetVmSettingsFieldValue]: (id, key: VMSettingsField, value: string) => ( + dispatch, + getState, + ) => { + const prevState = getState(); // must be called before dispatch + dispatch(vmWizardInternalActions[InternalActionType.SetVmSettingsFieldValue](id, key, value)); + + updateAndValidateState({ + id, + dispatch, + changedCommonData: new Set(), + getState, + prevState, + }); + }, + [ActionType.UpdateCommonData]: (id, commonData: CommonData, changedProps: ChangedCommonData) => ( + dispatch, + getState, + ) => { + const prevState = getState(); // must be called before dispatch + + dispatch(vmWizardInternalActions[InternalActionType.UpdateCommonData](id, commonData)); + + updateAndValidateState({ id, dispatch, changedCommonData: changedProps, getState, prevState }); + }, + [ActionType.SetNetworks]: (id, value: any, valid: boolean, locked: boolean) => (dispatch) => { + dispatch(vmWizardInternalActions[InternalActionType.SetNetworks](id, value, valid, locked)); + }, + [ActionType.SetStorages]: (id, value: any, valid: boolean, locked: boolean) => (dispatch) => { + dispatch(vmWizardInternalActions[InternalActionType.SetStorages](id, value, valid, locked)); + }, + [ActionType.SetResults]: (id, value: any, valid: boolean) => (dispatch) => { + dispatch(vmWizardInternalActions[InternalActionType.SetResults](id, value, valid)); + }, +}; diff --git a/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/redux/initial-state/index.ts b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/redux/initial-state/index.ts new file mode 100644 index 00000000000..7960ee6ec64 --- /dev/null +++ b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/redux/initial-state/index.ts @@ -0,0 +1 @@ +export * from './initial-state'; diff --git a/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/redux/initial-state/initial-state.ts b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/redux/initial-state/initial-state.ts new file mode 100644 index 00000000000..47b13a8a2f6 --- /dev/null +++ b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/redux/initial-state/initial-state.ts @@ -0,0 +1,25 @@ +import { CommonData, VMWizardTab } from '../../types'; +import { getVmSettingsInitialState } from './vm-settings-tab-initial-state'; +import { getNetworksInitialState } from './networks-tab-initial-state'; +import { getStorageInitialState } from './storage-tab-initial-state'; +import { getResultInitialState } from './result-tab-initial-state'; +import { getReviewInitialState } from './review-tab-initial-state'; + +const initialStateGetterResolver = { + [VMWizardTab.VM_SETTINGS]: getVmSettingsInitialState, + [VMWizardTab.NETWORKS]: getNetworksInitialState, + [VMWizardTab.STORAGE]: getStorageInitialState, + [VMWizardTab.REVIEW]: getReviewInitialState, + [VMWizardTab.RESULT]: getResultInitialState, +}; + +export const getTabInitialState = (tabKey: VMWizardTab, props: CommonData) => { + const getter = initialStateGetterResolver[tabKey]; + + let result; + if (getter) { + result = getter(props); + } + + return result || { value: {}, valid: false }; +}; diff --git a/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/redux/initial-state/networks-tab-initial-state.ts b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/redux/initial-state/networks-tab-initial-state.ts new file mode 100644 index 00000000000..20d079972d5 --- /dev/null +++ b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/redux/initial-state/networks-tab-initial-state.ts @@ -0,0 +1,18 @@ +import { NetworkBinding, NetworkType, POD_NETWORK } from '../../../../constants/vm'; + +export const podNetwork = { + rootNetwork: {}, + id: 0, + name: 'nic0', + mac: '', + network: POD_NETWORK, + editable: true, + edit: false, + networkType: NetworkType.POD, + binding: NetworkBinding.MASQUERADE, +}; + +export const getNetworksInitialState = () => ({ + value: [podNetwork], + valid: true, +}); diff --git a/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/redux/initial-state/result-tab-initial-state.ts b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/redux/initial-state/result-tab-initial-state.ts new file mode 100644 index 00000000000..4f1a0b687fc --- /dev/null +++ b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/redux/initial-state/result-tab-initial-state.ts @@ -0,0 +1,4 @@ +export const getResultInitialState = () => ({ + value: {}, + valid: null, // result of the request +}); diff --git a/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/redux/initial-state/review-tab-initial-state.ts b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/redux/initial-state/review-tab-initial-state.ts new file mode 100644 index 00000000000..a016e2bb030 --- /dev/null +++ b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/redux/initial-state/review-tab-initial-state.ts @@ -0,0 +1,4 @@ +export const getReviewInitialState = () => ({ + value: {}, + valid: true, +}); diff --git a/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/redux/initial-state/storage-tab-initial-state.ts b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/redux/initial-state/storage-tab-initial-state.ts new file mode 100644 index 00000000000..de13ac8a42f --- /dev/null +++ b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/redux/initial-state/storage-tab-initial-state.ts @@ -0,0 +1,33 @@ +// left intentionally empty +import { ProvisionSource } from '../../../../types/vm'; +import { StorageType } from '../../../../constants/vm/storage'; + +const rootDisk = { + rootStorage: {}, + name: 'rootdisk', + isBootable: true, +}; +export const rootContainerDisk = { + ...rootDisk, + storageType: StorageType.CONTAINER, +}; +export const rootDataVolumeDisk = { + ...rootDisk, + storageType: StorageType.DATAVOLUME, + size: 10, +}; +export const getInitialDisk = (provisionSource: ProvisionSource) => { + switch (provisionSource) { + case ProvisionSource.URL: + return rootDataVolumeDisk; + case ProvisionSource.CONTAINER: + return rootContainerDisk; + default: + return null; + } +}; + +export const getStorageInitialState = () => ({ + value: [], + valid: true, // empty Storages are valid +}); diff --git a/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/redux/initial-state/vm-settings-tab-initial-state.ts b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/redux/initial-state/vm-settings-tab-initial-state.ts new file mode 100644 index 00000000000..33ec043464f --- /dev/null +++ b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/redux/initial-state/vm-settings-tab-initial-state.ts @@ -0,0 +1,63 @@ +import { OrderedSet } from 'immutable'; +import { CommonData, VMSettingsField, VMWizardProps } from '../../types'; +import { asHidden, asRequired } from '../../utils/utils'; +import { ProvisionSource } from '../../../../types/vm'; + +export const getInitialVmSettings = (common: CommonData) => { + const { + data: { isCreateTemplate }, + } = common; + + const provisionSources = [ + // ProvisionSource.PXE, // TODO: uncomment when storage tab is implemented + ProvisionSource.URL, + ProvisionSource.CONTAINER, + // ProvisionSource.CLONED_DISK, // TODO: uncomment when storage tab is implemented + ]; + + const fields = { + [VMSettingsField.NAME]: { + isRequired: asRequired(true), + }, + [VMSettingsField.DESCRIPTION]: {}, + [VMSettingsField.USER_TEMPLATE]: { + isHidden: asHidden(isCreateTemplate, VMWizardProps.isCreateTemplate), + initialized: false, + }, + [VMSettingsField.PROVISION_SOURCE_TYPE]: { + isRequired: asRequired(true), + sources: OrderedSet(provisionSources), + }, + [VMSettingsField.CONTAINER_IMAGE]: {}, + [VMSettingsField.IMAGE_URL]: {}, + [VMSettingsField.OPERATING_SYSTEM]: { + isRequired: asRequired(true), + }, + [VMSettingsField.FLAVOR]: { + isRequired: asRequired(true), + }, + [VMSettingsField.MEMORY]: {}, + [VMSettingsField.CPU]: {}, + [VMSettingsField.WORKLOAD_PROFILE]: { + isRequired: asRequired(true), + }, + [VMSettingsField.START_VM]: { + isHidden: asHidden(isCreateTemplate, VMWizardProps.isCreateTemplate), + }, + [VMSettingsField.USE_CLOUD_INIT]: {}, + [VMSettingsField.USE_CLOUD_INIT_CUSTOM_SCRIPT]: {}, + [VMSettingsField.HOST_NAME]: {}, + [VMSettingsField.AUTHKEYS]: {}, + [VMSettingsField.CLOUD_INIT_CUSTOM_SCRIPT]: {}, + }; + + Object.keys(fields).forEach((k) => { + fields[k].key = k; + }); + return fields; +}; + +export const getVmSettingsInitialState = (props) => ({ + value: getInitialVmSettings(props), + valid: false, +}); diff --git a/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/redux/internal-actions.ts b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/redux/internal-actions.ts new file mode 100644 index 00000000000..9b14cfdf007 --- /dev/null +++ b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/redux/internal-actions.ts @@ -0,0 +1,108 @@ +import { VMSettingsField, VMWizardTab } from '../types'; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { ActionBatch, InternalActionType, WizardInternalActionDispatcher } from './types'; + +type VMWizardInternalActions = { [key in InternalActionType]: WizardInternalActionDispatcher }; + +export const vmWizardInternalActions: VMWizardInternalActions = { + [InternalActionType.Create]: (id, value: any) => ({ + payload: { + id, + value, + }, + type: InternalActionType.Create, + }), + + [InternalActionType.Dispose]: (id) => ({ + payload: { + id, + }, + type: InternalActionType.Dispose, + }), + [InternalActionType.Update]: (id, value) => ({ + payload: { + id, + value, + }, + type: InternalActionType.Update, + }), + [InternalActionType.UpdateCommonData]: (id, value) => ({ + payload: { + id, + value, + }, + type: InternalActionType.UpdateCommonData, + }), + [InternalActionType.SetTabValidity]: (id, tab: VMWizardTab, valid: boolean) => ({ + payload: { + id, + tab, + valid, + }, + type: InternalActionType.SetTabValidity, + }), + [InternalActionType.SetVmSettingsFieldValue]: (id, key: VMSettingsField, value: string) => ({ + payload: { + id, + key, + value, + }, + type: InternalActionType.SetVmSettingsFieldValue, + }), + [InternalActionType.UpdateVmSettingsField]: (id, key: VMSettingsField, value) => ({ + payload: { + id, + key, + value, + }, + type: InternalActionType.UpdateVmSettingsField, + }), + [InternalActionType.SetInVmSettings]: (id, path: string[], value) => ({ + payload: { + id, + path, + value, + }, + type: InternalActionType.SetInVmSettings, + }), + [InternalActionType.SetInVmSettingsBatch]: (id, batch: ActionBatch) => ({ + payload: { + id, + batch, + }, + type: InternalActionType.SetInVmSettingsBatch, + }), + [InternalActionType.UpdateVmSettings]: (id, value) => ({ + payload: { + id, + value, + }, + type: InternalActionType.UpdateVmSettings, + }), + [InternalActionType.SetNetworks]: (id, value, valid: boolean, locked: boolean) => ({ + payload: { + id, + value, + valid, + locked, + }, + type: InternalActionType.SetNetworks, + }), + [InternalActionType.SetStorages]: (id, value, valid: boolean, locked: boolean) => ({ + payload: { + id, + value, + valid, + locked, + }, + type: InternalActionType.SetStorages, + }), + [InternalActionType.SetResults]: (id, value, valid: boolean) => ({ + payload: { + id, + value, + valid, + }, + type: InternalActionType.SetResults, + }), +}; diff --git a/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/redux/reducers.ts b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/redux/reducers.ts new file mode 100644 index 00000000000..9ca73dbde74 --- /dev/null +++ b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/redux/reducers.ts @@ -0,0 +1,100 @@ +import { Map as ImmutableMap, fromJS } from 'immutable'; +import { VMWizardTab } from '../types'; +import { InternalActionType, WizardInternalAction } from './types'; + +// Merge deep in without updating the keys with undefined values +const mergeDeepInSpecial = (state, path: string[], value) => + state.updateIn(path, (oldValue) => { + if (oldValue) { + return oldValue.mergeDeepWith( + (oldSubValue, newSubValue) => + typeof newSubValue === 'undefined' ? oldSubValue : newSubValue, + value, + ); + } + return value; + }); + +const TAB_UPDATE_KEYS = ['value', 'valid', 'locked']; + +const setTabKeys = (state, tab: VMWizardTab, action: WizardInternalAction) => + TAB_UPDATE_KEYS.reduce((nextState, key) => { + if (typeof action.payload[key] === 'undefined') { + return nextState; + } + return nextState.setIn([action.payload.id, 'tabs', tab, key], fromJS(action.payload[key])); + }, state); + +const setObjectValues = (state, path, obj) => { + return obj + ? Object.keys(obj).reduce( + (nextState, key) => nextState.setIn([...path, key], fromJS(obj[key])), + state, + ) + : state; +}; + +export default (state, action: WizardInternalAction) => { + if (!state) { + return ImmutableMap(); + } + const { payload } = action; + const dialogId = payload && payload.id; + + switch (action.type) { + case InternalActionType.Create: + return state.set(dialogId, fromJS(payload.value)); + case InternalActionType.Dispose: + return state.delete(dialogId); + case InternalActionType.SetNetworks: + return setTabKeys(state, VMWizardTab.NETWORKS, action); + case InternalActionType.SetStorages: + return setTabKeys(state, VMWizardTab.STORAGE, action); + case InternalActionType.SetResults: + return setTabKeys(state, VMWizardTab.RESULT, action); + case InternalActionType.Update: + return mergeDeepInSpecial(state, [dialogId], fromJS(payload.value)); + case InternalActionType.UpdateCommonData: + return setObjectValues( + setObjectValues(state, [dialogId, 'commonData', 'data'], payload.value.data), + [dialogId, 'commonData', 'dataIDReferences'], + payload.value.dataIDReferences, + ); + case InternalActionType.SetTabValidity: + return state.setIn([dialogId, 'tabs', payload.tab, 'valid'], payload.valid); + case InternalActionType.SetVmSettingsFieldValue: + return state.setIn( + [dialogId, 'tabs', VMWizardTab.VM_SETTINGS, 'value', payload.key, 'value'], + fromJS(payload.value), + ); + case InternalActionType.SetInVmSettings: + return state.setIn( + [dialogId, 'tabs', VMWizardTab.VM_SETTINGS, 'value', ...payload.path], + fromJS(payload.value), + ); + case InternalActionType.SetInVmSettingsBatch: + return payload.batch.reduce( + (nextState, { path, value }) => + nextState.setIn( + [dialogId, 'tabs', VMWizardTab.VM_SETTINGS, 'value', ...path], + fromJS(value), + ), + state, + ); + case InternalActionType.UpdateVmSettingsField: + return mergeDeepInSpecial( + state, + [dialogId, 'tabs', VMWizardTab.VM_SETTINGS, 'value', payload.key], + fromJS(payload.value), + ); + case InternalActionType.UpdateVmSettings: + return mergeDeepInSpecial( + state, + [dialogId, 'tabs', VMWizardTab.VM_SETTINGS, 'value'], + fromJS(payload.value), + ); + default: + break; + } + return state; +}; diff --git a/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/redux/stateUpdate/vmSettings/prefill-vm-template-state-update.ts b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/redux/stateUpdate/vmSettings/prefill-vm-template-state-update.ts new file mode 100644 index 00000000000..929b2ad3ef4 --- /dev/null +++ b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/redux/stateUpdate/vmSettings/prefill-vm-template-state-update.ts @@ -0,0 +1,158 @@ +import * as _ from 'lodash'; +import { InternalActionType, UpdateOptions } from '../../types'; +import { iGetVmSettingValue } from '../../../selectors/immutable/vm-settings'; +import { VMSettingsField, VMWizardProps } from '../../../types'; +import { iGetLoadedCommonData, iGetName } from '../../../selectors/immutable/selectors'; +import { concatImmutableLists, immutableListToShallowJS } from '../../../../../utils/immutable'; +import { iGetNetworks as getDialogNetworks } from '../../../selectors/immutable/networks'; +import { iGetStorages } from '../../../selectors/immutable/storage'; +import { podNetwork } from '../../initial-state/networks-tab-initial-state'; +import { vmWizardInternalActions } from '../../internal-actions'; +import { CUSTOM_FLAVOR } from '../../../../../constants/vm'; +import { + DEFAULT_CPU, + getCloudInitUserData, + getCPU, + getInterfaces, + getMemory, + getNetworks, + hasAutoAttachPodInterface, + parseCPU, +} from '../../../../../selectors/vm'; +import { selectVM } from '../../../../../selectors/vm-template/selectors'; +import { + getTemplateFlavors, + getTemplateOperatingSystems, + getTemplateWorkloadProfiles, +} from '../../../../../selectors/vm-template/advanced'; +import { ProvisionSource } from '../../../../../types/vm'; +import { + getTemplateProvisionSource, + getTemplateStorages, +} from '../../../../../selectors/vm-template/combined'; +import { getFlavors } from '../../../../../selectors/vm-template/combined-dependent'; + +// used by user template; currently we do not support PROVISION_SOURCE_IMPORT +const provisionSourceDataFieldResolver = { + [ProvisionSource.CONTAINER]: VMSettingsField.CONTAINER_IMAGE, + [ProvisionSource.URL]: VMSettingsField.IMAGE_URL, +}; + +export const prefillVmTemplateUpdater = ({ id, dispatch, getState }: UpdateOptions) => { + const state = getState(); + + const userTemplateName = iGetVmSettingValue(state, id, VMSettingsField.USER_TEMPLATE); + + const iUserTemplates = iGetLoadedCommonData(state, id, VMWizardProps.userTemplates); + + const iUserTemplate = + userTemplateName && iUserTemplates + ? iUserTemplates.find((template) => iGetName(template) === userTemplateName) + : null; + + const vmSettingsUpdate = {}; + + // filter out oldTemplates + const networkRowsUpdate = immutableListToShallowJS(getDialogNetworks(state, id)).filter( + (network) => !network.templateNetwork, + ); + const storageRowsUpdate = immutableListToShallowJS(iGetStorages(state, id)).filter( + (storage) => !(storage.templateStorage || storage.rootStorage), + ); + + if (!networkRowsUpdate.find((row) => row.rootNetwork)) { + networkRowsUpdate.push(podNetwork); + } + + if (iUserTemplate) { + const dataVolumes = immutableListToShallowJS( + iGetLoadedCommonData(state, id, VMWizardProps.userTemplates), + ); + const userTemplate = iUserTemplate.toJS(); + + const vm = selectVM(userTemplate); + + // update flavor + const [flavor] = getTemplateFlavors([userTemplate]); + vmSettingsUpdate[VMSettingsField.FLAVOR] = { value: flavor }; + if (flavor === CUSTOM_FLAVOR) { + vmSettingsUpdate[VMSettingsField.CPU] = { value: parseCPU(getCPU(vm), DEFAULT_CPU).cores }; // TODO also add sockets + threads + const memory = getMemory(vm); + vmSettingsUpdate[VMSettingsField.MEMORY] = { value: memory ? parseInt(memory, 10) : null }; + } + + // update os + const [os] = getTemplateOperatingSystems([userTemplate]); + vmSettingsUpdate[VMSettingsField.OPERATING_SYSTEM] = { value: os && os.id }; + + // update workload profile + const [workload] = getTemplateWorkloadProfiles([userTemplate]); + vmSettingsUpdate[VMSettingsField.WORKLOAD_PROFILE] = { value: workload }; + + // update cloud-init + const cloudInitUserData = getCloudInitUserData(vm); + if (cloudInitUserData) { + vmSettingsUpdate[VMSettingsField.USE_CLOUD_INIT] = { value: true }; + vmSettingsUpdate[VMSettingsField.USE_CLOUD_INIT_CUSTOM_SCRIPT] = { value: true }; + vmSettingsUpdate[VMSettingsField.CLOUD_INIT_CUSTOM_SCRIPT] = { + value: cloudInitUserData || '', + }; + } + + // update provision source + const provisionSource = getTemplateProvisionSource(userTemplate, dataVolumes); + if (provisionSource.type === ProvisionSource.UNKNOWN) { + vmSettingsUpdate[VMSettingsField.PROVISION_SOURCE_TYPE] = { value: null }; + } else { + vmSettingsUpdate[VMSettingsField.PROVISION_SOURCE_TYPE] = { value: provisionSource.type }; + const dataFieldName = provisionSourceDataFieldResolver[provisionSource.type]; + if (dataFieldName) { + vmSettingsUpdate[dataFieldName] = { value: provisionSource.source }; + } + } + + // prefill networks + const templateNetworks = getInterfaces(vm).map((i) => ({ + templateNetwork: { + network: getNetworks(vm).find((n) => n.name === i.name), + interface: i, + }, + })); + + // do not add root interface if there already is one or autoAttachPodInterface is set to false + if ( + templateNetworks.some((network) => network.templateNetwork.network.pod) || + !hasAutoAttachPodInterface(vm, true) + ) { + const index = _.findIndex(networkRowsUpdate, (network: any) => network.rootNetwork); + networkRowsUpdate.splice(index, 1); + } + + networkRowsUpdate.push(...templateNetworks); + + // prefill storage + const templateStorages = getTemplateStorages(userTemplate, dataVolumes).map((storage) => ({ + templateStorage: storage, + rootStorage: storage.disk.bootOrder === 1 ? {} : undefined, + })); + storageRowsUpdate.push(...templateStorages); + } else { + const iCommonTemplates = iGetLoadedCommonData(state, id, VMWizardProps.commonTemplates); + + const flavors = getFlavors( + immutableListToShallowJS(concatImmutableLists(iCommonTemplates, iUserTemplates)), + { + workload: iGetVmSettingValue(state, id, VMSettingsField.WORKLOAD_PROFILE), + os: iGetVmSettingValue(state, id, VMSettingsField.OPERATING_SYSTEM), + userTemplate: null, + }, + ); + if (flavors.length === 1) { + vmSettingsUpdate[VMSettingsField.FLAVOR] = { value: flavors[0] }; + } + } + + dispatch(vmWizardInternalActions[InternalActionType.UpdateVmSettings](id, vmSettingsUpdate)); + dispatch(vmWizardInternalActions[InternalActionType.SetNetworks](id, networkRowsUpdate)); + dispatch(vmWizardInternalActions[InternalActionType.SetStorages](id, storageRowsUpdate)); +}; diff --git a/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/redux/stateUpdate/vmSettings/vm-settings-tab-state-update.ts b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/redux/stateUpdate/vmSettings/vm-settings-tab-state-update.ts new file mode 100644 index 00000000000..36677a0320a --- /dev/null +++ b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/redux/stateUpdate/vmSettings/vm-settings-tab-state-update.ts @@ -0,0 +1,207 @@ +import { + hasVmSettingsChanged, + iGetVmSettingAttribute, + iGetVmSettingValue, +} from '../../../selectors/immutable/vm-settings'; +import { VMSettingsField, VMWizardProps } from '../../../types'; +import { InternalActionType, UpdateOptions } from '../../types'; +import { asDisabled, asHidden, asRequired } from '../../../utils/utils'; +import { vmWizardInternalActions } from '../../internal-actions'; +import { + iGetCommonData, + iGetLoadedCommonData, + iGetName, +} from '../../../selectors/immutable/selectors'; +import { + iGetIsLoaded, + iGetLoadedData, + immutableListToShallowJS, +} from '../../../../../utils/immutable'; +import { ProvisionSource } from '../../../../../types/vm'; +import { ignoreCaseSort } from '../../../../../utils/sort'; +import { CUSTOM_FLAVOR } from '../../../../../constants/vm'; +import { iGetStorages } from '../../../selectors/immutable/storage'; +import { getInitialDisk } from '../../initial-state/storage-tab-initial-state'; +import { prefillVmTemplateUpdater } from './prefill-vm-template-state-update'; + +export const selectUserTemplateOnLoadedUpdater = ({ + id, + dispatch, + getState, + changedCommonData, +}: UpdateOptions) => { + const state = getState(); + if ( + iGetCommonData(state, id, VMWizardProps.isCreateTemplate) || + iGetVmSettingAttribute(state, id, VMSettingsField.USER_TEMPLATE, 'initialized') || + !( + changedCommonData.has(VMWizardProps.userTemplates) || + changedCommonData.has(VMWizardProps.commonTemplates) || + changedCommonData.has(VMWizardProps.dataVolumes) + ) + ) { + return; + } + + const iUserTemplatesWrapper = iGetCommonData(state, id, VMWizardProps.userTemplates); + const iCommonTemplatesWrapper = iGetCommonData(state, id, VMWizardProps.commonTemplates); // flavor prefill + const iDataVolumeWrapper = iGetCommonData(state, id, VMWizardProps.dataVolumes); // template prefill + + if ( + !iGetIsLoaded(iUserTemplatesWrapper) || + !iGetIsLoaded(iCommonTemplatesWrapper) || + !iGetIsLoaded(iDataVolumeWrapper) + ) { + return; + } + + const iUserTemplates = iGetLoadedData(iUserTemplatesWrapper); + + const firstUserTemplateName = ignoreCaseSort( + iUserTemplates + .toIndexedSeq() + .toArray() + .map(iGetName), + )[0]; + + dispatch( + vmWizardInternalActions[InternalActionType.UpdateVmSettings](id, { + [VMSettingsField.USER_TEMPLATE]: { + initialized: true, + value: firstUserTemplateName, + }, + }), + ); +}; + +export const selectedUserTemplateUpdater = (options: UpdateOptions) => { + const { id, prevState, dispatch, getState } = options; + const state = getState(); + if (!hasVmSettingsChanged(prevState, state, id, VMSettingsField.USER_TEMPLATE)) { + return; + } + + const userTemplates = iGetLoadedCommonData(state, id, VMWizardProps.userTemplates); + + const userTemplateName = iGetVmSettingValue(state, id, VMSettingsField.USER_TEMPLATE); + + const iUserTemplate = + userTemplateName && userTemplates + ? userTemplates.find((template) => iGetName(template) === userTemplateName) + : null; + + const isDisabled = asDisabled(iUserTemplate != null, VMSettingsField.USER_TEMPLATE); + + dispatch( + vmWizardInternalActions[InternalActionType.UpdateVmSettings](id, { + [VMSettingsField.PROVISION_SOURCE_TYPE]: { isDisabled }, + [VMSettingsField.CONTAINER_IMAGE]: { isDisabled }, + [VMSettingsField.IMAGE_URL]: { isDisabled }, + [VMSettingsField.OPERATING_SYSTEM]: { isDisabled }, + [VMSettingsField.WORKLOAD_PROFILE]: { isDisabled }, + }), + ); + + if (iGetVmSettingAttribute(state, id, VMSettingsField.USER_TEMPLATE, 'initialized')) { + prefillVmTemplateUpdater(options); + } +}; + +export const provisioningSourceUpdater = ({ id, prevState, dispatch, getState }: UpdateOptions) => { + const state = getState(); + if ( + !hasVmSettingsChanged( + prevState, + state, + id, + VMSettingsField.PROVISION_SOURCE_TYPE, + VMSettingsField.USER_TEMPLATE, + ) + ) { + return; + } + const source = iGetVmSettingValue(state, id, VMSettingsField.PROVISION_SOURCE_TYPE); + const isContainer = source === ProvisionSource.CONTAINER; + const isUrl = source === ProvisionSource.URL; + + dispatch( + vmWizardInternalActions[InternalActionType.UpdateVmSettings](id, { + [VMSettingsField.CONTAINER_IMAGE]: { + value: isContainer ? undefined : null, + isRequired: asRequired(isContainer, VMSettingsField.PROVISION_SOURCE_TYPE), + isHidden: asHidden(!isContainer, VMSettingsField.PROVISION_SOURCE_TYPE), + }, + [VMSettingsField.IMAGE_URL]: { + value: isUrl ? undefined : null, + isRequired: asRequired(isUrl, VMSettingsField.PROVISION_SOURCE_TYPE), + isHidden: asHidden(!isUrl, VMSettingsField.PROVISION_SOURCE_TYPE), + }, + }), + ); +}; + +// TODO: move this logic to StorageTab? +export const prefillInitialDiskUpdater = ({ id, prevState, dispatch, getState }: UpdateOptions) => { + const state = getState(); + if ( + !hasVmSettingsChanged( + prevState, + state, + id, + VMSettingsField.PROVISION_SOURCE_TYPE, + VMSettingsField.USER_TEMPLATE, + ) + ) { + return; + } + + const storageRowsUpdate = immutableListToShallowJS(iGetStorages(state, id)).filter( + (storage) => storage.templateStorage || !storage.rootStorage, + ); + // template pre-fills its own storages + if (!iGetVmSettingValue(state, id, VMSettingsField.USER_TEMPLATE)) { + const storage = getInitialDisk( + iGetVmSettingValue(state, id, VMSettingsField.PROVISION_SOURCE_TYPE), + ); + if (storage) { + storageRowsUpdate.push(storage); + } + } + + dispatch(vmWizardInternalActions[InternalActionType.SetStorages](id, storageRowsUpdate)); +}; + +export const flavorUpdater = ({ id, prevState, dispatch, getState }: UpdateOptions) => { + const state = getState(); + if (!hasVmSettingsChanged(prevState, state, id, VMSettingsField.FLAVOR)) { + return; + } + const flavor = iGetVmSettingValue(state, id, VMSettingsField.FLAVOR); + + const isHidden = asHidden(flavor !== CUSTOM_FLAVOR, VMSettingsField.FLAVOR); + const isRequired = asRequired(flavor === CUSTOM_FLAVOR, VMSettingsField.FLAVOR); + + dispatch( + vmWizardInternalActions[InternalActionType.UpdateVmSettings](id, { + [VMSettingsField.MEMORY]: { + isHidden, + isRequired, + }, + [VMSettingsField.CPU]: { + isHidden, + isRequired, + }, + }), + ); +}; + +export const updateVmSettingsState = (options: UpdateOptions) => + [ + selectUserTemplateOnLoadedUpdater, + selectedUserTemplateUpdater, + provisioningSourceUpdater, + prefillInitialDiskUpdater, + flavorUpdater, + ].forEach((updater) => { + updater && updater(options); + }); diff --git a/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/redux/types.ts b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/redux/types.ts new file mode 100644 index 00000000000..a6d0d21dee2 --- /dev/null +++ b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/redux/types.ts @@ -0,0 +1,80 @@ +import { + ChangedCommonData, + ChangedCommonDataProp, + VMSettingsField, + VMSettingsFieldType, + VMWizardTab, +} from '../types'; +import { ValidationObject } from '../../../utils/validations/types'; + +export enum ActionType { + Create = 'KubevirtVMWizardCreate', + Dispose = 'KubevirtVMWizardDispose', + UpdateCommonData = 'KubevirtVMWizardUpdateCommonData', + SetVmSettingsFieldValue = 'KubevirtVMWizardSetVmSettingsFieldValue', + SetNetworks = 'KubevirtVMWizardSetNetworks', + SetStorages = 'KubevirtVMWizardSetStorages', + SetResults = 'KubevirtVMWizardSetResults', +} + +// should not be called directly from outside redux code (e.g. stateUpdate) +export enum InternalActionType { + Create = 'KubevirtVMWizardCreate', + Dispose = 'KubevirtVMWizardDispose', + Update = 'KubevirtVMWizardUpdateInternal', + UpdateCommonData = 'KubevirtVMWizardUpdateCommonData', + SetTabValidity = 'KubevirtVMWizardSetTabValidityInternal', + SetVmSettingsFieldValue = 'KubevirtVMWizardSetVmSettingsFieldValueInternal', + SetInVmSettings = 'KubevirtVMWizardSetInVmSettingsInternal', + SetInVmSettingsBatch = 'KubevirtVMWizardSetInVmSettingsBatchInternal', + UpdateVmSettingsField = 'KubevirtVMWizardUpdateVmSettingsFieldInternal', + UpdateVmSettings = 'KubevirtVMWizardUpdateVmSettingsInternal', + SetNetworks = 'KubevirtVMWizardSetNetworks', + SetStorages = 'KubevirtVMWizardSetStorages', + SetResults = 'KubevirtVMWizardSetResults', +} + +export type WizardInternalAction = { + type: InternalActionType; + payload: { + id: string; + value?: any; + valid?: boolean; + locked?: boolean; + path?: string[]; + key?: VMSettingsField; + tab?: VMWizardTab; + batch?: ActionBatch; + }; +}; + +export type WizardInternalActionDispatcher = (id: string, ...any) => WizardInternalAction; +export type WizardActionDispatcher = ( + id: string, + ...any +) => (dispatch: Function, getState: Function) => void; + +export type ActionBatch = { path: string[]; value: any }[]; + +export type UpdateOptions = { + id: string; + changedCommonData: ChangedCommonData; + dispatch: Function; + getState: Function; + prevState: any; +}; + +export type VmSettingsValidator = ( + field: VMSettingsFieldType, + options: UpdateOptions, +) => ValidationObject; + +export type VMSettingsValidationConfig = { + [key: string]: { + detectValueChanges?: ((field, options) => VMSettingsField[]) | VMSettingsField[]; + detectCommonDataChanges?: + | ((field, options) => ChangedCommonDataProp[]) + | ChangedCommonDataProp[]; + validator: VmSettingsValidator; + }; +}; diff --git a/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/redux/utils.ts b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/redux/utils.ts new file mode 100644 index 00000000000..f6b08d810f1 --- /dev/null +++ b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/redux/utils.ts @@ -0,0 +1,56 @@ +import { VMWizardTab } from '../types'; +import { UpdateOptions } from './types'; +import { updateVmSettingsState } from './stateUpdate/vmSettings/vm-settings-tab-state-update'; +import { + setVmSettingsTabValidity, + validateVmSettings, +} from './validations/vm-settings-tab-validation'; + +const UPDATE_TABS = [VMWizardTab.VM_SETTINGS]; + +const updaterResolver = { + [VMWizardTab.VM_SETTINGS]: updateVmSettingsState, +}; + +const validateTabResolver = { + [VMWizardTab.VM_SETTINGS]: validateVmSettings, +}; + +const isTabValidResolver = { + [VMWizardTab.VM_SETTINGS]: setVmSettingsTabValidity, +}; + +export const updateAndValidateState = (options: UpdateOptions) => { + const { prevState, changedCommonData, getState } = options; + + const propsChanged = Object.keys(changedCommonData).some((key) => changedCommonData[key]); + const enhancedOptions = { ...options, propsChanged }; + + UPDATE_TABS.forEach((tabKey) => { + const updater = updaterResolver[tabKey]; + updater && updater(enhancedOptions); + }); + + if (propsChanged || prevState !== getState()) { + UPDATE_TABS.forEach((tabKey) => { + const dataValidator = validateTabResolver[tabKey]; + const tabValidator = isTabValidResolver[tabKey]; + + if (dataValidator) { + dataValidator(options); + } + + if (tabValidator) { + tabValidator(enhancedOptions); + } + }); + } +}; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const cleanup = (options: UpdateOptions) => { + // TODO (suomiy): add providers + // getProviders().forEach((provider) => { + // cleanupProvider(provider, options); + // }); +}; diff --git a/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/redux/validations/utils.ts b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/redux/validations/utils.ts new file mode 100644 index 00000000000..59c395f6d9f --- /dev/null +++ b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/redux/validations/utils.ts @@ -0,0 +1,42 @@ +import { Map as ImmutableMap } from 'immutable'; +import * as _ from 'lodash'; +import { UpdateOptions, VMSettingsValidationConfig } from '../types'; +import { ValidationObject } from '../../../../utils/validations/types'; +import { VMSettingsFieldType } from '../../types'; + +export const getValidationUpdate = ( + config: VMSettingsValidationConfig, + options: UpdateOptions, + fields: ImmutableMap, + compareField: (prevState, state, id: string, key: any) => boolean, +) => { + const { id, changedCommonData, prevState, getState } = options; + const state = getState(); + + return Object.keys(config).reduce((updateAcc, validationFieldKey) => { + const { detectValueChanges, detectCommonDataChanges, validator } = config[validationFieldKey]; + + const field = fields.get(validationFieldKey); + + const detectValues = _.isFunction(detectValueChanges) + ? detectValueChanges(field, options) + : detectValueChanges; + const detectCommonData = _.isFunction(detectCommonDataChanges) + ? detectCommonDataChanges(field, options) + : detectCommonDataChanges; + + const needsValidationUpdate = + (detectValues && + detectValues.some((fieldKey) => compareField(prevState, state, id, fieldKey))) || + (detectCommonData && detectCommonData.some((fieldKey) => changedCommonData[fieldKey])); + + if (needsValidationUpdate) { + const validation = validator(field, options); + // null -> value || oldValue -> null || oldValue -> value + if (field.get('validation') || validation) { + updateAcc[validationFieldKey] = { validation }; + } + } + return updateAcc; + }, {}) as { [key: string]: { validation: ValidationObject } }; +}; diff --git a/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/redux/validations/vm-settings-tab-validation.ts b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/redux/validations/vm-settings-tab-validation.ts new file mode 100644 index 00000000000..fe073bfb274 --- /dev/null +++ b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/redux/validations/vm-settings-tab-validation.ts @@ -0,0 +1,168 @@ +import { isEmpty } from 'lodash'; +import { List } from 'immutable'; +import { VMSettingsField, VMWizardProps, VMWizardTab } from '../../types'; +import { + iGetFieldKey, + iGetVmSettings, + hasVmSettingsChanged, + iGetFieldValue, + isFieldRequired, +} from '../../selectors/immutable/vm-settings'; +import { + InternalActionType, + UpdateOptions, + VMSettingsValidationConfig, + VmSettingsValidator, +} from '../types'; +import { vmWizardInternalActions } from '../internal-actions'; +import { ValidationErrorType, ValidationObject } from '../../../../utils/validations/types'; +import { + validateUserTemplateProvisionSource, + validateVmLikeEntityName, +} from '../../../../utils/validations/vm'; +import { + VIRTUAL_MACHINE_EXISTS, + VIRTUAL_MACHINE_TEMPLATE_EXISTS, +} from '../../../../utils/validations/strings'; +import { concatImmutableLists, immutableListToShallowJS } from '../../../../utils/immutable'; +import { getFieldTitle } from '../../utils/vm-settings-tab-utils'; +import { + iGetCommonData, + iGetLoadedCommonData, + iGetName, + immutableListToShallowMetadataJS, +} from '../../selectors/immutable/selectors'; +import { + validatePositiveInteger, + validateTrim, + validateURL, +} from '../../../../utils/validations/common'; +import { getValidationUpdate } from './utils'; + +const validateVm: VmSettingsValidator = (field, options) => { + const { getState, id } = options; + const state = getState(); + + const isCreateTemplate = iGetCommonData(state, id, VMWizardProps.isCreateTemplate); + + const entities = isCreateTemplate + ? concatImmutableLists( + iGetLoadedCommonData(state, id, VMWizardProps.commonTemplates), + iGetLoadedCommonData(state, id, VMWizardProps.userTemplates), + ) + : iGetLoadedCommonData(state, id, VMWizardProps.virtualMachines); + + return validateVmLikeEntityName( + iGetFieldValue(field), + iGetCommonData(state, id, VMWizardProps.activeNamespace), + immutableListToShallowMetadataJS(entities), + { + existsErrorMessage: isCreateTemplate + ? VIRTUAL_MACHINE_TEMPLATE_EXISTS + : VIRTUAL_MACHINE_EXISTS, + subject: getFieldTitle(iGetFieldKey(field)), + }, + ); +}; + +export const validateUserTemplate: VmSettingsValidator = (field, options) => { + const { getState, id } = options; + const state = getState(); + + const userTemplateName = iGetFieldValue(field); + if (!userTemplateName) { + return null; + } + const userTemplate = iGetLoadedCommonData(state, id, VMWizardProps.userTemplates, List()).find( + (template) => iGetName(template) === userTemplateName, + ); + + const dataVolumes = immutableListToShallowJS( + iGetLoadedCommonData(state, id, VMWizardProps.userTemplates), + ); + + return validateUserTemplateProvisionSource(userTemplate && userTemplate.toJSON(), dataVolumes); +}; + +const asVMSettingsFieldValidator = ( + validator: (value: string, opts: { subject: string }) => ValidationObject, +) => (field) => + validator(iGetFieldValue(field), { + subject: getFieldTitle(iGetFieldKey(field)), + }); + +const validationConfig: VMSettingsValidationConfig = { + [VMSettingsField.NAME]: { + detectValueChanges: [VMSettingsField.NAME], + detectCommonDataChanges: (field, options) => { + const isCreateTemplate = iGetCommonData( + options.getState(), + options.id, + VMWizardProps.isCreateTemplate, + ); + return isCreateTemplate + ? [ + VMWizardProps.activeNamespace, + VMWizardProps.userTemplates, + VMWizardProps.commonTemplates, + ] + : [VMWizardProps.activeNamespace, VMWizardProps.virtualMachines]; + }, + validator: validateVm, + }, + [VMSettingsField.CONTAINER_IMAGE]: { + detectValueChanges: [VMSettingsField.CONTAINER_IMAGE], + validator: asVMSettingsFieldValidator(validateTrim), + }, + [VMSettingsField.USER_TEMPLATE]: { + detectValueChanges: [VMSettingsField.USER_TEMPLATE], + detectCommonDataChanges: [VMWizardProps.userTemplates, VMWizardProps.dataVolumes], + validator: validateUserTemplate, + }, + [VMSettingsField.IMAGE_URL]: { + detectValueChanges: [VMSettingsField.IMAGE_URL], + validator: asVMSettingsFieldValidator(validateURL), + }, + [VMSettingsField.CPU]: { + detectValueChanges: [VMSettingsField.CPU], + validator: asVMSettingsFieldValidator(validatePositiveInteger), + }, + [VMSettingsField.MEMORY]: { + detectValueChanges: [VMSettingsField.MEMORY], + validator: asVMSettingsFieldValidator(validatePositiveInteger), + }, +}; + +export const validateVmSettings = (options: UpdateOptions) => { + const { id, dispatch, getState } = options; + const state = getState(); + const vmSettings = iGetVmSettings(state, id); + + const update = getValidationUpdate(validationConfig, options, vmSettings, hasVmSettingsChanged); + + if (!isEmpty(update)) { + dispatch(vmWizardInternalActions[InternalActionType.UpdateVmSettings](id, update)); + } +}; + +export const setVmSettingsTabValidity = (options: UpdateOptions) => { + const { id, dispatch, getState } = options; + const state = getState(); + const vmSettings = iGetVmSettings(state, id); + + // check if all required fields are defined + let valid = vmSettings + .filter((field) => isFieldRequired(field)) + .every((field) => field.get('value')); + + if (valid) { + // check if all fields are valid + valid = vmSettings.every( + (field) => field.getIn(['validation', 'type']) !== ValidationErrorType.Error, + ); + } + + dispatch( + vmWizardInternalActions[InternalActionType.SetTabValidity](id, VMWizardTab.VM_SETTINGS, valid), + ); +}; diff --git a/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/resource-load-errors.tsx b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/resource-load-errors.tsx new file mode 100644 index 00000000000..3c905d9ea70 --- /dev/null +++ b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/resource-load-errors.tsx @@ -0,0 +1,33 @@ +import { connect } from 'react-redux'; +import { AlertVariant } from '@patternfly/react-core'; +import { iGet } from '../../utils/immutable'; +import { Errors, Error } from '../errors/errors'; +import { COULD_NOT_LOAD_DATA } from '../../utils/strings'; +import { CommonDataProp, VMWizardProps } from './types'; +import { iGetCommonData } from './selectors/immutable/selectors'; + +const asError = (state, id: string, key: CommonDataProp, variant?: AlertVariant): Error => { + const loadError = iGet(iGetCommonData(state, id, key), 'loadError'); + return ( + loadError && { + message: loadError.message, + title: COULD_NOT_LOAD_DATA, + key: key as string, + variant: variant || AlertVariant.danger, + } + ); +}; + +const stateToProps = (state, { wizardReduxID }) => ({ + errors: [ + asError(state, wizardReduxID, VMWizardProps.commonTemplates), + asError(state, wizardReduxID, VMWizardProps.userTemplates), + asError(state, wizardReduxID, VMWizardProps.networkConfigs), + asError(state, wizardReduxID, VMWizardProps.persistentVolumeClaims), + asError(state, wizardReduxID, VMWizardProps.dataVolumes), + asError(state, wizardReduxID, VMWizardProps.storageClasses), + asError(state, wizardReduxID, VMWizardProps.virtualMachines, AlertVariant.warning), // for validation only + ], +}); + +export const ResourceLoadErrors = connect(stateToProps)(Errors); diff --git a/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/selectors/immutable/networks.ts b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/selectors/immutable/networks.ts new file mode 100644 index 00000000000..6997327d3ed --- /dev/null +++ b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/selectors/immutable/networks.ts @@ -0,0 +1,6 @@ +import { iGetIn } from '../../../../utils/immutable'; +import { VMWizardTab } from '../../types'; +import { iGetCreateVMWizardTabs } from './selectors'; + +export const iGetNetworks = (state, id: string) => + iGetIn(iGetCreateVMWizardTabs(state, id), [VMWizardTab.NETWORKS, 'value']); diff --git a/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/selectors/immutable/selectors.ts b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/selectors/immutable/selectors.ts new file mode 100644 index 00000000000..3c687831a41 --- /dev/null +++ b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/selectors/immutable/selectors.ts @@ -0,0 +1,48 @@ +import { K8sResourceKind } from '@console/internal/module/k8s'; +import { iGet, iGetIn, iGetLoadedData } from '../../../../utils/immutable'; +import { getCreateVMWizards } from '../selectors'; +import { CommonDataProp } from '../../types'; + +export const iGetCreateVMWizard = (state, reduxID: string) => + iGet(getCreateVMWizards(state), reduxID); +export const iGetCreateVMWizardTabs = (state, reduxID: string) => + iGet(iGetCreateVMWizard(state, reduxID), 'tabs'); + +export const iGetCommonData = (state, reduxID: string, key: CommonDataProp) => { + const wizard = iGetCreateVMWizard(state, reduxID); + const data = iGetIn(wizard, ['commonData', 'data', key]); + if (data !== undefined) { + return data; + } + + const dataRefererence = iGetIn(wizard, ['commonData', 'dataIDReferences', key]); + + if (dataRefererence && dataRefererence.size > 0) { + const firstStep = state[dataRefererence.first()]; + + return firstStep && firstStep.getIn(dataRefererence.skip(1)); + } + return undefined; +}; + +export const iGetName = (o) => + iGetIn(o, ['metadata', 'name']) as K8sResourceKind['metadata']['name']; +export const iGetNamespace = (o) => + iGetIn(o, ['metadata', 'namespace']) as K8sResourceKind['metadata']['namespace']; + +export const iGetLoadedCommonData = ( + state, + reduxID: string, + key: CommonDataProp, + defaultValue = undefined, +) => iGetLoadedData(iGetCommonData(state, reduxID, key), defaultValue); + +export const immutableListToShallowMetadataJS = (list, defaultValue = []) => + list + ? list.toArray().map((p) => ({ + metadata: { + name: iGetName(p), + namespace: iGetNamespace(p), + }, + })) + : defaultValue; diff --git a/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/selectors/immutable/storage.ts b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/selectors/immutable/storage.ts new file mode 100644 index 00000000000..7767d701d58 --- /dev/null +++ b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/selectors/immutable/storage.ts @@ -0,0 +1,6 @@ +import { iGetIn } from '../../../../utils/immutable'; +import { VMWizardTab } from '../../types'; +import { iGetCreateVMWizardTabs } from './selectors'; + +export const iGetStorages = (state, id: string) => + iGetIn(iGetCreateVMWizardTabs(state, id), [VMWizardTab.STORAGE, 'value']); diff --git a/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/selectors/immutable/vm-settings.ts b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/selectors/immutable/vm-settings.ts new file mode 100644 index 00000000000..794aab78e6f --- /dev/null +++ b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/selectors/immutable/vm-settings.ts @@ -0,0 +1,37 @@ +import { hasTruthyValue, iGet, iGetIn } from '../../../../utils/immutable'; +import { VMSettingsField, VMWizardTab } from '../../types'; +import { iGetCreateVMWizardTabs } from './selectors'; + +export const VM_SETTINGS_METADATA_ID = 'VM_SETTINGS_METADATA_ID'; +export const VMWARE_PROVIDER_METADATA_ID = 'VMWARE_PROVIDER_METADATA_ID'; + +export const iGetFieldValue = (field, defaultValue = undefined) => + iGet(field, 'value', defaultValue); +export const iGetFieldKey = (field, defaultValue = undefined) => iGet(field, 'key', defaultValue); + +export const isFieldRequired = (field) => hasTruthyValue(iGet(field, 'isRequired')); +export const isFieldHidden = (field) => hasTruthyValue(iGet(field, 'isHidden')); +export const isFieldDisabled = (field) => hasTruthyValue(iGet(field, 'isDisabled')); + +export const iGetVmSettings = (state, id: string) => + iGetIn(iGetCreateVMWizardTabs(state, id), [VMWizardTab.VM_SETTINGS, 'value']); +export const iGetVmSetting = (state, id: string, path, defaultValue = undefined) => + iGetIn(iGetVmSettings(state, id), path, defaultValue); + +export const hasVmSettingsChanged = (prevState, state, id: string, ...keys: VMSettingsField[]) => + !!keys.find((key) => iGetVmSetting(prevState, id, [key]) !== iGetVmSetting(state, id, [key])); + +export const iGetVmSettingAttribute = ( + state, + id: string, + key: VMSettingsField, + attribute = 'value', + defaultValue = undefined, +) => iGetVmSetting(state, id, [key, attribute], defaultValue); + +export const iGetVmSettingValue = ( + state, + id: string, + key: VMSettingsField, + defaultValue = undefined, +) => iGetVmSettingAttribute(state, id, key, 'value', defaultValue); diff --git a/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/selectors/immutable/wizard-selectors.ts b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/selectors/immutable/wizard-selectors.ts new file mode 100644 index 00000000000..a4520612037 --- /dev/null +++ b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/selectors/immutable/wizard-selectors.ts @@ -0,0 +1,4 @@ +import { iGetIn } from '../../../../utils/immutable'; + +export const isStepValid = (stepData, stepId: string) => !!iGetIn(stepData, [stepId, 'valid']); +export const isStepLocked = (stepData, stepId: string) => !!iGetIn(stepData, [stepId, 'locked']); diff --git a/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/selectors/selectors.ts b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/selectors/selectors.ts new file mode 100644 index 00000000000..c962e44bcc3 --- /dev/null +++ b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/selectors/selectors.ts @@ -0,0 +1,5 @@ +import { get } from 'lodash'; +import { Map } from 'immutable'; + +export const getCreateVMWizards = (state): Map => + get(state, ['kubevirt', 'createVmWizards']); diff --git a/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/strings/strings.ts b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/strings/strings.ts new file mode 100644 index 00000000000..c94c79ec311 --- /dev/null +++ b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/strings/strings.ts @@ -0,0 +1,15 @@ +import { VMWizardTab } from '../types'; + +export const CREATE_VM = 'Create Virtual Machine'; +export const CREATE_VM_TEMPLATE = `${CREATE_VM} Template`; +export const REVIEW_AND_CREATE = 'Review and create'; +export const NO_TEMPLATE = 'None'; +export const NO_TEMPLATE_AVAILABLE = 'No template available'; + +export const TabTitleResolver = { + [VMWizardTab.VM_SETTINGS]: 'General', + [VMWizardTab.NETWORKS]: 'Networking', + [VMWizardTab.STORAGE]: 'Storage', + [VMWizardTab.REVIEW]: 'Review', + [VMWizardTab.RESULT]: 'Result', +}; diff --git a/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/strings/vm-settings.ts b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/strings/vm-settings.ts new file mode 100644 index 00000000000..b3de2c7ee59 --- /dev/null +++ b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/strings/vm-settings.ts @@ -0,0 +1,56 @@ +import { VMSettingsField, VMSettingsRenderableFieldResolver } from '../types'; +import { ProvisionSource } from '../../../types/vm'; + +export const titleResolver: VMSettingsRenderableFieldResolver = { + [VMSettingsField.NAME]: 'Name', + [VMSettingsField.DESCRIPTION]: 'Description', + [VMSettingsField.USER_TEMPLATE]: 'Template', + [VMSettingsField.PROVISION_SOURCE_TYPE]: 'Source', + [VMSettingsField.PROVIDER]: 'Provider', + [VMSettingsField.CONTAINER_IMAGE]: 'Container Image', + [VMSettingsField.IMAGE_URL]: 'URL', + [VMSettingsField.OPERATING_SYSTEM]: 'Operating System', + [VMSettingsField.FLAVOR]: 'Flavor', + [VMSettingsField.MEMORY]: 'Memory (GB)', + [VMSettingsField.CPU]: 'CPUs', + [VMSettingsField.WORKLOAD_PROFILE]: 'Workload Profile', + [VMSettingsField.START_VM]: 'Start virtual machine on creation', + [VMSettingsField.USE_CLOUD_INIT]: 'Use cloud-init', + [VMSettingsField.USE_CLOUD_INIT_CUSTOM_SCRIPT]: 'Use custom script', + [VMSettingsField.HOST_NAME]: 'Hostname', + [VMSettingsField.AUTHKEYS]: 'Authenticated SSH Keys', + [VMSettingsField.CLOUD_INIT_CUSTOM_SCRIPT]: 'Custom Script', +}; + +export const placeholderResolver = { + [VMSettingsField.USER_TEMPLATE]: '--- Select Template ---', + [VMSettingsField.PROVISION_SOURCE_TYPE]: '--- Select Source ---', + [VMSettingsField.PROVIDER]: '--- Select Provider ---', + [VMSettingsField.OPERATING_SYSTEM]: '--- Select Operating System ---', + [VMSettingsField.FLAVOR]: '--- Select Flavor ---', + [VMSettingsField.WORKLOAD_PROFILE]: '--- Select Workload Profile ---', +}; + +const provisionSourceHelpResolver = { + [ProvisionSource.URL]: + 'An external URL to the .iso, .img, .qcow2 or .raw that the virtual machine should be created from.', + [ProvisionSource.PXE]: 'Discover provisionable virtual machines over the network.', + [ProvisionSource.CONTAINER]: + 'Ephemeral virtual machine disk image which will be pulled from container registry.', + [ProvisionSource.IMPORT]: 'Import a virtual machine from external service using a provider.', + [ProvisionSource.CLONED_DISK]: 'Select an existing PVC in Storage tab', +}; + +export const helpResolver = { + [VMSettingsField.PROVISION_SOURCE_TYPE]: (sourceType: ProvisionSource) => + provisionSourceHelpResolver[sourceType], + [VMSettingsField.PROVIDER]: (provider) => `Not Implemented for ${provider}!!!`, + [VMSettingsField.FLAVOR]: () => + 'The combination of processing power and memory that will be provided to the virtual machine.', + [VMSettingsField.MEMORY]: () => + 'The amount of memory that will be dedicated to the virtual machine.', + [VMSettingsField.CPU]: () => + 'The number of virtual CPU cores that will be dedicated to the virtual machine.', + [VMSettingsField.WORKLOAD_PROFILE]: () => + 'The category of workload that this virtual machine will be used for.', +}; diff --git a/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/tabs/vm-settings-tab/memory-cpu.tsx b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/tabs/vm-settings-tab/memory-cpu.tsx new file mode 100644 index 00000000000..666a7551834 --- /dev/null +++ b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/tabs/vm-settings-tab/memory-cpu.tsx @@ -0,0 +1,50 @@ +import * as React from 'react'; +import { Grid, GridItem } from '@patternfly/react-core'; +import { FormFieldMemoRow } from '../../form/form-field-row'; +import { FormField, FormFieldType } from '../../form/form-field'; +import { Integer } from '../../../form/integer/integer'; +import { VMSettingsField } from '../../types'; +import { isFieldHidden } from '../../selectors/immutable/vm-settings'; + +import './vm-settings-tab.scss'; + +export const MemoryCPU: React.FC = React.memo( + ({ memoryField, cpuField, onChange }) => { + if (isFieldHidden(memoryField) && isFieldHidden(cpuField)) { + return null; + } + + return ( + + + + + onChange(VMSettingsField.MEMORY, value)} + /> + + + + + + + onChange(VMSettingsField.CPU, value)} + /> + + + + + ); + }, +); + +type MemoryCPUProps = { + memoryField: any; + cpuField: any; + onChange: (key: string, value: string) => void; +}; diff --git a/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/tabs/vm-settings-tab/os-flavor.tsx b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/tabs/vm-settings-tab/os-flavor.tsx new file mode 100644 index 00000000000..aed1aa69e92 --- /dev/null +++ b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/tabs/vm-settings-tab/os-flavor.tsx @@ -0,0 +1,105 @@ +import * as React from 'react'; +import { FormSelect, FormSelectOption } from '@patternfly/react-core'; +import { + concatImmutableLists, + iGetLoadedData, + immutableListToShallowJS, +} from '../../../../utils/immutable'; +import { FormFieldRow } from '../../form/form-field-row'; +import { FormField, FormFieldType } from '../../form/form-field'; +import { FormSelectPlaceholderOption } from '../../../form/form-select-placeholder-option'; +import { + getFlavors, + getOperatingSystems, +} from '../../../../selectors/vm-template/combined-dependent'; +import { flavorSort, ignoreCaseSort } from '../../../../utils/sort'; +import { VMSettingsField } from '../../types'; +import { iGetFieldValue } from '../../selectors/immutable/vm-settings'; +import { getPlaceholder } from '../../utils/vm-settings-tab-utils'; +import { nullOnEmptyChange } from '../../utils/utils'; + +export const OSFlavor: React.FC = React.memo( + ({ + userTemplates, + commonTemplates, + userTemplate, + operatinSystemField, + flavorField, + workloadProfile, + onChange, + }) => { + const flavor = iGetFieldValue(flavorField); + const os = iGetFieldValue(operatinSystemField); + const params = { + userTemplate, + flavor, + workload: workloadProfile, + os, + }; + + const vanillaTemplates = immutableListToShallowJS( + concatImmutableLists(iGetLoadedData(commonTemplates), iGetLoadedData(userTemplates)), + ); + + const operatingSystems = ignoreCaseSort(getOperatingSystems(vanillaTemplates, params), [ + 'name', + ]); + + const flavors = flavorSort(getFlavors(vanillaTemplates, params)); + + return ( + <> + + + + + {operatingSystems.map(({ id, name }) => { + return ; + })} + + + + + + + + {flavors.map((f) => { + return ; + })} + + + + + ); + }, +); + +type OSFlavorProps = { + userTemplates: any; + commonTemplates: any; + flavorField: any; + operatinSystemField: any; + userTemplate: string; + workloadProfile: string; + onChange: (key: string, value: string) => void; +}; diff --git a/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/tabs/vm-settings-tab/user-templates.tsx b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/tabs/vm-settings-tab/user-templates.tsx new file mode 100644 index 00000000000..88ad474aeeb --- /dev/null +++ b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/tabs/vm-settings-tab/user-templates.tsx @@ -0,0 +1,61 @@ +import * as React from 'react'; +import { FormSelect, FormSelectOption } from '@patternfly/react-core'; +import { iGetIsLoaded, iGetLoadedData } from '../../../../utils/immutable'; +import { FormFieldRow } from '../../form/form-field-row'; +import { FormField, FormFieldType } from '../../form/form-field'; +import { FormSelectPlaceholderOption } from '../../../form/form-select-placeholder-option'; +import { ignoreCaseSort } from '../../../../utils/sort'; +import { VMSettingsField } from '../../types'; +import { NO_TEMPLATE, NO_TEMPLATE_AVAILABLE } from '../../strings/strings'; +import { nullOnEmptyChange } from '../../utils/utils'; +import { iGetName } from '../../selectors/immutable/selectors'; + +export const UserTemplates: React.FC = React.memo( + ({ userTemplateField, userTemplates, commonTemplates, dataVolumes, onChange }) => { + const data = iGetLoadedData(userTemplates); + const names = + data && + data + .toIndexedSeq() + .toArray() + .map(iGetName); + const sortedNames = ignoreCaseSort(names); + const hasUserTemplates = sortedNames.length > 0; + + return ( + + + + + {sortedNames.map((name) => { + return ; + })} + + + + ); + }, + (prevProps, nextProps) => + iGetIsLoaded(prevProps.dataVolumes) === iGetIsLoaded(nextProps.dataVolumes) && // wait for dataVolumes; required when pre-filling template + iGetIsLoaded(prevProps.commonTemplates) === iGetIsLoaded(nextProps.commonTemplates) && // wait -||- + prevProps.userTemplateField === nextProps.userTemplateField && + prevProps.userTemplates === nextProps.userTemplates, +); + +type UserTemplatesProps = { + userTemplateField: any; + userTemplates: any; + commonTemplates: any; + dataVolumes: any; + onChange: (key: string, value: string) => void; +}; diff --git a/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/tabs/vm-settings-tab/vm-settings-tab.scss b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/tabs/vm-settings-tab/vm-settings-tab.scss new file mode 100644 index 00000000000..8eca4d0bc45 --- /dev/null +++ b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/tabs/vm-settings-tab/vm-settings-tab.scss @@ -0,0 +1,20 @@ +.kubevirt-create-vm-modal__description { + min-height: 3em; + resize: vertical; +} + +.kubevirt-create-vm-modal__memory-row { + margin-right: 0.5em; +} + +.kubevirt-create-vm-modal__memory-input { + max-width: 100% !important; +} + +.kubevirt-create-vm-modal__cpu-input { + max-width: 100% !important; +} + +.kubevirt-create-vm-modal__start_vm_checkbox > input[type="checkbox"] { + margin: -0.25em 0 0 !important; +} diff --git a/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/tabs/vm-settings-tab/vm-settings-tab.tsx b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/tabs/vm-settings-tab/vm-settings-tab.tsx new file mode 100644 index 00000000000..d2380ae8ec4 --- /dev/null +++ b/frontend/packages/kubevirt-plugin/src/components/create-vm-wizard/tabs/vm-settings-tab/vm-settings-tab.tsx @@ -0,0 +1,165 @@ +import * as React from 'react'; +import { + Form, + FormSelect, + FormSelectOption, + TextArea, + TextInput, + Checkbox, +} from '@patternfly/react-core'; +import { connect } from 'react-redux'; +import { iGet, iGetIn } from '../../../../utils/immutable'; +import { FormFieldMemoRow } from '../../form/form-field-row'; +import { FormField, FormFieldType } from '../../form/form-field'; +import { FormSelectPlaceholderOption } from '../../../form/form-select-placeholder-option'; +import { vmWizardActions } from '../../redux/actions'; +import { VMSettingsField, VMSettingsRenderableField, VMWizardProps } from '../../types'; +import { iGetVmSettings } from '../../selectors/immutable/vm-settings'; +import { ActionType } from '../../redux/types'; +import { getFieldId, getPlaceholder } from '../../utils/vm-settings-tab-utils'; +import { iGetCommonData } from '../../selectors/immutable/selectors'; +import { WorkloadProfile } from './workload-profile'; +import { OSFlavor } from './os-flavor'; +import { UserTemplates } from './user-templates'; +import { MemoryCPU } from './memory-cpu'; + +import './vm-settings-tab.scss'; + +export class VMSettingsTabComponent extends React.Component { + getField = (key) => iGet(this.props.vmSettings, key); + + getFieldAttribute = (key, attribute) => iGetIn(this.props.vmSettings, [key, attribute]); + + getFieldValue = (key) => iGetIn(this.props.vmSettings, [key, 'value']); + + onChange = (key) => (value) => this.props.onFieldChange(key, value); + + render() { + const { userTemplates, commonTemplates, dataVolumes } = this.props; + + return ( +
+ + + + + + {(this.getFieldAttribute(VMSettingsField.PROVISION_SOURCE_TYPE, 'sources') || []).map( + (source) => { + return ; + }, + )} + + + + + + + + + + + + + + + + + + + + + + + +