diff --git a/src/components/Details/Description/Description.js b/src/components/Details/Description/Description.js new file mode 100644 index 000000000..017514e6a --- /dev/null +++ b/src/components/Details/Description/Description.js @@ -0,0 +1,50 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { getDescription } from '../../../utils'; +import { InlineEdit } from '../../InlineEdit'; +import { Loading } from '../../Loading'; +import { DASHES } from '../../../constants'; + +const descriptionFormFields = { + description: { + id: 'description-textarea', + type: 'textarea', + }, +}; + +export const Description = ({ formValues, vm, editing, updating, LoadingComponent, onFormChange }) => { + if (!(formValues && formValues.description)) { + onFormChange({ value: getDescription(vm) }, 'description', true); + } + + return ( + onFormChange(newValue, key, true)} + > + {getDescription(vm) || DASHES} + + ); +}; + +Description.propTypes = { + formValues: PropTypes.object, + vm: PropTypes.object.isRequired, + editing: PropTypes.bool, + updating: PropTypes.bool, + LoadingComponent: PropTypes.func, + onFormChange: PropTypes.func, +}; + +Description.defaultProps = { + formValues: undefined, + editing: false, + updating: false, + LoadingComponent: Loading, + onFormChange: () => {}, +}; diff --git a/src/components/Details/Description/fixtures/Description.fixture.js b/src/components/Details/Description/fixtures/Description.fixture.js new file mode 100644 index 000000000..ad5999c75 --- /dev/null +++ b/src/components/Details/Description/fixtures/Description.fixture.js @@ -0,0 +1,47 @@ +import { Description } from '..'; + +import { cloudInitTestVm } from '../../../../k8s/mock_vm/cloudInitTestVm.mock'; + +export default [ + { + component: Description, + name: 'Description', + props: { + vm: cloudInitTestVm, + onFormChange: () => {}, + formValues: { + description: { + value: 'vm description', + }, + }, + }, + }, + { + component: Description, + name: 'Description edit', + props: { + vm: cloudInitTestVm, + editing: true, + onFormChange: () => {}, + formValues: { + description: { + value: 'vm description', + }, + }, + }, + }, + { + component: Description, + name: 'Description updating', + props: { + vm: cloudInitTestVm, + updating: true, + onFormChange: () => {}, + formValues: { + description: { + value: 'vm description', + }, + }, + }, + }, +]; diff --git a/src/components/Details/Description/index.js b/src/components/Details/Description/index.js new file mode 100644 index 000000000..2b6c4564b --- /dev/null +++ b/src/components/Details/Description/index.js @@ -0,0 +1 @@ +export * from './Description'; diff --git a/src/components/Details/Description/tests/Description.test.js b/src/components/Details/Description/tests/Description.test.js new file mode 100644 index 000000000..9789f4d3e --- /dev/null +++ b/src/components/Details/Description/tests/Description.test.js @@ -0,0 +1,15 @@ +import React from 'react'; +import { shallow } from 'enzyme'; + +import { Description } from '..'; + +import { default as DescriptionFixture } from '../fixtures/Description.fixture'; + +const testDescription = () => ; + +describe('', () => { + it('renders correctly', () => { + const component = shallow(testDescription()); + expect(component).toMatchSnapshot(); + }); +}); diff --git a/src/components/Details/Description/tests/__snapshots__/Description.test.js.snap b/src/components/Details/Description/tests/__snapshots__/Description.test.js.snap new file mode 100644 index 000000000..a1a26a884 --- /dev/null +++ b/src/components/Details/Description/tests/__snapshots__/Description.test.js.snap @@ -0,0 +1,27 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders correctly 1`] = ` + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec lacus nibh, convallis vel nunc id,tempus vulputate augue. Proin eget nisl vel ante tincidunt accumsan vel at elit. Fusce eget tincidunt sem. Fusce cursus orci vitae nisl hendrerit mollis. Nullam at nulla ut ipsum malesuada laoreet a sit amet est. + +`; diff --git a/src/components/Details/Flavor/Flavor.js b/src/components/Details/Flavor/Flavor.js new file mode 100644 index 000000000..185a1c7fe --- /dev/null +++ b/src/components/Details/Flavor/Flavor.js @@ -0,0 +1,150 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { getCpu, getFlavor, getMemory, getVmTemplate } from '../../../utils'; +import { InlineEdit } from '../../InlineEdit/InlineEdit'; +import { TemplateModel } from '../../../models'; +import { CUSTOM_FLAVOR, VALIDATION_ERROR_TYPE } from '../../../constants'; +import { getTemplateFlavors, settingsValue } from '../../../k8s/selectors'; +import { Loading } from '../../Loading/Loading'; + +export class Flavor extends React.Component { + constructor(props) { + super(props); + this.state = { + loadingTemplate: false, + template: null, + }; + this.resolveInitialValues(); + } + + resolveInitialValues = () => { + const flavor = getFlavor(this.props.vm) || CUSTOM_FLAVOR; + const cpu = getCpu(this.props.vm); + const memory = getMemory(this.props.vm); + const memoryInt = memory ? parseInt(memory, 10) : undefined; + this.props.onFormChange({ value: flavor }, 'flavor'); + if (flavor === CUSTOM_FLAVOR) { + this.props.onFormChange({ value: cpu }, 'cpu', !!cpu); + this.props.onFormChange({ value: memoryInt }, 'memory', !!memoryInt); + } + }; + + componentDidMount() { + const template = getVmTemplate(this.props.vm); + if (template) { + this.setState({ + loadingTemplate: true, + }); + const getTemplatePromise = this.props.k8sGet(TemplateModel, template.name, template.namespace); + getTemplatePromise + .then(result => { + this.props.onFormChange(result, 'template'); + return this.setState({ + loadingTemplate: false, + template: result, + }); + }) + .catch(error => { + this.props.onLoadError(error.message || 'An error occurred while loading vm flavors. Please try again.'); + this.setState({ + loadingTemplate: false, + template: null, + }); + }); + } + } + + getFlavorDescription = () => { + const cpu = getCpu(this.props.vm); + const memory = getMemory(this.props.vm); + const cpuStr = cpu ? `${cpu} CPU` : ''; + const memoryStr = memory ? `${memory} Memory` : ''; + const resourceStr = cpuStr && memoryStr ? `${cpuStr}, ${memoryStr}` : `${cpuStr}${memoryStr}`; + return resourceStr ?
{resourceStr}
: undefined; + }; + + getFlavorChoices = () => { + const flavors = []; + if (this.state.template) { + flavors.push(...getTemplateFlavors([this.state.template])); + } + if (!flavors.some(flavor => flavor === CUSTOM_FLAVOR)) { + flavors.push(CUSTOM_FLAVOR); + } + return flavors; + }; + + flavorFormFields = () => ({ + flavor: { + id: 'flavor-dropdown', + type: 'dropdown', + choices: this.getFlavorChoices(), + }, + cpu: { + id: 'flavor-cpu', + title: 'CPU', + type: 'positive-number', + required: true, + isVisible: formFields => settingsValue(formFields, 'flavor') === CUSTOM_FLAVOR, + }, + memory: { + id: 'flavor-memory', + title: 'Memory (GB)', + type: 'positive-number', + required: true, + isVisible: formFields => settingsValue(formFields, 'flavor') === CUSTOM_FLAVOR, + }, + }); + + onFormChange = (newValue, key) => { + let valid = true; + if (this.props.formValues) { + valid = + valid && + !Object.keys(this.props.formValues) + .filter(formValueKey => formValueKey !== key) + .some(formValueKey => formValueKey.validation && formValueKey.validation.type === VALIDATION_ERROR_TYPE); + } + valid = valid && newValue.validation ? newValue.validation.type !== VALIDATION_ERROR_TYPE : true; + this.props.onFormChange(newValue, key, valid); + }; + + render() { + const { editing, updating, LoadingComponent } = this.props; + const formFields = this.flavorFormFields(); + + return ( + +
{settingsValue(this.props.formValues, 'flavor')}
+ {this.getFlavorDescription()} +
+ ); + } +} + +Flavor.propTypes = { + vm: PropTypes.object.isRequired, + onFormChange: PropTypes.func.isRequired, + updating: PropTypes.bool, + editing: PropTypes.bool, + k8sGet: PropTypes.func.isRequired, + LoadingComponent: PropTypes.func, + formValues: PropTypes.object, + onLoadError: PropTypes.func, +}; + +Flavor.defaultProps = { + updating: false, + editing: false, + LoadingComponent: Loading, + formValues: undefined, + onLoadError: () => {}, +}; diff --git a/src/components/Details/Flavor/fixtures/Flavor.fixture.js b/src/components/Details/Flavor/fixtures/Flavor.fixture.js new file mode 100644 index 000000000..9476d6b59 --- /dev/null +++ b/src/components/Details/Flavor/fixtures/Flavor.fixture.js @@ -0,0 +1,56 @@ +import { Flavor } from '..'; + +import { cloudInitTestVm } from '../../../../k8s/mock_vm/cloudInitTestVm.mock'; +import { fedora28 } from '../../../../k8s/mock_templates/fedora28.mock'; + +export default [ + { + component: Flavor, + name: 'Flavor', + props: { + vm: cloudInitTestVm, + onFormChange: () => {}, + k8sGet: () => + new Promise(resolve => { + resolve(fedora28); + }), + }, + }, + { + component: Flavor, + name: 'Flavor editing', + props: { + vm: cloudInitTestVm, + formValues: { + cpu: { + value: '2', + }, + memory: { + value: '2', + }, + flavor: { + value: 'Custom', + }, + }, + editing: true, + onFormChange: () => {}, + k8sGet: () => + new Promise(resolve => { + resolve(fedora28); + }), + }, + }, + { + component: Flavor, + name: 'Flavor updating', + props: { + vm: cloudInitTestVm, + updating: true, + onFormChange: () => {}, + k8sGet: () => + new Promise(resolve => { + resolve(fedora28); + }), + }, + }, +]; diff --git a/src/components/Details/Flavor/index.js b/src/components/Details/Flavor/index.js new file mode 100644 index 000000000..3ff0e18b4 --- /dev/null +++ b/src/components/Details/Flavor/index.js @@ -0,0 +1 @@ +export * from './Flavor'; diff --git a/src/components/Details/Flavor/tests/Flavor.test.js b/src/components/Details/Flavor/tests/Flavor.test.js new file mode 100644 index 000000000..01ba4aca8 --- /dev/null +++ b/src/components/Details/Flavor/tests/Flavor.test.js @@ -0,0 +1,15 @@ +import React from 'react'; +import { shallow } from 'enzyme'; + +import { Flavor } from '..'; + +import { default as FlavorFixture } from '../fixtures/Flavor.fixture'; + +const testFlavor = () => ; + +describe('', () => { + it('renders correctly', () => { + const component = shallow(testFlavor()); + expect(component).toMatchSnapshot(); + }); +}); diff --git a/src/components/Details/Flavor/tests/__snapshots__/Flavor.test.js.snap b/src/components/Details/Flavor/tests/__snapshots__/Flavor.test.js.snap new file mode 100644 index 000000000..6a5fe18ac --- /dev/null +++ b/src/components/Details/Flavor/tests/__snapshots__/Flavor.test.js.snap @@ -0,0 +1,41 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders correctly 1`] = ` + +
+
+ 2 CPU, 2G Memory +
+ +`; diff --git a/src/components/Details/VmDetails/VmDetails.js b/src/components/Details/VmDetails/VmDetails.js index 72f10bf8e..24e259d6c 100644 --- a/src/components/Details/VmDetails/VmDetails.js +++ b/src/components/Details/VmDetails/VmDetails.js @@ -1,123 +1,235 @@ import React, { Fragment } from 'react'; import PropTypes from 'prop-types'; import { get } from 'lodash'; -import { Row, Col } from 'patternfly-react'; +import { Row, Col, Button, Alert } from 'patternfly-react'; import { VmStatus } from '../../VmStatus'; import { getCpu, - getDescription, - getFlavor, getMemory, getNodeName, getOperatingSystem, getVmTemplate, getWorkloadProfile, getVmiIpAddresses, + getUpdateDescriptionPatch, + getUpdateFlavorPatch, } from '../../../utils'; +import { VirtualMachineModel } from '../../../models'; +import { CUSTOM_FLAVOR, DASHES } from '../../../constants'; +import { settingsValue, selectVm } from '../../../k8s/selectors'; +import { Flavor } from '../Flavor'; +import { Description } from '../Description'; +import { Loading } from '../../Loading'; -const DASHES = '---'; - -const Flavor = props => { - const { vm } = props; - const flavor = getFlavor(vm); - if (!flavor) { - return DASHES; +export class VmDetails extends React.Component { + constructor(props) { + super(props); + this.state = { + editing: false, + updating: false, + k8sError: null, + form: {}, + }; } - const cpu = getCpu(vm); - const memory = getMemory(vm); - const cpuStr = cpu ? `${cpu} CPU` : ''; - const memoryStr = memory ? `${memory} Memory` : ''; - const resourceStr = cpuStr && memoryStr ? `${cpuStr}, ${memoryStr}` : `${cpuStr}${memoryStr}`; - const resourceElement = resourceStr ?
{resourceStr}
: undefined; - - return ( - -
{flavor}
- {resourceElement} -
- ); -}; -Flavor.propTypes = { - vm: PropTypes.object.isRequired, -}; + toggleEditing = editing => + this.setState({ + editing, + }); -export const VmDetails = props => { - const { launcherPod, importerPod, migration, NodeLink, vm, vmi, PodResourceLink, NamespaceResourceLink } = props; - const nodeName = getNodeName(launcherPod); - const description = getDescription(vm); - const ipAddresses = getVmiIpAddresses(vmi); - - return ( -
-

Virtual Machine Overview

- - -
-
Name
-
{vm.metadata.name}
-
Description
-
-
{description || 'VM has no description'}
-
-
- - - - - -
-
Status
-
- -
- -
Operating System
-
{getOperatingSystem(vm) || DASHES}
- -
IP Addresses
-
{ipAddresses.length > 0 ? ipAddresses.join(', ') : DASHES}
- -
Workload Profile
-
{getWorkloadProfile(vm) || DASHES}
- -
Template
-
{getVmTemplate(vm) || DASHES}
-
- - - -
-
FQDN
-
{get(launcherPod, 'spec.hostname', DASHES)}
- -
Namespace
-
{NamespaceResourceLink ? : DASHES}
- -
Pod
-
{PodResourceLink ? : DASHES}
-
- - - -
-
Node
-
{nodeName ? : DASHES}
- -
Flavor
-
- -
-
- -
- -
-
- ); -}; + onFormChange = (formKey, newValue, key, valid) => + this.setState(state => ({ + form: { + ...state.form, + [formKey]: { + ...state.form[formKey], + [key]: newValue, + valid, + }, + }, + })); + + onLoadError = error => + this.setState({ + k8sError: error, + }); + + updateVmDetails = () => { + this.setState({ + editing: false, + }); + const vmPatch = []; + + const descriptionForm = this.state.form.description; + const flavorForm = this.state.form.flavor; + + const descriptionPatch = getUpdateDescriptionPatch(this.props.vm, settingsValue(descriptionForm, 'description')); + vmPatch.push(...descriptionPatch); + + const flavor = settingsValue(flavorForm, 'flavor'); + let cpu; + let memory; + if (flavor !== CUSTOM_FLAVOR) { + const templateVm = selectVm(flavorForm.template.objects); + cpu = getCpu(templateVm); + memory = getMemory(templateVm); + } else { + cpu = settingsValue(flavorForm, 'cpu'); + memory = `${settingsValue(flavorForm, 'memory')}G`; + } + + const flavorPatch = getUpdateFlavorPatch(this.props.vm, flavor, cpu, memory); + vmPatch.push(...flavorPatch); + + if (vmPatch.length > 0) { + this.setState({ + updating: true, + k8sError: null, + }); + const updatePromise = this.props.k8sPatch(VirtualMachineModel, this.props.vm, vmPatch); + updatePromise + .then(() => this.setState({ updating: false })) + .catch(error => + this.setState({ updating: false, k8sError: error.message || 'An error occurred. Please try again.' }) + ); + } + }; + + onErrorDismiss = () => + this.setState({ + k8sError: null, + }); + + isFormValid = () => Object.keys(this.state.form).every(key => this.state.form[key].valid); + + render() { + const { + launcherPod, + importerPod, + migration, + NodeLink, + vm, + vmi, + PodResourceLink, + NamespaceResourceLink, + LoadingComponent, + k8sGet, + } = this.props; + const nodeName = getNodeName(launcherPod); + const ipAddresses = getVmiIpAddresses(vmi); + const template = getVmTemplate(vm); + const editButton = ( + + ); + const cancelSaveButton = ( + + + + + ); + + return ( +
+

+ Virtual Machine Overview +
{this.state.editing ? cancelSaveButton : editButton}
+

+ {this.state.k8sError && {this.state.k8sError}} + + +
+
Name
+
{vm.metadata.name}
+
Description
+
+
+ this.onFormChange('description', newValue, key, valid)} + vm={vm} + /> +
+
+
+ + + + + +
+
Status
+
+ +
+ +
Operating System
+
{getOperatingSystem(vm) || DASHES}
+ +
IP Addresses
+
{ipAddresses.length > 0 ? ipAddresses.join(', ') : DASHES}
+ +
Workload Profile
+
{getWorkloadProfile(vm) || DASHES}
+ +
Template
+
{template ? `${template.namespace}/${template.name}` : DASHES}
+
+ + + +
+
FQDN
+
{get(launcherPod, 'spec.hostname', DASHES)}
+ +
Namespace
+
{NamespaceResourceLink ? : DASHES}
+ +
Pod
+
{PodResourceLink ? : DASHES}
+
+ + + +
+
Node
+
{nodeName && NodeLink ? : DASHES}
+ +
Flavor
+
+ this.onFormChange('flavor', newValue, key, valid)} + k8sGet={k8sGet} + formValues={this.state.form.flavor} + onLoadError={this.onLoadError} + /> +
+
+ +
+ +
+
+ ); + } +} VmDetails.propTypes = { vm: PropTypes.object.isRequired, @@ -125,9 +237,12 @@ VmDetails.propTypes = { launcherPod: PropTypes.object, importerPod: PropTypes.object, migration: PropTypes.object, - NodeLink: PropTypes.func.isRequired, + NodeLink: PropTypes.func, NamespaceResourceLink: PropTypes.func, PodResourceLink: PropTypes.func, + k8sPatch: PropTypes.func.isRequired, + k8sGet: PropTypes.func.isRequired, + LoadingComponent: PropTypes.func, }; VmDetails.defaultProps = { @@ -137,4 +252,6 @@ VmDetails.defaultProps = { migration: undefined, NamespaceResourceLink: undefined, PodResourceLink: undefined, + LoadingComponent: Loading, + NodeLink: undefined, }; diff --git a/src/components/Details/VmDetails/fixtures/VmDetails.fixture.js b/src/components/Details/VmDetails/fixtures/VmDetails.fixture.js index 20e8851e7..a24c60896 100644 --- a/src/components/Details/VmDetails/fixtures/VmDetails.fixture.js +++ b/src/components/Details/VmDetails/fixtures/VmDetails.fixture.js @@ -1,4 +1,5 @@ import { VmDetails } from '../VmDetails'; +import { fedora28 } from '../../../../k8s/mock_templates/fedora28.mock'; const metadata = { name: 'my-vm', @@ -90,6 +91,14 @@ export default [ name: 'offline VM', props: { vm: vmFixtures.downVm, + k8sPatch: () => + new Promise(resolve => { + resolve(); + }), + k8sGet: () => + new Promise(resolve => { + resolve(fedora28); + }), }, }, { @@ -97,6 +106,15 @@ export default [ name: 'running VM', props: { vm: vmFixtures.runningVm, + k8sPatch: () => + new Promise(resolve => { + resolve(); + }), + k8sGet: () => + new Promise(resolve => { + resolve(fedora28); + }), + NodeLink: () => true, }, }, { @@ -104,6 +122,15 @@ export default [ name: 'VM with description', props: { vm: vmFixtures.vmWithDescription, + k8sPatch: () => + new Promise(resolve => { + resolve(); + }), + k8sGet: () => + new Promise(resolve => { + resolve(fedora28); + }), + NodeLink: () => true, }, }, { @@ -111,6 +138,15 @@ export default [ name: 'VM with flavor workload os', props: { vm: vmFixtures.vmWithLabels, + k8sPatch: () => + new Promise(resolve => { + resolve(); + }), + k8sGet: () => + new Promise(resolve => { + resolve(fedora28); + }), + NodeLink: () => true, }, }, { @@ -118,6 +154,15 @@ export default [ name: 'VM with custom flavor', props: { vm: vmFixtures.customVm, + k8sPatch: () => + new Promise(resolve => { + resolve(); + }), + k8sGet: () => + new Promise(resolve => { + resolve(fedora28); + }), + NodeLink: () => true, }, }, ]; diff --git a/src/components/Details/VmDetails/tests/VmDetails.test.js b/src/components/Details/VmDetails/tests/VmDetails.test.js index 27c4d47d8..e7759a840 100644 --- a/src/components/Details/VmDetails/tests/VmDetails.test.js +++ b/src/components/Details/VmDetails/tests/VmDetails.test.js @@ -1,56 +1,212 @@ import React from 'react'; -import { render } from 'enzyme'; +import { render, mount } from 'enzyme'; +import { Button, MenuItem } from 'patternfly-react'; -import { cloudInitTestVmi } from '../../../../k8s/mock_vmi/cloudInitTestVmi.vmi'; -import { VmDetails } from '../VmDetails'; -import { vmFixtures } from '../fixtures/VmDetails.fixture'; +import { cloudInitTestVmi } from '../../../../k8s/mock_vmi/cloudInitTestVmi.mock'; +import { VmDetails } from '../index'; +import { vmFixtures, default as VmDetailsFixture } from '../fixtures/VmDetails.fixture'; +import { InlineFormFactory } from '../../../Form/FormFactory'; +import { Description } from '../../Description'; +import { TextArea } from '../../../Form'; +import { InlineEdit } from '../../../InlineEdit'; +import { CUSTOM_FLAVOR } from '../../../../constants'; -describe('', () => { +const testVmDetails = (vm, otherProps) => ; + +const expectMockWasCalledWith = (fn, jsonPatch, call = 0) => { + expect(fn.mock.calls[call][2]).toEqual(jsonPatch); +}; + +const getCpuInput = component => + component + .find(InlineEdit) + .find('#flavor-cpu') + .find('input'); + +const getMemoryInput = component => + component + .find(InlineEdit) + .find('#flavor-memory') + .find('input'); + +const selectFlavor = (component, flavor) => { + const flavorDropdown = component.find('#flavor-dropdown'); + flavorDropdown + .find(MenuItem) + .findWhere(item => item.text() === flavor) + .find('a') + .simulate('click'); +}; + +const clickButton = (component, buttonText) => getButton(component, buttonText).simulate('click'); + +const getButton = (component, buttonText) => + component + .find(Button) + .findWhere(button => button.text() === buttonText) + .find('.btn'); + +const setInput = (input, value) => input.simulate('change', { target: { value } }); + +describe('', () => { it('renders correctly', () => { - const component = render( true} />); + const component = render(testVmDetails(vmFixtures.downVm)); expect(component).toMatchSnapshot(); }); it('renders on status correctly', () => { - const component = render( true} />); + const component = render(testVmDetails(vmFixtures.runningVm)); expect(component).toMatchSnapshot(); }); it('renders off status correctly', () => { - const component = render( true} />); + const component = render(testVmDetails(vmFixtures.downVm)); expect(component).toMatchSnapshot(); }); -}); -describe('VmDetails with description', () => { it('renders description correctly', () => { - const component = render( true} />); + const component = render(testVmDetails(vmFixtures.vmWithDescription)); expect(component).toMatchSnapshot(); }); -}); -describe('VmDetails with labels', () => { it('renders values from labels correctly', () => { - const component = render( true} />); + const component = render(testVmDetails(vmFixtures.vmWithLabels)); expect(component).toMatchSnapshot(); }); -}); -describe('VmDetails for VM with Custom flavor', () => { it('renders CPU and Memory settings for VM with custom flavor', () => { - const component = render( true} />); + const component = render(testVmDetails(vmFixtures.customVm)); expect(component).toMatchSnapshot(); }); it('does not render CPU and Memory settings for VM without custom flavor', () => { - const component = render( true} />); + const component = render(testVmDetails(vmFixtures.vmWithSmallFlavor)); expect(component).toMatchSnapshot(); }); -}); -describe('', () => { it('renders IP addresses correctly', () => { - const component = render( true} vmi={cloudInitTestVmi} />); + const component = render(testVmDetails(vmFixtures.runningVm, { vmi: cloudInitTestVmi })); expect(component).toMatchSnapshot(); }); }); + +describe(' enzyme', () => { + it('edit button triggers editing', () => { + const component = mount(testVmDetails(vmFixtures.customVm)); + expect(component.find(InlineFormFactory).exists()).toBeFalsy(); + clickButton(component, 'Edit'); + component.update(); + expect(component.find(InlineFormFactory)).toHaveLength(2); + expect( + component + .find(Button) + .findWhere(button => button.text() === 'Cancel') + .exists() + ).toBeTruthy(); + expect( + component + .find(Button) + .findWhere(button => button.text() === 'Save') + .exists() + ).toBeTruthy(); + }); + it('disables edit mode when clicked on cancel', () => { + const component = mount(testVmDetails(vmFixtures.customVm)); + expect(component.find(InlineFormFactory).exists()).toBeFalsy(); + clickButton(component, 'Edit'); + component.update(); + expect(component.find(InlineFormFactory)).toHaveLength(2); + clickButton(component, 'Cancel'); + component.update(); + expect(component.find(InlineFormFactory).exists()).toBeFalsy(); + }); + it('updates VM description after clicking save button', () => { + const k8sPatchMock = jest.fn().mockReturnValue( + new Promise(resolve => { + resolve(); + }) + ); + const component = mount( + testVmDetails(vmFixtures.customVm, { + k8sPatch: k8sPatchMock, + }) + ); + clickButton(component, 'Edit'); + component.update(); + + const descriptionField = component.find(Description).find(TextArea); + setInput(descriptionField, 'My new value'); + component.update(); + + clickButton(component, 'Save'); + + expectMockWasCalledWith(k8sPatchMock, [ + { + op: 'add', + path: '/metadata/annotations', + value: { description: 'My new value' }, + }, + ]); + expect(component.find(InlineFormFactory).exists()).toBeFalsy(); + }); + + it('updates VM flavor after clicking save button', () => { + const k8sPatchMock = jest.fn().mockReturnValue( + new Promise(resolve => { + resolve(); + }) + ); + const component = mount( + testVmDetails(vmFixtures.customVm, { + k8sPatch: k8sPatchMock, + }) + ); + + clickButton(component, 'Edit'); + component.update(); + + selectFlavor(component, CUSTOM_FLAVOR); + setInput(getCpuInput(component), 1); + setInput(getMemoryInput(component), '1'); + + component.update(); + + clickButton(component, 'Save'); + + expectMockWasCalledWith(k8sPatchMock, [ + { + op: 'replace', + path: '/spec/template/spec/domain/cpu/cores', + value: 1, + }, + { + op: 'replace', + path: '/spec/template/spec/domain/resources/requests/memory', + value: '1G', + }, + ]); + expect(component.find(InlineFormFactory).exists()).toBeFalsy(); + }); + + it('disables save when form is invalid', () => { + const component = mount(testVmDetails(vmFixtures.customVm)); + expect(component.find(InlineFormFactory).exists()).toBeFalsy(); + clickButton(component, 'Edit'); + component.update(); + + selectFlavor(component, CUSTOM_FLAVOR); + component.update(); + + setInput(getCpuInput(component), ''); + component.update(); + + expect(getButton(component, 'Save').props().disabled).toBeTruthy(); + + setInput(getCpuInput(component), '1'); + + setInput(getMemoryInput(component), '1'); + component.update(); + + expect(getButton(component, 'Save').props().disabled).toBeFalsy(); + }); +}); diff --git a/src/components/Details/VmDetails/tests/__snapshots__/VmDetails.test.js.snap b/src/components/Details/VmDetails/tests/__snapshots__/VmDetails.test.js.snap index 82059e0a9..0b865d9e1 100644 --- a/src/components/Details/VmDetails/tests/__snapshots__/VmDetails.test.js.snap +++ b/src/components/Details/VmDetails/tests/__snapshots__/VmDetails.test.js.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[` renders correctly 1`] = ` +exports[` does not render CPU and Memory settings for VM without custom flavor 1`] = `
@@ -8,6 +8,14 @@ exports[` renders correctly 1`] = ` class="co-m-pane__heading" > Virtual Machine Overview +
+ +
renders correctly 1`] = `
- VM has no description +
+ --- +
@@ -123,7 +133,9 @@ exports[` renders correctly 1`] = ` Flavor
- --- +
+
+
@@ -133,7 +145,7 @@ exports[` renders correctly 1`] = `
`; -exports[` renders off status correctly 1`] = ` +exports[` renders CPU and Memory settings for VM with custom flavor 1`] = `
@@ -141,6 +153,14 @@ exports[` renders off status correctly 1`] = ` class="co-m-pane__heading" > Virtual Machine Overview +
+ +
renders off status correctly 1`] = `
- VM has no description +
+ --- +
@@ -186,9 +208,9 @@ exports[` renders off status correctly 1`] = `
Operating System @@ -256,7 +278,12 @@ exports[` renders off status correctly 1`] = ` Flavor
- --- +
+
+
+ 2 CPU, 4G Memory +
+
@@ -266,7 +293,7 @@ exports[` renders off status correctly 1`] = `
`; -exports[` renders on status correctly 1`] = ` +exports[` renders IP addresses correctly 1`] = `
@@ -274,6 +301,14 @@ exports[` renders on status correctly 1`] = ` class="co-m-pane__heading" > Virtual Machine Overview +
+ +
renders on status correctly 1`] = `
- VM has no description +
+ --- +
@@ -333,7 +370,7 @@ exports[` renders on status correctly 1`] = ` IP Addresses
- --- + 172.17.0.15, 172.17.0.16, 172.17.0.17
Workload Profile @@ -389,7 +426,9 @@ exports[` renders on status correctly 1`] = ` Flavor
- --- +
+
+
@@ -399,7 +438,7 @@ exports[` renders on status correctly 1`] = `
`; -exports[` renders IP addresses correctly 1`] = ` +exports[` renders correctly 1`] = `
@@ -407,6 +446,14 @@ exports[` renders IP addresses correctly 1`] = ` class="co-m-pane__heading" > Virtual Machine Overview +
+ +
renders IP addresses correctly 1`] = `
- VM has no description +
+ --- +
@@ -452,9 +501,9 @@ exports[` renders IP addresses correctly 1`] = `
Operating System @@ -466,7 +515,7 @@ exports[` renders IP addresses correctly 1`] = ` IP Addresses
- 172.17.0.15, 172.17.0.16, 172.17.0.17 + ---
Workload Profile @@ -522,7 +571,9 @@ exports[` renders IP addresses correctly 1`] = ` Flavor
- --- +
+
+
@@ -532,7 +583,7 @@ exports[` renders IP addresses correctly 1`] = `
`; -exports[`VmDetails for VM with Custom flavor does not render CPU and Memory settings for VM without custom flavor 1`] = ` +exports[` renders description correctly 1`] = `
@@ -540,6 +591,14 @@ exports[`VmDetails for VM with Custom flavor does not render CPU and Memory sett class="co-m-pane__heading" > Virtual Machine Overview +
+ +
- VM has no description +
+ This VM has a description +
@@ -656,7 +717,7 @@ exports[`VmDetails for VM with Custom flavor does not render CPU and Memory sett
- small +
@@ -667,7 +728,7 @@ exports[`VmDetails for VM with Custom flavor does not render CPU and Memory sett
`; -exports[`VmDetails for VM with Custom flavor renders CPU and Memory settings for VM with custom flavor 1`] = ` +exports[` renders off status correctly 1`] = `
@@ -675,6 +736,14 @@ exports[`VmDetails for VM with Custom flavor renders CPU and Memory settings for class="co-m-pane__heading" > Virtual Machine Overview +
+ +
- VM has no description +
+ --- +
@@ -720,9 +791,9 @@ exports[`VmDetails for VM with Custom flavor renders CPU and Memory settings for
Operating System @@ -791,10 +862,7 @@ exports[`VmDetails for VM with Custom flavor renders CPU and Memory settings for
- Custom -
-
- 2 CPU, 4G Memory +
@@ -805,7 +873,7 @@ exports[`VmDetails for VM with Custom flavor renders CPU and Memory settings for
`; -exports[`VmDetails with description renders description correctly 1`] = ` +exports[` renders on status correctly 1`] = `
@@ -813,6 +881,14 @@ exports[`VmDetails with description renders description correctly 1`] = ` class="co-m-pane__heading" > Virtual Machine Overview +
+ +
- This VM has a description +
+ --- +
@@ -858,9 +936,9 @@ exports[`VmDetails with description renders description correctly 1`] = `
Operating System @@ -928,7 +1006,9 @@ exports[`VmDetails with description renders description correctly 1`] = ` Flavor
- --- +
+
+
@@ -938,7 +1018,7 @@ exports[`VmDetails with description renders description correctly 1`] = `
`; -exports[`VmDetails with labels renders values from labels correctly 1`] = ` +exports[` renders values from labels correctly 1`] = `
@@ -946,6 +1026,14 @@ exports[`VmDetails with labels renders values from labels correctly 1`] = ` class="co-m-pane__heading" > Virtual Machine Overview +
+ +
- VM has no description +
+ --- +
@@ -1017,7 +1107,7 @@ exports[`VmDetails with labels renders values from labels correctly 1`] = ` Template
- default_fedora-generic + default/fedora-generic
@@ -1062,7 +1152,7 @@ exports[`VmDetails with labels renders values from labels correctly 1`] = `
- small +
diff --git a/src/components/Dialog/BasicMigrationDialog/fixtures/BasicMigrationDialog.fixture.js b/src/components/Dialog/BasicMigrationDialog/fixtures/BasicMigrationDialog.fixture.js index 15556f507..ea70f98ff 100644 --- a/src/components/Dialog/BasicMigrationDialog/fixtures/BasicMigrationDialog.fixture.js +++ b/src/components/Dialog/BasicMigrationDialog/fixtures/BasicMigrationDialog.fixture.js @@ -1,6 +1,6 @@ import { BasicMigrationDialog } from '../BasicMigrationDialog'; import { k8sCreate } from '../../../Wizard/CreateVmWizard/fixtures/CreateVmWizard.fixture'; -import { blueVmi } from '../../../../k8s/mock_vmi/blue.vmi'; +import { blueVmi } from '../../../../k8s/mock_vmi/blue.mock'; export default [ { diff --git a/src/components/Dialog/BasicMigrationDialog/tests/BasicMigrationDialog.test.js b/src/components/Dialog/BasicMigrationDialog/tests/BasicMigrationDialog.test.js index 90027deb6..6c1a5bcb9 100644 --- a/src/components/Dialog/BasicMigrationDialog/tests/BasicMigrationDialog.test.js +++ b/src/components/Dialog/BasicMigrationDialog/tests/BasicMigrationDialog.test.js @@ -1,9 +1,10 @@ import React from 'react'; import { shallow, mount } from 'enzyme'; -import { BasicMigrationDialog } from '../BasicMigrationDialog'; +import { BasicMigrationDialog } from '..'; + import { k8sCreate } from '../../../Wizard/CreateVmWizard/fixtures/CreateVmWizard.fixture'; -import { blueVmi } from '../../../../k8s/mock_vmi/blue.vmi'; +import { blueVmi } from '../../../../k8s/mock_vmi/blue.mock'; import { migrate } from '../../../../k8s/migrate'; jest.mock('../../../../k8s/migrate'); diff --git a/src/components/Form/Dropdown.js b/src/components/Form/Dropdown.js index ed42475d8..f478e611e 100644 --- a/src/components/Form/Dropdown.js +++ b/src/components/Form/Dropdown.js @@ -2,8 +2,6 @@ import React from 'react'; import PropTypes from 'prop-types'; import { ButtonGroup, DropdownButton, MenuItem, noop } from 'patternfly-react'; -import { valueHandler } from './utils'; - export const Dropdown = ({ id, value, disabled, onChange, onBlur, choices }) => ( bsStyle="default" className="kubevirt-dropdown" title={value} - onSelect={valueHandler(onChange)} - onBlur={valueHandler(onBlur)} + onSelect={onChange} + onBlur={onBlur} disabled={disabled} > {choices.map(choice => { diff --git a/src/components/Form/FormFactory.js b/src/components/Form/FormFactory.js index a7217644b..252009a7e 100644 --- a/src/components/Form/FormFactory.js +++ b/src/components/Form/FormFactory.js @@ -129,18 +129,19 @@ const getFormGroups = ({ fields, fieldsValues, onFormChange, textPosition, label onChange: newValue => onChange(field, newValue, key, onFormChange), }); - const label = horizontal && ( - - {field.type !== 'checkbox' && ( - - {field.title} - {field.help && ( - - )} - - )} - - ); + const label = horizontal && + field.title && ( + + {field.type !== 'checkbox' && ( + + {field.title} + {field.help && ( + + )} + + )} + + ); return ( + getFormGroups({ fields, fieldsValues, onFormChange, horizontal: true }); + export const ListFormFactory = ({ fields, fieldsValues, onFormChange, actions, columnSizes }) => { const formGroups = getFormGroups({ fields, fieldsValues, onFormChange }); const form = formGroups.map((formGroup, index) => ( diff --git a/src/components/Form/tests/__snapshots__/Dropdown.test.js.snap b/src/components/Form/tests/__snapshots__/Dropdown.test.js.snap index 8a4a9aa34..fa51ea024 100644 --- a/src/components/Form/tests/__snapshots__/Dropdown.test.js.snap +++ b/src/components/Form/tests/__snapshots__/Dropdown.test.js.snap @@ -34,7 +34,7 @@ exports[` renders correctly 1`] = ` disabled={false} id="1" onBlur={[Function]} - onSelect={[Function]} + onSelect={[MockFunction]} title="This is dropdown button" > renders correctly 1`] = ` disabled={false} id="1" onBlur={[Function]} - onSelect={[Function]} + onSelect={[MockFunction]} title="This is dropdown button" > renders correctly 1`] = ` componentClass={[Function]} disabled={false} id="1" - onSelect={[Function]} + onSelect={[MockFunction]} onToggle={[Function]} > renders correctly 1`] = `
- -
- - -
- expect(typeof fn === 'function').toBeTruthy(); describe('', () => { it('checks handlers', () => { - checkFuntion(valueHandler(jest.fn())); checkFuntion(checkboxHandler(jest.fn())); checkFuntion(eventValueHandler(jest.fn())); diff --git a/src/components/Form/utils.js b/src/components/Form/utils.js index 0e5df3f1a..a2e2b01b2 100644 --- a/src/components/Form/utils.js +++ b/src/components/Form/utils.js @@ -1,5 +1,3 @@ -export const valueHandler = callback => (typeof callback === 'function' ? value => callback(value) : null); - export const eventValueHandler = (callback, defaultValue = '') => typeof callback === 'function' ? event => callback(event.target.value || defaultValue) : null; diff --git a/src/components/InlineEdit/InlineEdit.js b/src/components/InlineEdit/InlineEdit.js new file mode 100644 index 000000000..86a2a22ec --- /dev/null +++ b/src/components/InlineEdit/InlineEdit.js @@ -0,0 +1,42 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { InlineFormFactory } from '../Form/FormFactory'; +import { Loading } from '../Loading/Loading'; + +export const InlineEdit = ({ + updating, + editing, + formFields, + LoadingComponent, + children, + onFormChange, + fieldsValues, +}) => { + if (updating) { + return ; + } + if (editing) { + return ; + } + return
{children}
; +}; + +InlineEdit.propTypes = { + updating: PropTypes.bool, + editing: PropTypes.bool, + formFields: PropTypes.object, + LoadingComponent: PropTypes.func, + children: PropTypes.node.isRequired, + onFormChange: PropTypes.func, + fieldsValues: PropTypes.object, +}; + +InlineEdit.defaultProps = { + updating: false, + editing: false, + formFields: {}, + LoadingComponent: Loading, + onFormChange: () => {}, + fieldsValues: {}, +}; diff --git a/src/components/InlineEdit/fixtures/InlineEdit.fixture.js b/src/components/InlineEdit/fixtures/InlineEdit.fixture.js new file mode 100644 index 000000000..78e52cc1d --- /dev/null +++ b/src/components/InlineEdit/fixtures/InlineEdit.fixture.js @@ -0,0 +1,8 @@ +import { InlineEdit } from '..'; + +export default { + component: InlineEdit, + props: { + children: 'inline edit', + }, +}; diff --git a/src/components/InlineEdit/index.js b/src/components/InlineEdit/index.js new file mode 100644 index 000000000..f16a156e7 --- /dev/null +++ b/src/components/InlineEdit/index.js @@ -0,0 +1 @@ +export * from './InlineEdit'; diff --git a/src/components/InlineEdit/tests/InlineEdit.test.js b/src/components/InlineEdit/tests/InlineEdit.test.js new file mode 100644 index 000000000..7bad1e04d --- /dev/null +++ b/src/components/InlineEdit/tests/InlineEdit.test.js @@ -0,0 +1,15 @@ +import React from 'react'; +import { shallow } from 'enzyme'; + +import { InlineEdit } from '..'; + +import { default as InlineEditFixture } from '../fixtures/InlineEdit.fixture'; + +const testInlineEdit = () => ; + +describe('', () => { + it('renders correctly', () => { + const component = shallow(testInlineEdit()); + expect(component).toMatchSnapshot(); + }); +}); diff --git a/src/components/InlineEdit/tests/__snapshots__/InlineEdit.test.js.snap b/src/components/InlineEdit/tests/__snapshots__/InlineEdit.test.js.snap new file mode 100644 index 000000000..2a05bdd24 --- /dev/null +++ b/src/components/InlineEdit/tests/__snapshots__/InlineEdit.test.js.snap @@ -0,0 +1,7 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders correctly 1`] = ` +
+ inline edit +
+`; diff --git a/src/components/Loading/Loading.js b/src/components/Loading/Loading.js index 4dae02c44..58efb846f 100644 --- a/src/components/Loading/Loading.js +++ b/src/components/Loading/Loading.js @@ -9,5 +9,9 @@ export const Loading = ({ text }) => ( ); Loading.propTypes = { - text: PropTypes.string.isRequired, + text: PropTypes.string, +}; + +Loading.defaultProps = { + text: 'Loading', }; diff --git a/src/constants/index.js b/src/constants/index.js index aa489e0e1..758c95f86 100644 --- a/src/constants/index.js +++ b/src/constants/index.js @@ -64,3 +64,4 @@ export const VM_STATUS_TO_TEXT = { }; export const DEFAULT_RDP_PORT = 3389; +export const DASHES = '---'; diff --git a/src/k8s/mock_pod/cloudInitTestPod.pod.js b/src/k8s/mock_pod/cloudInitTestPod.mock.js similarity index 100% rename from src/k8s/mock_pod/cloudInitTestPod.pod.js rename to src/k8s/mock_pod/cloudInitTestPod.mock.js diff --git a/src/k8s/mock_vm/cloudInitTestVm.vm.js b/src/k8s/mock_vm/cloudInitTestVm.mock.js similarity index 100% rename from src/k8s/mock_vm/cloudInitTestVm.vm.js rename to src/k8s/mock_vm/cloudInitTestVm.mock.js diff --git a/src/k8s/mock_vmi/blue.vmi.js b/src/k8s/mock_vmi/blue.mock.js similarity index 100% rename from src/k8s/mock_vmi/blue.vmi.js rename to src/k8s/mock_vmi/blue.mock.js diff --git a/src/k8s/mock_vmi/cloudInitTestVmi.vmi.js b/src/k8s/mock_vmi/cloudInitTestVmi.mock.js similarity index 100% rename from src/k8s/mock_vmi/cloudInitTestVmi.vmi.js rename to src/k8s/mock_vmi/cloudInitTestVmi.mock.js diff --git a/src/k8s/tests/migrate.test.js b/src/k8s/tests/migrate.test.js index 028d7e8cf..bb217e1f3 100644 --- a/src/k8s/tests/migrate.test.js +++ b/src/k8s/tests/migrate.test.js @@ -1,6 +1,6 @@ import { migrate } from '../migrate'; import { k8sCreate } from './request.test'; -import { blueVmi } from '../mock_vmi/blue.vmi'; +import { blueVmi } from '../mock_vmi/blue.mock'; import { VirtualMachineInstanceMigrationModel } from '../../models'; import { getModelApi } from '../selectors'; diff --git a/src/utils/selectors.js b/src/utils/selectors.js index c99bebe37..774c5588e 100644 --- a/src/utils/selectors.js +++ b/src/utils/selectors.js @@ -34,7 +34,17 @@ export const getCpu = vm => get(vm, 'spec.template.spec.domain.cpu.cores'); export const getOperatingSystem = vm => getLabelValue(vm, TEMPLATE_OS_LABEL); export const getWorkloadProfile = vm => getLabelValue(vm, TEMPLATE_WORKLOAD_LABEL); export const getFlavor = vm => getLabelValue(vm, TEMPLATE_FLAVOR_LABEL); -export const getVmTemplate = vm => getLabelValue(vm, ANNOTATION_USED_TEMPLATE); +export const getVmTemplate = vm => { + const vmTemplate = getLabelValue(vm, ANNOTATION_USED_TEMPLATE); + if (vmTemplate) { + const templateParts = vmTemplate.split('_'); + return { + name: templateParts[1], + namespace: templateParts[0], + }; + } + return null; +}; export const getDescription = vm => get(vm, 'metadata.annotations.description'); export const getCloudInitData = vm => { const volumes = getVolumes(vm); diff --git a/src/utils/tests/selectors.test.js b/src/utils/tests/selectors.test.js index 732a574a3..53a3f5d4f 100644 --- a/src/utils/tests/selectors.test.js +++ b/src/utils/tests/selectors.test.js @@ -1,5 +1,5 @@ import { getVmiIpAddresses } from '../selectors'; -import { cloudInitTestVmi } from '../../k8s/mock_vmi/cloudInitTestVmi.vmi'; +import { cloudInitTestVmi } from '../../k8s/mock_vmi/cloudInitTestVmi.mock'; describe('getVmiIpAddresses()', () => { it('returns multiple IP addresses correctly', () => { diff --git a/src/utils/tests/utils.test.js b/src/utils/tests/utils.test.js index b5189d32a..07bab9e09 100644 --- a/src/utils/tests/utils.test.js +++ b/src/utils/tests/utils.test.js @@ -1,5 +1,14 @@ -import { ANNOTATION_FIRST_BOOT, BOOT_ORDER_SECOND, BOOT_ORDER_FIRST, PVC_ACCESSMODE_RWO } from '../../constants'; -import { getPxeBootPatch, getAddDiskPatch } from '../utils'; +import { cloneDeep } from 'lodash'; + +import { + ANNOTATION_FIRST_BOOT, + BOOT_ORDER_SECOND, + BOOT_ORDER_FIRST, + PVC_ACCESSMODE_RWO, + TEMPLATE_FLAVOR_LABEL, +} from '../../constants'; +import { getPxeBootPatch, getAddDiskPatch, getUpdateDescriptionPatch, getUpdateFlavorPatch } from '../utils'; +import { cloudInitTestVm } from '../../k8s/mock_vm/cloudInitTestVm.mock'; const getVM = firstBoot => ({ metadata: { @@ -75,11 +84,11 @@ const dataVolumeTemplate = { }, }; -const compareAddPatch = (patch, expectedPath, expectedValue) => { +const comparePatch = (patch, path, value, op = 'add') => { expect(patch).toEqual({ - op: 'add', - path: expectedPath, - value: expectedValue, + op, + path, + value, }); }; @@ -119,9 +128,9 @@ describe('utils.js tests', () => { const patch = getAddDiskPatch(vm, storageNoClass); expect(patch).toHaveLength(3); - compareAddPatch(patch[0], '/spec/template/spec/domain/devices/disks/0', disk); - compareAddPatch(patch[1], '/spec/template/spec/volumes', [volume]); - compareAddPatch(patch[2], '/spec/dataVolumeTemplates', [dataVolumeTemplate]); + comparePatch(patch[0], '/spec/template/spec/domain/devices/disks/0', disk); + comparePatch(patch[1], '/spec/template/spec/volumes', [volume]); + comparePatch(patch[2], '/spec/dataVolumeTemplates', [dataVolumeTemplate]); const dataVolWithClass = { ...dataVolumeTemplate, @@ -135,8 +144,171 @@ describe('utils.js tests', () => { }; const patchWithClass = getAddDiskPatch(vm, storage); expect(patchWithClass).toHaveLength(3); - compareAddPatch(patchWithClass[0], '/spec/template/spec/domain/devices/disks/0', disk); - compareAddPatch(patchWithClass[1], '/spec/template/spec/volumes', [volume]); - compareAddPatch(patchWithClass[2], '/spec/dataVolumeTemplates', [dataVolWithClass]); + comparePatch(patchWithClass[0], '/spec/template/spec/domain/devices/disks/0', disk); + comparePatch(patchWithClass[1], '/spec/template/spec/volumes', [volume]); + comparePatch(patchWithClass[2], '/spec/dataVolumeTemplates', [dataVolWithClass]); + }); + it('Update description patch', () => { + let patch = getUpdateDescriptionPatch(cloudInitTestVm, 'new description'); + expect(patch).toHaveLength(1); + comparePatch(patch[0], '/metadata/annotations/description', 'new description', 'replace'); + + patch = getUpdateDescriptionPatch(cloudInitTestVm, cloudInitTestVm.metadata.annotations.description); + expect(patch).toHaveLength(0); + + patch = getUpdateDescriptionPatch(cloudInitTestVm, ''); + expect(patch).toHaveLength(1); + comparePatch(patch[0], '/metadata/annotations/description', undefined, 'remove'); + + const vmWithNoDescription = cloneDeep(cloudInitTestVm); + delete vmWithNoDescription.metadata.annotations.description; + + patch = getUpdateDescriptionPatch(vmWithNoDescription, 'new description'); + expect(patch).toHaveLength(1); + comparePatch(patch[0], '/metadata/annotations/description', 'new description'); + + const vmWithNoAnnotations = cloneDeep(cloudInitTestVm); + delete vmWithNoAnnotations.metadata.annotations; + + patch = getUpdateDescriptionPatch(vmWithNoAnnotations, 'new description'); + expect(patch).toHaveLength(1); + comparePatch(patch[0], '/metadata/annotations', { description: 'new description' }); + }); + it('Update flavor patch', () => { + let patch = getUpdateFlavorPatch(cloudInitTestVm, 'Custom', '1', '3G'); + expect(patch).toHaveLength(4); + comparePatch(patch[0], `/metadata/labels/${TEMPLATE_FLAVOR_LABEL}~1small`, undefined, 'remove'); + comparePatch(patch[1], `/metadata/labels/${TEMPLATE_FLAVOR_LABEL}~1Custom`, 'true'); + comparePatch(patch[2], `/spec/template/spec/domain/cpu/cores`, 1, 'replace'); + comparePatch(patch[3], `/spec/template/spec/domain/resources/requests/memory`, '3G', 'replace'); + + patch = getUpdateFlavorPatch(cloudInitTestVm, 'small', '2', '2G'); + expect(patch).toHaveLength(0); + + patch = getUpdateFlavorPatch(cloudInitTestVm, 'small', '3', '2G'); + expect(patch).toHaveLength(1); + comparePatch(patch[0], `/spec/template/spec/domain/cpu/cores`, 3, 'replace'); + + patch = getUpdateFlavorPatch(cloudInitTestVm, 'small', '2', '1G'); + expect(patch).toHaveLength(1); + comparePatch(patch[0], `/spec/template/spec/domain/resources/requests/memory`, '1G', 'replace'); + + patch = getUpdateFlavorPatch(cloudInitTestVm, 'medium', '2', '2G'); + expect(patch).toHaveLength(2); + comparePatch(patch[0], `/metadata/labels/${TEMPLATE_FLAVOR_LABEL}~1small`, undefined, 'remove'); + comparePatch(patch[1], `/metadata/labels/${TEMPLATE_FLAVOR_LABEL}~1medium`, 'true'); + + const vmWithNoFlavorLabel = cloneDeep(cloudInitTestVm); + delete vmWithNoFlavorLabel.metadata.labels[`${TEMPLATE_FLAVOR_LABEL}/small`]; + + patch = getUpdateFlavorPatch(vmWithNoFlavorLabel, 'Custom', '1', '3G'); + expect(patch).toHaveLength(3); + comparePatch(patch[0], `/metadata/labels/${TEMPLATE_FLAVOR_LABEL}~1Custom`, 'true'); + comparePatch(patch[1], `/spec/template/spec/domain/cpu/cores`, 1, 'replace'); + comparePatch(patch[2], `/spec/template/spec/domain/resources/requests/memory`, '3G', 'replace'); + + const vmWithNoLabels = cloneDeep(cloudInitTestVm); + delete vmWithNoLabels.metadata.labels; + + patch = getUpdateFlavorPatch(vmWithNoLabels, 'Custom', '1', '3G'); + expect(patch).toHaveLength(4); + comparePatch(patch[0], `/metadata/labels`, {}); + comparePatch(patch[1], `/metadata/labels/${TEMPLATE_FLAVOR_LABEL}~1Custom`, 'true'); + comparePatch(patch[2], `/spec/template/spec/domain/cpu/cores`, 1, 'replace'); + comparePatch(patch[3], `/spec/template/spec/domain/resources/requests/memory`, '3G', 'replace'); + + const vmWithNoCores = cloneDeep(cloudInitTestVm); + delete vmWithNoCores.spec.template.spec.domain.cpu.cores; + + patch = getUpdateFlavorPatch(vmWithNoCores, 'Custom', '1', '3G'); + expect(patch).toHaveLength(4); + comparePatch(patch[0], `/metadata/labels/${TEMPLATE_FLAVOR_LABEL}~1small`, undefined, 'remove'); + comparePatch(patch[1], `/metadata/labels/${TEMPLATE_FLAVOR_LABEL}~1Custom`, 'true'); + comparePatch(patch[2], `/spec/template/spec/domain/cpu/cores`, 1); + comparePatch(patch[3], `/spec/template/spec/domain/resources/requests/memory`, '3G', 'replace'); + + const vmWithNoCpuCores = cloneDeep(cloudInitTestVm); + delete vmWithNoCpuCores.spec.template.spec.domain.cpu; + + patch = getUpdateFlavorPatch(vmWithNoCpuCores, 'Custom', '1', '3G'); + expect(patch).toHaveLength(4); + comparePatch(patch[0], `/metadata/labels/${TEMPLATE_FLAVOR_LABEL}~1small`, undefined, 'remove'); + comparePatch(patch[1], `/metadata/labels/${TEMPLATE_FLAVOR_LABEL}~1Custom`, 'true'); + comparePatch(patch[2], `/spec/template/spec/domain/cpu`, { cores: 1 }); + comparePatch(patch[3], `/spec/template/spec/domain/resources/requests/memory`, '3G', 'replace'); + + const vmWithNoMemory = cloneDeep(cloudInitTestVm); + delete vmWithNoMemory.spec.template.spec.domain.resources.requests.memory; + + patch = getUpdateFlavorPatch(vmWithNoMemory, 'Custom', '1', '3G'); + expect(patch).toHaveLength(4); + comparePatch(patch[0], `/metadata/labels/${TEMPLATE_FLAVOR_LABEL}~1small`, undefined, 'remove'); + comparePatch(patch[1], `/metadata/labels/${TEMPLATE_FLAVOR_LABEL}~1Custom`, 'true'); + comparePatch(patch[2], `/spec/template/spec/domain/cpu/cores`, 1, 'replace'); + comparePatch(patch[3], `/spec/template/spec/domain/resources/requests/memory`, '3G'); + + const vmWithNoRequests = cloneDeep(cloudInitTestVm); + delete vmWithNoRequests.spec.template.spec.domain.resources.requests; + + patch = getUpdateFlavorPatch(vmWithNoRequests, 'Custom', '1', '3G'); + expect(patch).toHaveLength(4); + comparePatch(patch[0], `/metadata/labels/${TEMPLATE_FLAVOR_LABEL}~1small`, undefined, 'remove'); + comparePatch(patch[1], `/metadata/labels/${TEMPLATE_FLAVOR_LABEL}~1Custom`, 'true'); + comparePatch(patch[2], `/spec/template/spec/domain/cpu/cores`, 1, 'replace'); + comparePatch(patch[3], `/spec/template/spec/domain/resources/requests`, { memory: '3G' }); + + const vmWithNoResources = cloneDeep(cloudInitTestVm); + delete vmWithNoResources.spec.template.spec.domain.resources; + + patch = getUpdateFlavorPatch(vmWithNoResources, 'Custom', '1', '3G'); + expect(patch).toHaveLength(4); + comparePatch(patch[0], `/metadata/labels/${TEMPLATE_FLAVOR_LABEL}~1small`, undefined, 'remove'); + comparePatch(patch[1], `/metadata/labels/${TEMPLATE_FLAVOR_LABEL}~1Custom`, 'true'); + comparePatch(patch[2], `/spec/template/spec/domain/cpu/cores`, 1, 'replace'); + comparePatch(patch[3], `/spec/template/spec/domain/resources`, { requests: { memory: '3G' } }); + + const vmWithNoDomain = cloneDeep(cloudInitTestVm); + delete vmWithNoDomain.spec.template.spec.domain; + + patch = getUpdateFlavorPatch(vmWithNoDomain, 'Custom', '1', '3G'); + expect(patch).toHaveLength(5); + comparePatch(patch[0], `/metadata/labels/${TEMPLATE_FLAVOR_LABEL}~1small`, undefined, 'remove'); + comparePatch(patch[1], `/metadata/labels/${TEMPLATE_FLAVOR_LABEL}~1Custom`, 'true'); + comparePatch(patch[2], `/spec/template/spec/domain`, {}); + comparePatch(patch[3], `/spec/template/spec/domain/cpu`, { cores: 1 }); + comparePatch(patch[4], `/spec/template/spec/domain/resources`, { requests: { memory: '3G' } }); + + const vmWithNoTemplateSpec = cloneDeep(cloudInitTestVm); + delete vmWithNoTemplateSpec.spec.template.spec; + + patch = getUpdateFlavorPatch(vmWithNoTemplateSpec, 'Custom', '1', '3G'); + expect(patch).toHaveLength(5); + comparePatch(patch[0], `/metadata/labels/${TEMPLATE_FLAVOR_LABEL}~1small`, undefined, 'remove'); + comparePatch(patch[1], `/metadata/labels/${TEMPLATE_FLAVOR_LABEL}~1Custom`, 'true'); + comparePatch(patch[2], `/spec/template/spec`, { domain: {} }); + comparePatch(patch[3], `/spec/template/spec/domain/cpu`, { cores: 1 }); + comparePatch(patch[4], `/spec/template/spec/domain/resources`, { requests: { memory: '3G' } }); + + const vmWithNoTemplate = cloneDeep(cloudInitTestVm); + delete vmWithNoTemplate.spec.template; + + patch = getUpdateFlavorPatch(vmWithNoTemplate, 'Custom', '1', '3G'); + expect(patch).toHaveLength(5); + comparePatch(patch[0], `/metadata/labels/${TEMPLATE_FLAVOR_LABEL}~1small`, undefined, 'remove'); + comparePatch(patch[1], `/metadata/labels/${TEMPLATE_FLAVOR_LABEL}~1Custom`, 'true'); + comparePatch(patch[2], `/spec/template`, { spec: { domain: {} } }); + comparePatch(patch[3], `/spec/template/spec/domain/cpu`, { cores: 1 }); + comparePatch(patch[4], `/spec/template/spec/domain/resources`, { requests: { memory: '3G' } }); + + const vmWithNoSpec = cloneDeep(cloudInitTestVm); + delete vmWithNoSpec.spec; + + patch = getUpdateFlavorPatch(vmWithNoSpec, 'Custom', '1', '3G'); + expect(patch).toHaveLength(5); + comparePatch(patch[0], `/metadata/labels/${TEMPLATE_FLAVOR_LABEL}~1small`, undefined, 'remove'); + comparePatch(patch[1], `/metadata/labels/${TEMPLATE_FLAVOR_LABEL}~1Custom`, 'true'); + comparePatch(patch[2], `/spec`, { template: { spec: { domain: {} } } }); + comparePatch(patch[3], `/spec/template/spec/domain/cpu`, { cores: 1 }); + comparePatch(patch[4], `/spec/template/spec/domain/resources`, { requests: { memory: '3G' } }); }); }); diff --git a/src/utils/utils.js b/src/utils/utils.js index 3dd8d1ee1..c92bee2ef 100644 --- a/src/utils/utils.js +++ b/src/utils/utils.js @@ -1,7 +1,13 @@ -import { get } from 'lodash'; +import { get, has } from 'lodash'; -import { getDisks, getInterfaces, getName } from './selectors'; -import { ANNOTATION_FIRST_BOOT, BOOT_ORDER_FIRST, BOOT_ORDER_SECOND, PVC_ACCESSMODE_RWO } from '../constants'; +import { getDisks, getInterfaces, getName, getDescription, getFlavor, getCpu, getMemory } from './selectors'; +import { + ANNOTATION_FIRST_BOOT, + BOOT_ORDER_FIRST, + BOOT_ORDER_SECOND, + PVC_ACCESSMODE_RWO, + TEMPLATE_FLAVOR_LABEL, +} from '../constants'; export function prefixedId(idPrefix, id) { return idPrefix && id ? `${idPrefix}-${id}` : null; @@ -143,3 +149,174 @@ export const getAddDiskPatch = (vm, storage) => { return patch; }; + +export const getUpdateDescriptionPatch = (vm, description) => { + const patch = []; + if (description !== getDescription(vm)) { + if (!description && has(vm.metadata, 'annotations.description')) { + patch.push({ + op: 'remove', + path: '/metadata/annotations/description', + }); + } else if (!has(vm.metadata, 'annotations')) { + patch.push({ + op: 'add', + path: '/metadata/annotations', + value: { + description, + }, + }); + } else { + patch.push({ + op: has(vm.metadata, 'annotations.description') ? 'replace' : 'add', + path: '/metadata/annotations/description', + value: description, + }); + } + } + return patch; +}; + +const getDomainPatch = vm => { + let patch; + if (!has(vm, 'spec')) { + patch = { + op: 'add', + path: '/spec', + value: { + template: { + spec: { + domain: {}, + }, + }, + }, + }; + } else if (!has(vm.spec, 'template')) { + patch = { + op: 'add', + path: '/spec/template', + value: { + spec: { + domain: {}, + }, + }, + }; + } else if (!has(vm.spec.template, 'spec')) { + patch = { + op: 'add', + path: '/spec/template/spec', + value: { + domain: {}, + }, + }; + } else if (!has(vm.spec.template.spec, 'domain')) { + patch = { + op: 'add', + path: '/spec/template/spec/domain', + value: {}, + }; + } + return patch; +}; + +const getLabelsPatch = vm => { + if (!has(vm.metadata, 'labels')) { + return { + op: 'add', + path: '/metadata/labels', + value: {}, + }; + } + return null; +}; + +const getCpuPatch = (vm, cpu) => { + if (!has(vm.spec, 'template.spec.domain.cpu')) { + return { + op: 'add', + path: '/spec/template/spec/domain/cpu', + value: { + cores: parseInt(cpu, 10), + }, + }; + } + return { + op: has(vm.spec, 'template.spec.domain.cpu.cores') ? 'replace' : 'add', + path: '/spec/template/spec/domain/cpu/cores', + value: parseInt(cpu, 10), + }; +}; + +const getMemoryPatch = (vm, memory) => { + if (!has(vm.spec, 'template.spec.domain.resources')) { + return { + op: 'add', + path: '/spec/template/spec/domain/resources', + value: { + requests: { + memory, + }, + }, + }; + } + if (!has(vm.spec, 'template.spec.domain.resources.requests')) { + return { + op: 'add', + path: '/spec/template/spec/domain/resources/requests', + value: { + memory, + }, + }; + } + return { + op: has(vm.spec, 'template.spec.domain.resources.requests.memory') ? 'replace' : 'add', + path: '/spec/template/spec/domain/resources/requests/memory', + value: memory, + }; +}; + +export const getUpdateFlavorPatch = (vm, flavor, cpu, memory) => { + const patch = []; + if (flavor !== getFlavor(vm)) { + const labelKey = `${TEMPLATE_FLAVOR_LABEL}/${flavor}`.replace('~', '~0').replace('/', '~1'); + const labelPatch = getLabelsPatch(vm); + if (labelPatch) { + patch.push(labelPatch); + } + const flavorLabel = Object.keys(vm.metadata.labels || {}).find(key => key.startsWith(TEMPLATE_FLAVOR_LABEL)); + if (flavorLabel) { + const flavorParts = flavorLabel.split('/'); + if (flavorParts[flavorParts.length - 1] !== flavor) { + const escapedLabel = flavorLabel.replace('~', '~0').replace('/', '~1'); + patch.push({ + op: 'remove', + path: `/metadata/labels/${escapedLabel}`, + }); + } + } + patch.push({ + op: 'add', + path: `/metadata/labels/${labelKey}`, + value: 'true', + }); + } + + const vmCpu = getCpu(vm); + const vmMemory = getMemory(vm); + + if (parseInt(cpu, 10) !== vmCpu || memory !== vmMemory) { + const domainPatch = getDomainPatch(vm); + if (domainPatch) { + patch.push(domainPatch); + } + } + + if (parseInt(cpu, 10) !== vmCpu) { + patch.push(getCpuPatch(vm, cpu)); + } + + if (memory !== vmMemory) { + patch.push(getMemoryPatch(vm, memory)); + } + return patch; +};