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 index 97cab6d88fa..e315c723218 100644 --- 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 @@ -17,7 +17,7 @@ export const MemoryCPU: React.FC = React.memo( onChange(VMSettingsField.MEMORY, value)} /> @@ -29,7 +29,7 @@ export const MemoryCPU: React.FC = React.memo( onChange(VMSettingsField.CPU, value)} /> 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 index f4528877836..992d0300c45 100644 --- 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 @@ -7,14 +7,6 @@ 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.1em 0 0 !important; } diff --git a/frontend/packages/kubevirt-plugin/src/components/form/integer/integer.scss b/frontend/packages/kubevirt-plugin/src/components/form/integer/integer.scss new file mode 100644 index 00000000000..4c12eedbee7 --- /dev/null +++ b/frontend/packages/kubevirt-plugin/src/components/form/integer/integer.scss @@ -0,0 +1,3 @@ +.kubevirt-integer-component { + max-width: 100% !important; +} diff --git a/frontend/packages/kubevirt-plugin/src/components/form/integer/integer.tsx b/frontend/packages/kubevirt-plugin/src/components/form/integer/integer.tsx index 20873a0e778..550476ba433 100644 --- a/frontend/packages/kubevirt-plugin/src/components/form/integer/integer.tsx +++ b/frontend/packages/kubevirt-plugin/src/components/form/integer/integer.tsx @@ -1,8 +1,11 @@ import * as React from 'react'; +import * as classNames from 'classnames'; import { TextInput } from '@patternfly/react-core'; import { getSequence, setNativeValue } from '../../../utils/utils'; import { isMinus, KEY_CODES, INPUT_NAVIGATION_KEYS } from '../../../constants/keys'; +import './integer.scss'; + const NON_NEGATIVE_INTEGER_KEYS = [ ...INPUT_NAVIGATION_KEYS, ...getSequence(KEY_CODES[0], KEY_CODES[9]), @@ -68,6 +71,9 @@ export const Integer: React.FC = ({ isPositive, isNonNegative, className, + isFullWidth, + isValid, + ...restProps }) => { let allowedKeys; let validRegex; @@ -120,6 +126,7 @@ export const Integer: React.FC = ({ return ( = ({ typeof onBlur === 'function' ? (event) => onBlur(event.target.value || '', event) : null } onChange={onChange} - className={className} + className={classNames(className, { + 'kubevirt-integer-component': isFullWidth, + })} isDisabled={isDisabled} /> ); @@ -138,6 +147,7 @@ export const Integer: React.FC = ({ type IntegerProps = { id?: string; + isFullWidth?: boolean; className?: string; value?: string; defaultValue?: string; @@ -146,4 +156,6 @@ type IntegerProps = { isPositive?: boolean; isNonNegative?: boolean; // is ignored when positive == true isDisabled?: boolean; + isValid?: boolean; + 'aria-label'?: string; }; diff --git a/frontend/packages/kubevirt-plugin/src/components/form/size-unit-form-row.scss b/frontend/packages/kubevirt-plugin/src/components/form/size-unit-form-row.scss index 780e741e45e..2749228586f 100644 --- a/frontend/packages/kubevirt-plugin/src/components/form/size-unit-form-row.scss +++ b/frontend/packages/kubevirt-plugin/src/components/form/size-unit-form-row.scss @@ -1,7 +1,3 @@ -.kubevirt-size-unit-form-row__size { - max-width: 100% !important; -} - .kubevirt-size-unit-form-row__unit { width: 6em; } diff --git a/frontend/packages/kubevirt-plugin/src/components/form/size-unit-form-row.tsx b/frontend/packages/kubevirt-plugin/src/components/form/size-unit-form-row.tsx index c825311b42a..a9f7e0a2df0 100644 --- a/frontend/packages/kubevirt-plugin/src/components/form/size-unit-form-row.tsx +++ b/frontend/packages/kubevirt-plugin/src/components/form/size-unit-form-row.tsx @@ -3,6 +3,7 @@ import { FormSelect, FormSelectOption, Split, SplitItem } from '@patternfly/reac import { ValidationObject } from '@console/shared'; import { prefixedID } from '../../utils'; import { getStringEnumValues } from '../../utils/types'; +import { isValidationError } from '../../utils/validations/common'; import { FormRow } from './form-row'; import { Integer } from './integer/integer'; import { BinaryUnit, toIECUnit } from './size-unit-utils'; @@ -43,12 +44,14 @@ export const SizeUnitFormRow: React.FC = ({ onSizeChanged(v), [onSizeChanged])} + aria-label={`${title} size`} /> @@ -58,6 +61,7 @@ export const SizeUnitFormRow: React.FC = ({ value={unit} id={prefixedID(id, 'unit')} isDisabled={isDisabled} + aria-label={`${title} unit`} > {(units || getStringEnumValues(BinaryUnit)).map((u) => ( diff --git a/frontend/packages/kubevirt-plugin/src/components/modals/vm-flavor-modal/_vm-flavor-modal.scss b/frontend/packages/kubevirt-plugin/src/components/modals/vm-flavor-modal/_vm-flavor-modal.scss deleted file mode 100644 index f38c609ab00..00000000000 --- a/frontend/packages/kubevirt-plugin/src/components/modals/vm-flavor-modal/_vm-flavor-modal.scss +++ /dev/null @@ -1,24 +0,0 @@ -.kubevirt-vm-flavor-modal__content-custom { - height: 20em; -} - -.kubevirt-vm-flavor-modal__content-generic { - min-height: 13em; - .modal-body { - overflow-y: unset; /* Hotfix for PF3 */ - } -} - -.kubevirt-vm-flavor-modal__form { - resize: vertical; -} - -.kubevirt-vm-flavor-modal__dropdown { - .dropdown { - width: 100%; - } -} - -.kubevirt-vm-flavor-modal__dropdown-button { - width: 100%; -} diff --git a/frontend/packages/kubevirt-plugin/src/components/modals/vm-flavor-modal/vm-flavor-modal.tsx b/frontend/packages/kubevirt-plugin/src/components/modals/vm-flavor-modal/vm-flavor-modal.tsx index b058ce4cd1b..0b5fa14d7bd 100644 --- a/frontend/packages/kubevirt-plugin/src/components/modals/vm-flavor-modal/vm-flavor-modal.tsx +++ b/frontend/packages/kubevirt-plugin/src/components/modals/vm-flavor-modal/vm-flavor-modal.tsx @@ -1,59 +1,55 @@ import * as React from 'react'; import * as _ from 'lodash'; -import * as classNames from 'classnames'; +import { Form, FormSelect, FormSelectOption } from '@patternfly/react-core'; import { + Firehose, + FirehoseResult, HandlePromiseProps, + validate, withHandlePromise, - Dropdown, - convertToBaseValue, - Firehose, } from '@console/internal/components/utils'; import { TemplateModel } from '@console/internal/models'; import { createModalLauncher, - ModalTitle, ModalBody, ModalComponentProps, - ModalFooter, + ModalTitle, } from '@console/internal/components/factory'; import { k8sPatch, TemplateKind } from '@console/internal/module/k8s'; -import { Form, FormGroup, TextInput } from '@patternfly/react-core'; import { VMLikeEntityKind } from '../../../types/vmLike'; import { + asVM, + getCPU, getFlavor, getMemory, - getCPU, - vCPUCount, - asVM, getVMLikeModel, + vCPUCount, } from '../../../selectors/vm'; import { getFlavors } from '../../../selectors/vm-template/selectors'; import { getUpdateFlavorPatches } from '../../../k8s/patches/vm/vm-patches'; import { CUSTOM_FLAVOR, NAMESPACE_OPENSHIFT, - TEMPLATE_TYPE_LABEL, TEMPLATE_TYPE_BASE, + TEMPLATE_TYPE_LABEL, } from '../../../constants'; -import { getResource } from '../../../utils'; -import './_vm-flavor-modal.scss'; - -const Gi = 1024 ** 3; +import { getLoadedData, getResource } from '../../../utils'; +import { SizeUnitFormRow } from '../../form/size-unit-form-row'; +import { BinaryUnit } from '../../form/size-unit-utils'; +import { ModalFooter } from '../modal/modal-footer'; +import { FormRow } from '../../form/form-row'; +import { Integer } from '../../form/integer/integer'; +import { validateFlavor } from '../../../utils/validations/vm/flavor'; +import { isValidationError } from '../../../utils/validations/common'; +import { useShowErrorToggler } from '../../../hooks/use-show-error-toggler'; +import { getDialogUIError } from '../../../utils/strings'; const getId = (field: string) => `vm-flavor-modal-${field}`; -const dehumanizeMemory = (memory?: string) => { - if (!memory) { - return null; - } - - return convertToBaseValue(memory) / Gi; -}; const VMFlavorModal = withHandlePromise((props: VMFlavornModalProps) => { const { vmLike, templates, - inProgress, errorMessage, handlePromise, close, @@ -61,94 +57,139 @@ const VMFlavorModal = withHandlePromise((props: VMFlavornModalProps) => { loadError, loaded, } = props; + const inProgress = props.inProgress || !loaded; const vm = asVM(vmLike); - const flattenTemplates = _.get(templates, 'data', []) as TemplateKind[]; + const flattenTemplates = getLoadedData(templates, []); - const vmFlavor = getFlavor(vmLike); const flavors = getFlavors(vmLike, flattenTemplates); + const vmFlavor = getFlavor(vmLike) || flavors[flavors.length - 1]; - const sourceMemory = getMemory(vm); - + const [sourceMemSize, sourceMemUnit] = validate.split(getMemory(vm) || ''); const sourceCPURaw = getCPU(vm); const sourceCPU = vCPUCount(sourceCPURaw); const [flavor, setFlavor] = React.useState(vmFlavor); - const [mem, setMem] = React.useState( - vmFlavor === CUSTOM_FLAVOR ? dehumanizeMemory(sourceMemory) : 1, + const isCustom = flavor === CUSTOM_FLAVOR; + + const [memSize, setMemSize] = React.useState(isCustom ? sourceMemSize || '' : ''); + const [memUnit, setMemUnit] = React.useState( + isCustom ? sourceMemUnit || BinaryUnit.Gi : BinaryUnit.Gi, ); - const [cpu, setCpu] = React.useState(vmFlavor === CUSTOM_FLAVOR ? sourceCPU : 1); + const [cpus, setCpus] = React.useState(isCustom ? `${sourceCPU}` : ''); + + const { + validations: { cpus: cpusValidation, memory: memoryValidation }, + hasAllRequiredFilled, + isValid, + } = validateFlavor( + { cpus, memory: { size: memSize, unit: memUnit } }, + { isCustomFlavor: isCustom }, + ); + + const [showUIError, setShowUIError] = useShowErrorToggler(false, isValid, isValid); const submit = (e) => { e.preventDefault(); - const patches = getUpdateFlavorPatches(vmLike, flattenTemplates, flavor, cpu, `${mem}Gi`); - if (patches.length === 0) { - close(); + if (isValid) { + const patches = getUpdateFlavorPatches( + vmLike, + flattenTemplates, + flavor, + parseInt(cpus, 10), + `${memSize}${memUnit}`, + ); + if (patches.length > 0) { + const promise = k8sPatch(getVMLikeModel(vmLike), vmLike, patches); + handlePromise(promise).then(close); // eslint-disable-line promise/catch-or-return + } else { + close(); + } } else { - const promise = k8sPatch(getVMLikeModel(vmLike), vmLike, patches); - handlePromise(promise).then(close); // eslint-disable-line promise/catch-or-return + setShowUIError(true); } }; - const topClass = classNames('modal-content', { - 'kubevirt-vm-flavor-modal__content-custom': flavor === CUSTOM_FLAVOR, - 'kubevirt-vm-flavor-modal__content-generic': flavor !== CUSTOM_FLAVOR, - }); - return ( -
+
Edit Flavor -
- - setFlavor(f)} - selectedKey={_.capitalize(flavor) || CUSTOM_FLAVOR} - title={_.capitalize(flavor)} - className="kubevirt-vm-flavor-modal__dropdown" - buttonClassName="kubevirt-vm-flavor-modal__dropdown-button" - /> - - - {flavor === CUSTOM_FLAVOR && ( + + + { + if (f === CUSTOM_FLAVOR) { + const isSourceCustom = vmFlavor === CUSTOM_FLAVOR; + setMemSize(isSourceCustom ? sourceMemSize || '' : ''); + setMemUnit(isSourceCustom ? sourceMemUnit || BinaryUnit.Gi : BinaryUnit.Gi); + setCpus(isSourceCustom ? `${sourceCPU}` : ''); + } + setFlavor(f); + }} + value={flavor} + id={getId('flavor')} + isDisabled={inProgress} + > + {flavors.map((f) => ( + + ))} + + + + {isCustom && ( <> - - + setCpu(parseInt(v, 10) || 1)} + value={cpus} + isPositive + onChange={(v) => setCpus(v)} + isFullWidth aria-label="CPU count" /> - - - setMem(parseInt(v, 10) || 1)} - aria-label="Memory" - /> - + + )}
- - - + id="vm-flavor-modal" + errorMessage={ + errorMessage || + loadError?.message || + (showUIError ? getDialogUIError(hasAllRequiredFilled) : null) + } + isSimpleError={showUIError} + isDisabled={inProgress} + inProgress={inProgress} + onSubmit={submit} + submitButtonText="Save" + onCancel={(e) => { + e.stopPropagation(); + cancel(); + }} + />
); }); @@ -172,7 +213,7 @@ const VMFlavorModalFirehose = (props) => { export type VMFlavornModalProps = HandlePromiseProps & ModalComponentProps & { vmLike: VMLikeEntityKind; - templates?: any; + templates?: FirehoseResult; loadError?: any; loaded: boolean; }; diff --git a/frontend/packages/kubevirt-plugin/src/components/vm-disks/disk-row.tsx b/frontend/packages/kubevirt-plugin/src/components/vm-disks/disk-row.tsx index 7dbe92f9a3a..76b20472d5e 100644 --- a/frontend/packages/kubevirt-plugin/src/components/vm-disks/disk-row.tsx +++ b/frontend/packages/kubevirt-plugin/src/components/vm-disks/disk-row.tsx @@ -35,6 +35,7 @@ const menuActionEdit = ( diskModalEnhanced({ vmLikeEntity, isEditing: true, + blocking: true, disk: disk.diskWrapper.asResource(), volume: disk.volumeWrapper.asResource(), dataVolume: disk.dataVolumeWrapper && disk.dataVolumeWrapper.asResource(), diff --git a/frontend/packages/kubevirt-plugin/src/components/vm-templates/vm-template-resource.tsx b/frontend/packages/kubevirt-plugin/src/components/vm-templates/vm-template-resource.tsx index e0e5bd93cd4..744b8aee9cc 100644 --- a/frontend/packages/kubevirt-plugin/src/components/vm-templates/vm-template-resource.tsx +++ b/frontend/packages/kubevirt-plugin/src/components/vm-templates/vm-template-resource.tsx @@ -139,7 +139,7 @@ export const VMTemplateDetailsList: React.FC = ({ vmFlavorModal({ vmLike: template })} + onClick={() => vmFlavorModal({ vmLike: template, blocking: true })} > {flavorText} diff --git a/frontend/packages/kubevirt-plugin/src/components/vms/vm-resource.tsx b/frontend/packages/kubevirt-plugin/src/components/vms/vm-resource.tsx index 38820765320..51e67698298 100644 --- a/frontend/packages/kubevirt-plugin/src/components/vms/vm-resource.tsx +++ b/frontend/packages/kubevirt-plugin/src/components/vms/vm-resource.tsx @@ -201,7 +201,7 @@ export const VMDetailsList: React.FC = ({ vmFlavorModal({ vmLike: vm })} + onClick={() => vmFlavorModal({ vmLike: vm, blocking: true })} > {flavorText} diff --git a/frontend/packages/kubevirt-plugin/src/constants/vm/constants.ts b/frontend/packages/kubevirt-plugin/src/constants/vm/constants.ts index c987ff45fcf..1684be8b937 100644 --- a/frontend/packages/kubevirt-plugin/src/constants/vm/constants.ts +++ b/frontend/packages/kubevirt-plugin/src/constants/vm/constants.ts @@ -24,6 +24,8 @@ export const TEMPLATE_VM_DOMAIN_LABEL = 'kubevirt.io/domain'; export const LABEL_USED_TEMPLATE_NAME = 'vm.kubevirt.io/template'; export const LABEL_USED_TEMPLATE_NAMESPACE = 'vm.kubevirt.io/template-namespace'; +export const LABEL_TEMPLATE_REVISION = 'vm.kubevirt.io/template.revision'; +export const LABEL_TEMPLATE_VERSION = 'vm.kubevirt.io/template.version'; export const DEFAULT_RDP_PORT = 3389; diff --git a/frontend/packages/kubevirt-plugin/src/selectors/vm-template/selectors.ts b/frontend/packages/kubevirt-plugin/src/selectors/vm-template/selectors.ts index 8e814b5a106..00e72ac2176 100644 --- a/frontend/packages/kubevirt-plugin/src/selectors/vm-template/selectors.ts +++ b/frontend/packages/kubevirt-plugin/src/selectors/vm-template/selectors.ts @@ -1,13 +1,13 @@ import * as _ from 'lodash'; -import { getName, getNamespace } from '@console/shared/src/selectors'; +import { getLabel, getName, getNamespace } from '@console/shared/src/selectors'; import { TemplateKind } from '@console/internal/module/k8s'; import { VMGenericLikeEntityKind } from '../../types/vmLike'; -import { iGetIn } from '../../utils/immutable'; import { CUSTOM_FLAVOR, + LABEL_USED_TEMPLATE_NAME, + LABEL_USED_TEMPLATE_NAMESPACE, TEMPLATE_FLAVOR_LABEL, TEMPLATE_OS_LABEL, - TEMPLATE_TYPE_LABEL, TEMPLATE_WORKLOAD_LABEL, } from '../../constants'; import { getLabels } from '../selectors'; @@ -22,8 +22,8 @@ export const getVMTemplateNamespacedName = ( return null; } - const name = vm.metadata.labels['vm.kubevirt.io/template']; - const namespace = vm.metadata.labels['vm.kubevirt.io/template-namespace']; + const name = getLabel(vm, LABEL_USED_TEMPLATE_NAME); + const namespace = getLabel(vm, LABEL_USED_TEMPLATE_NAMESPACE); return name && namespace ? { name, namespace } : null; }; @@ -105,15 +105,12 @@ export const getTemplateForFlavor = (templates: TemplateKind[], vm: VMKind, flav export const getFlavors = (vm: VMGenericLikeEntityKind, templates: TemplateKind[]) => { const vmTemplate = getVMTemplate(vm, templates); - const flavors = { - // always listed - [CUSTOM_FLAVOR]: CUSTOM_FLAVOR, - }; + const flavors = [CUSTOM_FLAVOR]; if (vmTemplate) { // enforced by the vm const templateFlavors = getTemplateFlavors([vmTemplate]); - templateFlavors.forEach((f) => (flavors[f] = _.capitalize(f))); + flavors.push(...templateFlavors); } // if VM OS or Workload is set, add flavors of matching templates only. Otherwise list all flavors. @@ -121,35 +118,7 @@ export const getFlavors = (vm: VMGenericLikeEntityKind, templates: TemplateKind[ const vmWorkload = getWorkloadProfile(vm); const matchingTemplates = getTemplates(templates, vmOS, vmWorkload, undefined); const templateFlavors = getTemplateFlavors(matchingTemplates); - templateFlavors.forEach((f) => (flavors[f] = _.capitalize(f))); + flavors.push(...templateFlavors); - // Sort flavors - const sortedFlavors = {}; - flavorSort(Object.keys(flavors)).forEach((k) => { - sortedFlavors[k] = flavors[k]; - }); - - return sortedFlavors; -}; - -export const getRelevantTemplates = ( - commonTemplates: TemplateKind[], - os: string, - workloadProfile: string, - flavor: string, -) => { - const relevantTemplates = (commonTemplates || []).filter( - (template) => - iGetIn(template, ['metadata', 'labels', TEMPLATE_TYPE_LABEL]) === 'base' && - (!os || iGetIn(template, ['metadata', 'labels', `${TEMPLATE_OS_LABEL}/${os}`])) && - (!workloadProfile || - iGetIn(template, [ - 'metadata', - 'labels', - `${TEMPLATE_WORKLOAD_LABEL}/${workloadProfile}`, - ])) && - (flavor === 'Custom' || - iGetIn(template, ['metadata', 'labels', `${TEMPLATE_FLAVOR_LABEL}/${flavor}`])), - ); - return relevantTemplates; + return _.uniq(flavorSort(flavors).filter((f) => f)); }; diff --git a/frontend/packages/kubevirt-plugin/src/utils/sort.ts b/frontend/packages/kubevirt-plugin/src/utils/sort.ts index 99c95c14257..c5eb00d71d6 100644 --- a/frontend/packages/kubevirt-plugin/src/utils/sort.ts +++ b/frontend/packages/kubevirt-plugin/src/utils/sort.ts @@ -8,7 +8,7 @@ const FLAVOR_ORDER = { large: 3, }; -export const flavorSort = (array = []) => +export const flavorSort = (array: string[] = []) => array.sort((a, b) => { if (a === CUSTOM_FLAVOR) { return 1; diff --git a/frontend/packages/kubevirt-plugin/src/utils/validations/vm/flavor.ts b/frontend/packages/kubevirt-plugin/src/utils/validations/vm/flavor.ts new file mode 100644 index 00000000000..d544a53cd4f --- /dev/null +++ b/frontend/packages/kubevirt-plugin/src/utils/validations/vm/flavor.ts @@ -0,0 +1,48 @@ +import { ValidationObject, validateEmptyValue } from '@console/shared/src'; + +export const validateFlavor = ( + { + memory: { unit, size }, + cpus, + }: { + memory: { size: string; unit: string }; + cpus: string; + }, + { isCustomFlavor }: { isCustomFlavor: boolean }, +): UIFlavorValidation => { + const validations = { + memory: null, + cpus: null, + }; + + let hasAllRequiredFilled = true; + + const addRequired = (addon) => { + if (hasAllRequiredFilled) { + hasAllRequiredFilled = hasAllRequiredFilled && addon; + } + }; + + if (isCustomFlavor) { + addRequired(unit); + addRequired(size); + addRequired(cpus); + validations.memory = validateEmptyValue(size, { subject: 'Memory' }); + validations.cpus = validateEmptyValue(cpus, { subject: 'CPUs' }); + } + + return { + validations, + hasAllRequiredFilled: !!hasAllRequiredFilled, + isValid: !!hasAllRequiredFilled && !Object.keys(validations).find((key) => validations[key]), + }; +}; + +export type UIFlavorValidation = { + validations: { + memory?: ValidationObject; + cpus?: ValidationObject; + }; + isValid: boolean; + hasAllRequiredFilled: boolean; +};