diff --git a/app/helpers/manageiq/providers/nuage/toolbar_overrides/network_router_center.rb b/app/helpers/manageiq/providers/nuage/toolbar_overrides/network_router_center.rb new file mode 100644 index 0000000000..0b896ac151 --- /dev/null +++ b/app/helpers/manageiq/providers/nuage/toolbar_overrides/network_router_center.rb @@ -0,0 +1,35 @@ +module ManageIQ + module Providers + module Nuage + module ToolbarOverrides + class NetworkRouterCenter < ::ApplicationHelper::Toolbar::Override + button_group( + 'nuage_network_router', + [ + select( + :nuage_network_router, + 'fa fa-cog fa-lg', + t = N_('Edit'), + t, + :items => [ + button( + :nuage_create_cloud_subnet, + 'pficon pficon-add-circle-o fa-lg', + t = N_('Create L3 Cloud Subnet'), + t, + :data => {'function' => 'sendDataWithRx', + 'function-data' => {:controller => 'provider_dialogs', + :button => :nuage_create_cloud_subnet, + :modal_title => N_('Create L3 Cloud Subnet'), + :component_name => 'CreateNuageCloudSubnetForm'}.to_json}, + :klass => ApplicationHelper::Button::ButtonWithoutRbacCheck + ), + ] + ) + ] + ) + end + end + end + end +end diff --git a/app/javascript/components/create-nuage-cloud-subnet-form.jsx b/app/javascript/components/create-nuage-cloud-subnet-form.jsx new file mode 100644 index 0000000000..fe5e2241eb --- /dev/null +++ b/app/javascript/components/create-nuage-cloud-subnet-form.jsx @@ -0,0 +1,53 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import NuageCloudSubnetForm from './forms/nuage-cloud-subnet-form' +import { handleApiError, createSubnet, fetchRouter } from '../utils/api.js' + +class CreateNuageCloudSubnetForm extends React.Component { + constructor(props) { + super(props); + this.handleFormStateUpdate = this.handleFormStateUpdate.bind(this); + this.state = { + loading: true + } + } + + componentDidMount() { + this.props.dispatch({ + type: 'FormButtons.init', + payload: { + newRecord: true, + pristine: true, + addClicked: () => createSubnet(this.state.values, this.state.emsId, this.state.routerRef) + } + }); + fetchRouter(ManageIQ.record.recordId).then(router => { + this.setState({emsId: router.ems_id, routerRef: router.ems_ref, loading: false}); + }, handleApiError(this)); + } + + handleFormStateUpdate(formState) { + this.props.dispatch({ type: 'FormButtons.saveable', payload: formState.valid }); + this.props.dispatch({ type: 'FormButtons.pristine', payload: formState.pristine }); + this.setState({ values: formState.values }); + } + + render() { + if(this.state.error) { + return

{this.state.error}

+ } + return ( + + ); + } +} + +CreateNuageCloudSubnetForm.propTypes = { + dispatch: PropTypes.func.isRequired, +}; + +export default connect()(CreateNuageCloudSubnetForm); diff --git a/app/javascript/components/forms/nuage-cloud-subnet-form.jsx b/app/javascript/components/forms/nuage-cloud-subnet-form.jsx new file mode 100644 index 0000000000..4b8b86e890 --- /dev/null +++ b/app/javascript/components/forms/nuage-cloud-subnet-form.jsx @@ -0,0 +1,79 @@ +import React, { Component } from 'react'; +import { Form, Field, FormSpy } from 'react-final-form'; +import { Form as PfForm, Grid, Button, Col, Row, Spinner } from 'patternfly-react'; +import PropTypes from 'prop-types'; +import { required } from 'redux-form-validators'; + +import { FinalFormField, FinalFormTextArea, FinalFormSelect } from '@manageiq/react-ui-components/dist/forms'; +import { ip4Validator, netmaskValidator } from '../../utils/validators' + +const NuageCloudSubnetForm = ({loading, updateFormState}) => { + if(loading){ + return ( + + ); + } + + return ( +
{}} // handled by modal + render={({ handleSubmit }) => ( + + updateFormState({ ...state, values: state.values })} /> + + + + + + + + + + + + + + +
+
+
+
+ )} + /> + ); +}; + +NuageCloudSubnetForm.propTypes = { + updateFormState: PropTypes.func.isRequired, + loading: PropTypes.bool +}; + +NuageCloudSubnetForm.defaultProps = { + loading: false, +}; + +export default NuageCloudSubnetForm; diff --git a/app/javascript/packs/component-definitions-common.js b/app/javascript/packs/component-definitions-common.js new file mode 100644 index 0000000000..e1cf75709d --- /dev/null +++ b/app/javascript/packs/component-definitions-common.js @@ -0,0 +1,3 @@ +import CreateNuageCloudSubnetForm from '../components/create-nuage-cloud-subnet-form'; + +ManageIQ.component.addReact('CreateNuageCloudSubnetForm', CreateNuageCloudSubnetForm); diff --git a/app/javascript/utils/api.js b/app/javascript/utils/api.js new file mode 100644 index 0000000000..d42465eca7 --- /dev/null +++ b/app/javascript/utils/api.js @@ -0,0 +1,18 @@ +export const createSubnet = (values, emsId, routerRef) => API.post(`/api/providers/${emsId}/cloud_subnets`, { + action: 'create', + resource: {...values, router_ref: routerRef}, +}).then(response => { + response['results'].forEach(res => window.add_flash(res.message, res.success ? 'success' : 'error')); +}); + +export const fetchRouter = (routerId) => API.get(`/api/network_routers/${routerId}?attributes=ems_ref,name,ems_id`); + +export const handleApiError = (self) => { + return (err) => { + let msg = __('Unknown API error'); + if(err.data && err.data.error && err.data.error.message) { + msg = err.data.error.message + } + self.setState({loading: false, error: msg}); + }; +}; diff --git a/app/javascript/utils/validators.js b/app/javascript/utils/validators.js new file mode 100644 index 0000000000..5af2bb9241 --- /dev/null +++ b/app/javascript/utils/validators.js @@ -0,0 +1,15 @@ +import { addValidator } from 'redux-form-validators'; + +export const ip4Validator = addValidator({ + defaultMessage: __('Must be IPv4 address'), + validator: function(options, value, allValues) { + return (/^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/).test(value) + } +}); + +export const netmaskValidator = addValidator({ + defaultMessage: __('Must be netmask'), + validator: function(options, value, allValues) { + return (/^(((128|192|224|240|248|252|254)\.0\.0\.0)|(255\.(0|128|192|224|240|248|252|254)\.0\.0)|(255\.255\.(0|128|192|224|240|248|252|254)\.0)|(255\.255\.255\.(0|128|192|224|240|248|252|254)))$/).test(value) + } +}); diff --git a/package.json b/package.json index ba55b2d71a..f87099ba36 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,6 @@ "jest": { "testURL": "http://localhost", "setupTestFrameworkScriptFile": "/spec/javascript/setup.js", - "testPathDirs": ["/spec/javascript"] + "roots": ["/spec/javascript"] } } diff --git a/spec/javascript/components/__snapshots__/components.test.jsx.snap b/spec/javascript/components/__snapshots__/components.test.jsx.snap deleted file mode 100644 index d755677d14..0000000000 --- a/spec/javascript/components/__snapshots__/components.test.jsx.snap +++ /dev/null @@ -1,7 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[` renders component 1`] = ` -
-`; diff --git a/spec/javascript/components/__snapshots__/create-nuage-cloud-subnet-form.test.jsx.snap b/spec/javascript/components/__snapshots__/create-nuage-cloud-subnet-form.test.jsx.snap new file mode 100644 index 0000000000..e94a3aa5b6 --- /dev/null +++ b/spec/javascript/components/__snapshots__/create-nuage-cloud-subnet-form.test.jsx.snap @@ -0,0 +1,14 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CreateNuageCloudSubnetForm componentDidMount router fetch fails 1`] = ` +

+ MSG +

+`; + +exports[`CreateNuageCloudSubnetForm componentDidMount router fetch succeeds 1`] = ` + +`; diff --git a/spec/javascript/components/components.test.jsx b/spec/javascript/components/components.test.jsx deleted file mode 100644 index 1a7eadfa1e..0000000000 --- a/spec/javascript/components/components.test.jsx +++ /dev/null @@ -1,11 +0,0 @@ -import React from 'react'; -import { shallow } from 'enzyme'; -import toJson from 'enzyme-to-json'; - -import { Spinner } from 'patternfly-react'; - -describe('', () => { - it('renders component', () => { - expect(toJson(shallow())).toMatchSnapshot(); - }); -}); diff --git a/spec/javascript/components/create-nuage-cloud-subnet-form.test.jsx b/spec/javascript/components/create-nuage-cloud-subnet-form.test.jsx new file mode 100644 index 0000000000..e3358ef977 --- /dev/null +++ b/spec/javascript/components/create-nuage-cloud-subnet-form.test.jsx @@ -0,0 +1,55 @@ +import CreateNuageCloudSubnetForm from '../../../app/javascript/components/create-nuage-cloud-subnet-form' +import * as api from "../../../app/javascript/utils/api"; + +const fetchRouterMock = jest.spyOn(api, 'fetchRouter').mockResolvedValue('SUCCESS'); +const dispatchMock = jest.spyOn(DEFAULT_STORE, 'dispatch'); + +let renderComponent; +let renderComponentFull; + +describe('CreateNuageCloudSubnetForm', () => { + beforeAll(() => { + renderComponent = () => shallowRedux(); + renderComponentFull = () => mountRedux(); + }); + + beforeEach(() => { + ManageIQ.record.recordId = 123; + }); + + describe('componentDidMount', () => { + it('router fetch succeeds', () => { + fetchRouterMock.mockResolvedValue({ ems_id: 111, ems_ref: 222 }); + let component = renderComponent(); + return fetchRouterMock().then(() => { + component.update(); + expect(fetchRouterMock).toHaveBeenCalledWith(123); + expect(component.state()).toEqual({emsId: 111, routerRef: 222, loading: false}); + expect(toJson(component)).toMatchSnapshot(); + }); + }); + + it('router fetch fails', () => { + fetchRouterMock.mockRejectedValue({data: {error: { message: 'MSG'}}}); + let component = renderComponent(); + return fetchRouterMock().catch(() => { + component.update(); + expect(fetchRouterMock).toHaveBeenCalledWith(123); + expect(component.state()).toEqual({loading: false, error: 'MSG'}); + expect(toJson(component)).toMatchSnapshot(); + }); + }); + }); + + describe('redux bindings', () => { + it('when fully mounted', () => { + fetchRouterMock.mockResolvedValue('SUCCESS'); + let component = renderComponentFull(); + return fetchRouterMock().then(() => { + expect(dispatchMock).toHaveBeenCalledWith({type: 'FormButtons.init', payload: expect.anything()}); + expect(dispatchMock).toHaveBeenCalledWith({type: 'FormButtons.saveable', payload: expect.anything()}); + expect(dispatchMock).toHaveBeenCalledWith({type: 'FormButtons.pristine', payload: expect.anything()}); + }); + }); + }); +}); diff --git a/spec/javascript/components/forms/__snapshots__/nuage-cloud-subnet-form.test.jsx.snap b/spec/javascript/components/forms/__snapshots__/nuage-cloud-subnet-form.test.jsx.snap new file mode 100644 index 0000000000..972368a290 --- /dev/null +++ b/spec/javascript/components/forms/__snapshots__/nuage-cloud-subnet-form.test.jsx.snap @@ -0,0 +1,92 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`NuageCloudSubnetForm renders form 1`] = ` + + + + + + + + + + + + + + + + +
+
+
+ +`; + +exports[`NuageCloudSubnetForm renders spinner 1`] = ` +
+`; diff --git a/spec/javascript/components/forms/nuage-cloud-subnet-form.test.jsx b/spec/javascript/components/forms/nuage-cloud-subnet-form.test.jsx new file mode 100644 index 0000000000..f8c6343818 --- /dev/null +++ b/spec/javascript/components/forms/nuage-cloud-subnet-form.test.jsx @@ -0,0 +1,21 @@ +import NuageCloudSubnetForm from '../../../../app/javascript/components/forms/nuage-cloud-subnet-form' + +let renderComponent; + +describe('NuageCloudSubnetForm', () => { + beforeAll(() => { + renderComponent = (loading = false) => shallowRedux(); + }); + + describe('renders', () => { + it('form', () => { + let component = renderComponent(); + expect(toJson(component)).toMatchSnapshot(); + }); + + it('spinner', () => { + let component = renderComponent(true); + expect(toJson(component)).toMatchSnapshot(); + }); + }); +}); diff --git a/spec/javascript/setup.js b/spec/javascript/setup.js index fc7b0dce1f..04fc7149e9 100644 --- a/spec/javascript/setup.js +++ b/spec/javascript/setup.js @@ -1,4 +1,26 @@ +import React from 'react'; import Enzyme from 'enzyme'; import Adapter from 'enzyme-adapter-react-16'; +import configureStore from 'redux-mock-store'; +import { shallow, mount } from 'enzyme'; +import toJson from 'enzyme-to-json'; +// Enzyme configuration and some utility functions. Enzyme.configure({ adapter: new Adapter() }); +global.shallowRedux = (component) => shallow(component, DEFAULT_CONTEXT).dive(); +global.mountRedux = (component) => mount(component, DEFAULT_CONTEXT); + +// Global variables that Components usually get from elsewhere. +global.ManageIQ = { record: { recordId: -1 } }; +global.__ = jest.fn().mockImplementation((val) => `_${val}_`); + +// Redux store mocks. +global.mockStore = configureStore(); +global.DEFAULT_STORE = mockStore({}); +global.DEFAULT_CONTEXT = { context: {store: DEFAULT_STORE} }; + +// Make common functions available to all tests so we don't need to import every time. +global.shallow = shallow; +global.mount = mount; +global.toJson = toJson; +global.React = React;