diff --git a/__tests__/.eslintrc b/__tests__/.eslintrc new file mode 100644 index 0000000000..a155c21b32 --- /dev/null +++ b/__tests__/.eslintrc @@ -0,0 +1,5 @@ +{ + "rules": { + "react/jsx-pascal-case": "0" + } +} diff --git a/__tests__/pages/Organizations/components/OrganizationForm.test.jsx b/__tests__/pages/Organizations/components/OrganizationForm.test.jsx new file mode 100644 index 0000000000..7c1d32dbc0 --- /dev/null +++ b/__tests__/pages/Organizations/components/OrganizationForm.test.jsx @@ -0,0 +1,240 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import { MemoryRouter } from 'react-router-dom'; +import { I18nProvider } from '@lingui/react'; +import { ConfigContext } from '../../../../src/context'; +import OrganizationForm from '../../../../src/pages/Organizations/components/OrganizationForm'; + +const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)); + +describe('', () => { + let api; + + const mockData = { + id: 1, + name: 'Foo', + description: 'Bar', + custom_virtualenv: 'Fizz', + related: { + instance_groups: '/api/v2/organizations/1/instance_groups' + } + }; + + beforeEach(() => { + api = { + getInstanceGroups: jest.fn(), + }; + }); + + test('should request related instance groups from api', () => { + const mockInstanceGroups = [ + { name: 'One', id: 1 }, + { name: 'Two', id: 2 } + ]; + api.getOrganizationInstanceGroups = jest.fn(() => ( + Promise.resolve({ data: { results: mockInstanceGroups } }) + )); + mount( + + + + + + ).find('OrganizationForm'); + + expect(api.getOrganizationInstanceGroups).toHaveBeenCalledTimes(1); + }); + + test('componentDidMount should set instanceGroups to state', async () => { + const mockInstanceGroups = [ + { name: 'One', id: 1 }, + { name: 'Two', id: 2 } + ]; + api.getOrganizationInstanceGroups = jest.fn(() => ( + Promise.resolve({ data: { results: mockInstanceGroups } }) + )); + const wrapper = mount( + + + + + + ).find('OrganizationForm'); + + await wrapper.instance().componentDidMount(); + expect(wrapper.state().instanceGroups).toEqual(mockInstanceGroups); + }); + + test('changing instance group successfully sets instanceGroups state', () => { + const wrapper = mount( + + + + + + ).find('OrganizationForm'); + + const lookup = wrapper.find('InstanceGroupsLookup'); + expect(lookup.length).toBe(1); + + lookup.prop('onChange')([ + { + id: 1, + name: 'foo' + } + ], 'instanceGroups'); + expect(wrapper.state().instanceGroups).toEqual([ + { + id: 1, + name: 'foo' + } + ]); + }); + + test('changing inputs should update form values', () => { + const wrapper = mount( + + + + + + ).find('OrganizationForm'); + + const form = wrapper.find('Formik'); + wrapper.find('input#edit-org-form-name').simulate('change', { + target: { value: 'new foo', name: 'name' } + }); + expect(form.state('values').name).toEqual('new foo'); + wrapper.find('input#edit-org-form-description').simulate('change', { + target: { value: 'new bar', name: 'description' } + }); + expect(form.state('values').description).toEqual('new bar'); + }); + + test('AnsibleSelect component renders if there are virtual environments', () => { + const config = { + custom_virtualenvs: ['foo', 'bar'], + }; + const wrapper = mount( + + + + + + + + ); + expect(wrapper.find('FormSelect')).toHaveLength(1); + expect(wrapper.find('FormSelectOption')).toHaveLength(2); + }); + + test('calls handleSubmit when form submitted', async () => { + const handleSubmit = jest.fn(); + const wrapper = mount( + + + + + + ).find('OrganizationForm'); + expect(wrapper.prop('handleSubmit')).not.toHaveBeenCalled(); + wrapper.find('button[aria-label="Save"]').simulate('click'); + await sleep(1); + expect(handleSubmit).toHaveBeenCalledWith({ + name: 'Foo', + description: 'Bar', + custom_virtualenv: 'Fizz', + }, [], []); + }); + + test('handleSubmit associates and disassociates instance groups', async () => { + const mockInstanceGroups = [ + { name: 'One', id: 1 }, + { name: 'Two', id: 2 } + ]; + api.getOrganizationInstanceGroups = jest.fn(() => ( + Promise.resolve({ data: { results: mockInstanceGroups } }) + )); + const mockDataForm = { + name: 'Foo', + description: 'Bar', + custom_virtualenv: 'Fizz', + }; + const handleSubmit = jest.fn(); + api.updateOrganizationDetails = jest.fn().mockResolvedValue(1, mockDataForm); + api.associateInstanceGroup = jest.fn().mockResolvedValue('done'); + api.disassociate = jest.fn().mockResolvedValue('done'); + const wrapper = mount( + + + + + + ).find('OrganizationForm'); + + await wrapper.instance().componentDidMount(); + + wrapper.find('InstanceGroupsLookup').prop('onChange')([ + { name: 'One', id: 1 }, + { name: 'Three', id: 3 } + ], 'instanceGroups'); + + wrapper.find('button[aria-label="Save"]').simulate('click'); + await sleep(0); + expect(handleSubmit).toHaveBeenCalledWith(mockDataForm, [3], [2]); + }); + + test('calls "handleCancel" when Cancel button is clicked', () => { + const handleCancel = jest.fn(); + const wrapper = mount( + + + + + + ); + expect(handleCancel).not.toHaveBeenCalled(); + wrapper.find('button[aria-label="Cancel"]').prop('onClick')(); + expect(handleCancel).toBeCalled(); + }); +}); diff --git a/__tests__/pages/Organizations/screens/Organization/OrganizationEdit.test.jsx b/__tests__/pages/Organizations/screens/Organization/OrganizationEdit.test.jsx index 1160f6ed4d..3351dd8095 100644 --- a/__tests__/pages/Organizations/screens/Organization/OrganizationEdit.test.jsx +++ b/__tests__/pages/Organizations/screens/Organization/OrganizationEdit.test.jsx @@ -2,11 +2,13 @@ import React from 'react'; import { mount } from 'enzyme'; import { MemoryRouter } from 'react-router-dom'; import { I18nProvider } from '@lingui/react'; -import { ConfigContext } from '../../../../../src/context'; -import APIClient from '../../../../../src/api'; -import OrganizationEdit from '../../../../../src/pages/Organizations/screens/Organization/OrganizationEdit'; +import OrganizationEdit, { _OrganizationEdit } from '../../../../../src/pages/Organizations/screens/Organization/OrganizationEdit'; + +const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)); describe('', () => { + let api; + const mockData = { name: 'Foo', description: 'Bar', @@ -17,228 +19,93 @@ describe('', () => { } }; - test('should request related instance groups from api', () => { - const mockInstanceGroups = [ - { name: 'One', id: 1 }, - { name: 'Two', id: 2 } - ]; - const getOrganizationInstanceGroups = jest.fn(() => ( - Promise.resolve({ data: { results: mockInstanceGroups } }) - )); - mount( - - - - - - ).find('OrganizationEdit'); - - expect(getOrganizationInstanceGroups).toHaveBeenCalledTimes(1); - }); - - test('componentDidMount should set instanceGroups to state', async () => { - const mockInstanceGroups = [ - { name: 'One', id: 1 }, - { name: 'Two', id: 2 } - ]; - const getOrganizationInstanceGroups = jest.fn(() => ( - Promise.resolve({ data: { results: mockInstanceGroups } }) - )); - const wrapper = mount( - - - - - - ).find('OrganizationEdit'); - - await wrapper.instance().componentDidMount(); - expect(wrapper.state().form.instanceGroups.value).toEqual(mockInstanceGroups); - }); - test('onLookupSave successfully sets instanceGroups state', () => { - const api = jest.fn(); - const wrapper = mount( - - - - - - ).find('OrganizationEdit'); - - wrapper.instance().onLookupSave([ - { - id: 1, - name: 'foo' - } - ], 'instanceGroups'); - expect(wrapper.state().form.instanceGroups.value).toEqual([ - { - id: 1, - name: 'foo' - } - ]); - }); - test('calls "onFieldChange" when input values change', () => { - const api = new APIClient(); - const spy = jest.spyOn(OrganizationEdit.WrappedComponent.prototype, 'onFieldChange'); - const wrapper = mount( - - - - - - ).find('OrganizationEdit'); - - expect(spy).not.toHaveBeenCalled(); - wrapper.instance().onFieldChange('foo', { target: { name: 'name' } }); - wrapper.instance().onFieldChange('bar', { target: { name: 'description' } }); - expect(spy).toHaveBeenCalledTimes(2); - }); - test('AnsibleSelect component renders if there are virtual environments', () => { - const api = jest.fn(); - const config = { - custom_virtualenvs: ['foo', 'bar'], + beforeEach(() => { + api = { + getInstanceGroups: jest.fn(), + updateOrganizationDetails: jest.fn(), + associateInstanceGroup: jest.fn(), + disassociate: jest.fn(), }; - const wrapper = mount( - - - - - - - - ); - expect(wrapper.find('FormSelect')).toHaveLength(1); - expect(wrapper.find('FormSelectOption')).toHaveLength(2); }); - test('calls onSubmit when Save button is clicked', () => { - const api = jest.fn(); - const spy = jest.spyOn(OrganizationEdit.WrappedComponent.prototype, 'onSubmit'); + + test('handleSubmit should call api update', () => { const wrapper = mount( ); - expect(spy).not.toHaveBeenCalled(); - wrapper.find('button[aria-label="Save"]').prop('onClick')(); - expect(spy).toBeCalled(); - }); - test('onSubmit associates and disassociates instance groups', async () => { - const mockInstanceGroups = [ - { name: 'One', id: 1 }, - { name: 'Two', id: 2 } - ]; - const getOrganizationInstanceGroupsFn = jest.fn(() => ( - Promise.resolve({ data: { results: mockInstanceGroups } }) - )); - const mockDataForm = { - name: 'Foo', - description: 'Bar', - custom_virtualenv: 'Fizz', + + const updatedOrgData = { + name: 'new name', + description: 'new description', + custom_virtualenv: 'Buzz', }; - const updateOrganizationDetailsFn = jest.fn().mockResolvedValue(1, mockDataForm); - const associateInstanceGroupFn = jest.fn().mockResolvedValue('done'); - const disassociateInstanceGroupFn = jest.fn().mockResolvedValue('done'); - const api = { - getOrganizationInstanceGroups: getOrganizationInstanceGroupsFn, - updateOrganizationDetails: updateOrganizationDetailsFn, - associateInstanceGroup: associateInstanceGroupFn, - disassociate: disassociateInstanceGroupFn - }; - const wrapper = mount( - - - - - - ).find('OrganizationEdit'); + wrapper.find('OrganizationForm').prop('handleSubmit')(updatedOrgData, [], []); - await wrapper.instance().componentDidMount(); - - wrapper.instance().onLookupSave([ - { name: 'One', id: 1 }, - { name: 'Three', id: 3 } - ], 'instanceGroups'); - - await wrapper.instance().onSubmit(); - expect(updateOrganizationDetailsFn).toHaveBeenCalledWith(1, mockDataForm); - expect(associateInstanceGroupFn).toHaveBeenCalledWith('/api/v2/organizations/1/instance_groups', 3); - expect(associateInstanceGroupFn).not.toHaveBeenCalledWith('/api/v2/organizations/1/instance_groups', 1); - expect(associateInstanceGroupFn).not.toHaveBeenCalledWith('/api/v2/organizations/1/instance_groups', 2); - - expect(disassociateInstanceGroupFn).toHaveBeenCalledWith('/api/v2/organizations/1/instance_groups', 2); - expect(disassociateInstanceGroupFn).not.toHaveBeenCalledWith('/api/v2/organizations/1/instance_groups', 1); - expect(disassociateInstanceGroupFn).not.toHaveBeenCalledWith('/api/v2/organizations/1/instance_groups', 3); + expect(api.updateOrganizationDetails).toHaveBeenCalledWith( + 1, + updatedOrgData + ); }); - test('calls "onCancel" when Cancel button is clicked', () => { - const api = jest.fn(); - const spy = jest.spyOn(OrganizationEdit.WrappedComponent.prototype, 'onCancel'); + test('handleSubmit associates and disassociates instance groups', async () => { const wrapper = mount( ); - expect(spy).not.toHaveBeenCalled(); + + const updatedOrgData = { + name: 'new name', + description: 'new description', + custom_virtualenv: 'Buzz', + }; + wrapper.find('OrganizationForm').prop('handleSubmit')(updatedOrgData, [3, 4], [2]); + await sleep(1); + + expect(api.associateInstanceGroup).toHaveBeenCalledWith( + '/api/v2/organizations/1/instance_groups', + 3 + ); + expect(api.associateInstanceGroup).toHaveBeenCalledWith( + '/api/v2/organizations/1/instance_groups', + 4 + ); + expect(api.disassociate).toHaveBeenCalledWith( + '/api/v2/organizations/1/instance_groups', + 2 + ); + }); + + test('should navigate to organization detail when cancel is clicked', () => { + const history = { + push: jest.fn(), + }; + const wrapper = mount( + + + <_OrganizationEdit + history={history} + organization={mockData} + api={api} + /> + + + ); + + expect(history.push).not.toHaveBeenCalled(); wrapper.find('button[aria-label="Cancel"]').prop('onClick')(); - expect(spy).toBeCalled(); + + expect(history.push).toHaveBeenCalledWith('/organizations/1'); }); }); diff --git a/__tests__/pages/Organizations/screens/OrganizationAdd.test.jsx b/__tests__/pages/Organizations/screens/OrganizationAdd.test.jsx index 6a1c919ce3..607ae9acd9 100644 --- a/__tests__/pages/Organizations/screens/OrganizationAdd.test.jsx +++ b/__tests__/pages/Organizations/screens/OrganizationAdd.test.jsx @@ -1,184 +1,152 @@ import React from 'react'; import { mount } from 'enzyme'; -import { MemoryRouter, Router } from 'react-router-dom'; +import { MemoryRouter } from 'react-router-dom'; import { I18nProvider } from '@lingui/react'; import { ConfigContext } from '../../../../src/context'; -import OrganizationAdd from '../../../../src/pages/Organizations/screens/OrganizationAdd'; +import OrganizationAdd, { _OrganizationAdd } from '../../../../src/pages/Organizations/screens/OrganizationAdd'; + +const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)); describe('', () => { - test('initially renders succesfully', () => { - mount( - - - - - - ); + let api; + + beforeEach(() => { + api = { + getInstanceGroups: jest.fn(), + createOrganization: jest.fn(), + associateInstanceGroup: jest.fn(), + disassociate: jest.fn(), + }; }); - test('calls "onFieldChange" when input values change', () => { - const spy = jest.spyOn(OrganizationAdd.WrappedComponent.prototype, 'onFieldChange'); + test('handleSubmit should post to api', () => { const wrapper = mount( ); - expect(spy).not.toHaveBeenCalled(); - wrapper.find('input#add-org-form-name').simulate('change', { target: { value: 'foo' } }); - wrapper.find('input#add-org-form-description').simulate('change', { target: { value: 'bar' } }); - expect(spy).toHaveBeenCalledTimes(2); + + const updatedOrgData = { + name: 'new name', + description: 'new description', + custom_virtualenv: 'Buzz', + }; + wrapper.find('OrganizationForm').prop('handleSubmit')(updatedOrgData, [], []); + + expect(api.createOrganization).toHaveBeenCalledWith(updatedOrgData); }); - test('calls "onSubmit" when Save button is clicked', () => { - const spy = jest.spyOn(OrganizationAdd.WrappedComponent.prototype, 'onSubmit'); - const wrapper = mount( - - - - - - ); - expect(spy).not.toHaveBeenCalled(); - wrapper.find('button[aria-label="Save"]').prop('onClick')(); - expect(spy).toBeCalled(); - }); - - test('calls "onCancel" when Cancel button is clicked', () => { - const spy = jest.spyOn(OrganizationAdd.WrappedComponent.prototype, 'onCancel'); - const wrapper = mount( - - - - - - ); - expect(spy).not.toHaveBeenCalled(); - wrapper.find('button[aria-label="Cancel"]').prop('onClick')(); - expect(spy).toBeCalled(); - }); - - test('calls "onCancel" when close button (x) is clicked', () => { - const wrapper = mount( - - - - - - ); - const history = wrapper.find(Router).prop('history'); - expect(history.length).toBe(1); - expect(history.location.pathname).toEqual('/organizations/add'); - wrapper.find('button[aria-label="Close"]').prop('onClick')(); - expect(history.length).toBe(2); - expect(history.location.pathname).toEqual('/organizations'); - }); - - test('Successful form submission triggers redirect', (done) => { - const onSuccess = jest.spyOn(OrganizationAdd.WrappedComponent.prototype, 'onSuccess'); - const mockedResp = { data: { id: 1, related: { instance_groups: '/bar' } } }; - const api = { createOrganization: jest.fn().mockResolvedValue(mockedResp), associateInstanceGroup: jest.fn().mockResolvedValue('done') }; - const wrapper = mount( - - - - - - ); - wrapper.find('input#add-org-form-name').simulate('change', { target: { value: 'foo' } }); - wrapper.find('button[aria-label="Save"]').prop('onClick')(); - setImmediate(() => { - expect(onSuccess).toHaveBeenCalled(); - done(); - }); - }); - - test('onLookupSave successfully sets instanceGroups state', () => { - const wrapper = mount( - - - - - - ).find('OrganizationAdd'); - wrapper.instance().onLookupSave([ - { - id: 1, - name: 'foo' - } - ], 'instanceGroups'); - expect(wrapper.state('instanceGroups')).toEqual([ - { - id: 1, - name: 'foo' - } - ]); - }); - - test('onFieldChange successfully sets custom_virtualenv state', () => { - const wrapper = mount( - - - - - - ).find('OrganizationAdd'); - wrapper.instance().onFieldChange('fooBar', { target: { name: 'custom_virtualenv' } }); - expect(wrapper.state('custom_virtualenv')).toBe('fooBar'); - }); - - test('onSubmit posts instance groups from selectedInstanceGroups', async () => { - const createOrganizationFn = jest.fn().mockResolvedValue({ - data: { - id: 1, - name: 'mock org', - related: { - instance_groups: '/api/v2/organizations/1/instance_groups' - } - } - }); - const associateInstanceGroupFn = jest.fn().mockResolvedValue('done'); - const api = { - createOrganization: createOrganizationFn, - associateInstanceGroup: associateInstanceGroupFn + test('should navigate to organizations list when cancel is clicked', () => { + const history = { + push: jest.fn(), }; const wrapper = mount( - + <_OrganizationAdd + history={history} + api={api} + /> - ).find('OrganizationAdd'); - wrapper.setState({ - name: 'mock org', - instanceGroups: [{ - id: 1, - name: 'foo' - }] + ); + + expect(history.push).not.toHaveBeenCalled(); + wrapper.find('button[aria-label="Cancel"]').prop('onClick')(); + + expect(history.push).toHaveBeenCalledWith('/organizations'); + }); + + test('should navigate to organizations list when close (x) is clicked', () => { + const history = { + push: jest.fn(), + }; + const wrapper = mount( + + + <_OrganizationAdd + history={history} + api={api} + /> + + + ); + + expect(history.push).not.toHaveBeenCalled(); + wrapper.find('button[aria-label="Close"]').prop('onClick')(); + + expect(history.push).toHaveBeenCalledWith('/organizations'); + }); + + test('successful form submission should trigger redirect', async () => { + const history = { + push: jest.fn(), + }; + const orgData = { + name: 'new name', + description: 'new description', + custom_virtualenv: 'Buzz', + }; + api.createOrganization.mockReturnValueOnce({ + data: { + id: 5, + related: { + instance_groups: '/bar', + }, + ...orgData, + } }); - await wrapper.instance().onSubmit(); - expect(createOrganizationFn).toHaveBeenCalledWith({ - custom_virtualenv: '', - description: '', - name: 'mock org' + const wrapper = mount( + + + <_OrganizationAdd + history={history} + api={api} + /> + + + ); + + wrapper.find('OrganizationForm').prop('handleSubmit')(orgData, [], []); + await sleep(0); + + expect(history.push).toHaveBeenCalledWith('/organizations/5'); + }); + + test('handleSubmit should post instance groups', async () => { + const wrapper = mount( + + + + + + ); + + const orgData = { + name: 'new name', + description: 'new description', + custom_virtualenv: 'Buzz', + }; + api.createOrganization.mockReturnValueOnce({ + data: { + id: 5, + related: { + instance_groups: '/api/v2/organizations/5/instance_groups', + }, + ...orgData, + } }); - expect(associateInstanceGroupFn).toHaveBeenCalledWith('/api/v2/organizations/1/instance_groups', 1); + wrapper.find('OrganizationForm').prop('handleSubmit')(orgData, [3], []); + await sleep(0); + + expect(api.associateInstanceGroup) + .toHaveBeenCalledWith('/api/v2/organizations/5/instance_groups', 3); }); test('AnsibleSelect component renders if there are virtual environments', () => { @@ -189,7 +157,7 @@ describe('', () => { - + @@ -206,7 +174,7 @@ describe('', () => { - + diff --git a/__tests__/util/validators.test.js b/__tests__/util/validators.test.js new file mode 100644 index 0000000000..d197c61a21 --- /dev/null +++ b/__tests__/util/validators.test.js @@ -0,0 +1,34 @@ +import { required, maxLength } from '../../src/util/validators'; + +describe('validators', () => { + test('required returns undefined if value given', () => { + expect(required()('some value')).toBeUndefined(); + expect(required('oops')('some value')).toBeUndefined(); + }); + + test('required returns default message if value missing', () => { + expect(required()('')).toEqual('This field must not be blank'); + }); + + test('required returns custom message if value missing', () => { + expect(required('oops')('')).toEqual('oops'); + }); + + test('required interprets white space as empty value', () => { + expect(required()(' ')).toEqual('This field must not be blank'); + expect(required()('\t')).toEqual('This field must not be blank'); + }); + + test('maxLength accepts value below max', () => { + expect(maxLength(10)('snazzy')).toBeUndefined(); + }); + + test('maxLength accepts value equal to max', () => { + expect(maxLength(10)('abracadbra')).toBeUndefined(); + }); + + test('maxLength rejects value above max', () => { + expect(maxLength(8)('abracadbra')) + .toEqual('This field must not exceed 8 characters'); + }); +}); diff --git a/package-lock.json b/package-lock.json index 959fdd4ae0..9f91d8a84e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1656,7 +1656,7 @@ }, "ansi-colors": { "version": "1.1.0", - "resolved": "http://registry.npmjs.org/ansi-colors/-/ansi-colors-1.1.0.tgz", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-1.1.0.tgz", "integrity": "sha512-SFKX67auSNoVR38N3L+nvsPjOE0bybKTYbkf5tRvushrAPQ9V75huw0ZxBkKVeRU9kqH3d6HA4xTckbwZ4ixmA==", "requires": { "ansi-wrap": "^0.1.0" @@ -2184,6 +2184,11 @@ "integrity": "sha1-iYUI2iIm84DfkEcoRWhJwVAaSw0=", "dev": true }, + "asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY=" + }, "asn1": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.4.tgz", @@ -2566,12 +2571,12 @@ }, "babel-plugin-syntax-class-properties": { "version": "6.13.0", - "resolved": "http://registry.npmjs.org/babel-plugin-syntax-class-properties/-/babel-plugin-syntax-class-properties-6.13.0.tgz", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-class-properties/-/babel-plugin-syntax-class-properties-6.13.0.tgz", "integrity": "sha1-1+sjt5oxf4VDlixQW4J8fWysJ94=" }, "babel-plugin-syntax-flow": { "version": "6.18.0", - "resolved": "http://registry.npmjs.org/babel-plugin-syntax-flow/-/babel-plugin-syntax-flow-6.18.0.tgz", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-flow/-/babel-plugin-syntax-flow-6.18.0.tgz", "integrity": "sha1-TDqyCiryaqIM0lmVw5jE63AxDI0=" }, "babel-plugin-syntax-jsx": { @@ -4139,6 +4144,15 @@ "sha.js": "^2.4.8" } }, + "create-react-context": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/create-react-context/-/create-react-context-0.2.3.tgz", + "integrity": "sha512-CQBmD0+QGgTaxDL3OX1IDXYqjkp2It4RIbcb99jS6AEg27Ga+a9G3JtK6SIu0HBwPLZlmwt9F7UwWA4Bn92Rag==", + "requires": { + "fbjs": "^0.8.0", + "gud": "^1.0.0" + } + }, "cross-spawn": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-5.1.0.tgz", @@ -4379,6 +4393,11 @@ "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz", "integrity": "sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=" }, + "deepmerge": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-2.2.1.tgz", + "integrity": "sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA==" + }, "default-gateway": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/default-gateway/-/default-gateway-2.7.2.tgz", @@ -4710,7 +4729,7 @@ }, "readable-stream": { "version": "2.3.6", - "resolved": "http://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", "requires": { "core-util-is": "~1.0.0", @@ -4851,7 +4870,6 @@ "version": "0.1.12", "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.12.tgz", "integrity": "sha1-U4tm8+5izRq1HsMjgp0flIDHS+s=", - "dev": true, "requires": { "iconv-lite": "~0.4.13" } @@ -5685,6 +5703,27 @@ "bser": "^2.0.0" } }, + "fbjs": { + "version": "0.8.17", + "resolved": "https://registry.npmjs.org/fbjs/-/fbjs-0.8.17.tgz", + "integrity": "sha1-xNWY6taUkRJlPWWIsBpc3Nn5D90=", + "requires": { + "core-js": "^1.0.0", + "isomorphic-fetch": "^2.1.1", + "loose-envify": "^1.0.0", + "object-assign": "^4.1.0", + "promise": "^7.1.1", + "setimmediate": "^1.0.5", + "ua-parser-js": "^0.7.18" + }, + "dependencies": { + "core-js": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-1.2.7.tgz", + "integrity": "sha1-ZSKUwUZR2yj6k70tX/KYOk8IxjY=" + } + } + }, "fbjs-scripts": { "version": "0.8.3", "resolved": "https://registry.npmjs.org/fbjs-scripts/-/fbjs-scripts-0.8.3.tgz", @@ -5735,7 +5774,7 @@ }, "readable-stream": { "version": "2.3.6", - "resolved": "http://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", "requires": { "core-util-is": "~1.0.0", @@ -6000,6 +6039,22 @@ "mime-types": "^2.1.12" } }, + "formik": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/formik/-/formik-1.5.1.tgz", + "integrity": "sha512-FBWGBKQkcCE4d5b5l2fKccD9d1QxNxw/0bQTRvp3EjzA8Bnjmsm9H/Oy0375UA8P3FPmfJkF4cXLLdEqK7fP5A==", + "requires": { + "create-react-context": "^0.2.2", + "deepmerge": "^2.1.1", + "hoist-non-react-statics": "^2.5.5", + "lodash": "^4.17.11", + "lodash-es": "^4.17.11", + "prop-types": "^15.6.1", + "react-fast-compare": "^2.0.1", + "tiny-warning": "^1.0.2", + "tslib": "^1.9.3" + } + }, "forwarded": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.1.2.tgz", @@ -6830,6 +6885,11 @@ "integrity": "sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE=", "dev": true }, + "gud": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/gud/-/gud-1.0.0.tgz", + "integrity": "sha512-zGEOVKFM5sVPPrYs7J5/hYEw2Pof8KCyOwyhG8sAF26mCAeUFAcYPu1mwB7hhpIP29zOIBaDqwuHdLp0jvZXjw==" + }, "handle-thing": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.0.tgz", @@ -8030,8 +8090,7 @@ "is-stream": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", - "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ=", - "dev": true + "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ=" }, "is-string": { "version": "1.0.4", @@ -8102,6 +8161,15 @@ } } }, + "isomorphic-fetch": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-2.2.1.tgz", + "integrity": "sha1-YRrhrPFPXoH3KVB0coGf6XM1WKk=", + "requires": { + "node-fetch": "^1.0.1", + "whatwg-fetch": ">=0.10.0" + } + }, "isstream": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", @@ -9263,7 +9331,7 @@ }, "kind-of": { "version": "1.1.0", - "resolved": "http://registry.npmjs.org/kind-of/-/kind-of-1.1.0.tgz", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-1.1.0.tgz", "integrity": "sha1-FAo9LUGjbS78+pN3tiwk+ElaXEQ=" }, "kleur": { @@ -9357,6 +9425,11 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.11.tgz", "integrity": "sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg==" }, + "lodash-es": { + "version": "4.17.11", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.11.tgz", + "integrity": "sha512-DHb1ub+rMjjrxqlB3H56/6MXtm1lSksDp2rA2cNWjG8mlDUYFhUj3Di2Zn5IwSU87xLv8tNIQ7sSwE/YOX/D/Q==" + }, "lodash.assign": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/lodash.assign/-/lodash.assign-4.2.0.tgz", @@ -10116,7 +10189,6 @@ "version": "1.6.3", "resolved": "http://registry.npmjs.org/node-fetch/-/node-fetch-1.6.3.tgz", "integrity": "sha1-3CNO3WSJmC1Y6PDbT2lQKavNjAQ=", - "dev": true, "requires": { "encoding": "^0.1.11", "is-stream": "^1.0.1" @@ -11304,6 +11376,14 @@ "integrity": "sha512-OE+a6vzqazc+K6LxJrX5UPyKFvGnL5CYmq2jFGNIBWHpc4QyE49/YOumcrpQFJpfejmvRtbJzgO1zPmMCqlbBg==", "dev": true }, + "promise": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz", + "integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==", + "requires": { + "asap": "~2.0.3" + } + }, "promise-inflight": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", @@ -11553,6 +11633,11 @@ "scheduler": "^0.10.0" } }, + "react-fast-compare": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz", + "integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==" + }, "react-hot-loader": { "version": "4.3.11", "resolved": "https://registry.npmjs.org/react-hot-loader/-/react-hot-loader-4.3.11.tgz", @@ -13013,8 +13098,7 @@ "setimmediate": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", - "integrity": "sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU=", - "dev": true + "integrity": "sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU=" }, "setprototypeof": { "version": "1.1.0", @@ -13938,6 +14022,11 @@ "setimmediate": "^1.0.4" } }, + "tiny-warning": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.2.tgz", + "integrity": "sha512-rru86D9CpQRLvsFG5XFdy0KdLAvjdQDyZCsRcuu60WtzFylDM3eAWSxEVz5kzL2Gp544XiUvPbVKtOA/txLi9Q==" + }, "tippy.js": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-3.4.1.tgz", @@ -14110,8 +14199,7 @@ "tslib": { "version": "1.9.3", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.9.3.tgz", - "integrity": "sha512-4krF8scpejhaOgqzBEcGM7yDIEfi0/8+8zDRZhNZZ2kjmHJ4hv3zCbQWxoJGz1iw5U0Jl0nma13xzHXcncMavQ==", - "dev": true + "integrity": "sha512-4krF8scpejhaOgqzBEcGM7yDIEfi0/8+8zDRZhNZZ2kjmHJ4hv3zCbQWxoJGz1iw5U0Jl0nma13xzHXcncMavQ==" }, "tty-browserify": { "version": "0.0.0", @@ -14162,6 +14250,11 @@ "integrity": "sha512-Gr4p6nFNaoufRIY4NMdpQRNmgxVIGMs4Fcu/ujdYk3nAZqk7supzBE9idmvfZIlH/Cuj//dvi+019qEue9lV0w==", "dev": true }, + "ua-parser-js": { + "version": "0.7.19", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.19.tgz", + "integrity": "sha512-T3PVJ6uz8i0HzPxOF9SWzWAlfN/DavlpQqepn22xgve/5QecC+XMCAtmUNnY7C9StehaV6exjUCI801lOI7QlQ==" + }, "uglify-js": { "version": "3.4.9", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.4.9.tgz", @@ -15561,6 +15654,11 @@ "iconv-lite": "0.4.24" } }, + "whatwg-fetch": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.0.0.tgz", + "integrity": "sha512-9GSJUgz1D4MfyKU7KRqwOjXCXTqWdFNvEr7eUBYchQiVc744mqK/MzXPNR2WsPkmkOa4ywfg8C2n8h+13Bey1Q==" + }, "whatwg-mimetype": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-2.2.0.tgz", diff --git a/package.json b/package.json index e08cf65cd4..5e6e4398c1 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "@patternfly/react-icons": "^3.6.1", "@patternfly/react-tokens": "^2.2.1", "axios": "^0.18.0", + "formik": "^1.5.1", "prop-types": "^15.6.2", "react": "^16.4.1", "react-dom": "^16.4.1", diff --git a/src/components/AnsibleSelect/AnsibleSelect.jsx b/src/components/AnsibleSelect/AnsibleSelect.jsx index 90e1fddccc..c4de1c51e7 100644 --- a/src/components/AnsibleSelect/AnsibleSelect.jsx +++ b/src/components/AnsibleSelect/AnsibleSelect.jsx @@ -16,7 +16,7 @@ class AnsibleSelect extends React.Component { onSelectChange (val, event) { const { onChange, name } = this.props; event.target.name = name; - onChange(val, event); + onChange(event, val); } render () { @@ -24,10 +24,22 @@ class AnsibleSelect extends React.Component { return ( {({ i18n }) => ( - - {data.map((datum) => (datum === defaultSelected - ? () : ())) - } + + {data.map((datum) => ( + datum === defaultSelected ? ( + + ) : ( + + ) + ))} )} diff --git a/src/components/FormActionGroup.jsx b/src/components/FormActionGroup.jsx index 451987e847..675633163e 100644 --- a/src/components/FormActionGroup.jsx +++ b/src/components/FormActionGroup.jsx @@ -28,10 +28,10 @@ const FormActionGroup = ({ onSubmit, submitDisabled, onCancel }) => ( - {i18n._(t`Save`)} + {i18n._(t`Save`)} - {i18n._(t`Cancel`)} + {i18n._(t`Cancel`)} diff --git a/src/components/FormField.jsx b/src/components/FormField.jsx new file mode 100644 index 0000000000..177f8eeaa1 --- /dev/null +++ b/src/components/FormField.jsx @@ -0,0 +1,56 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Field } from 'formik'; +import { FormGroup, TextInput } from '@patternfly/react-core'; + +function FormField (props) { + const { id, name, label, validate, isRequired, ...rest } = props; + + return ( + { + const isValid = !form.touched[field.name] || !form.errors[field.name]; + + return ( + + { + field.onChange(event); + }} + /> + + ); + }} + /> + ); +} + +FormField.propTypes = { + id: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + type: PropTypes.string, + validate: PropTypes.func, + isRequired: PropTypes.bool, +}; + +FormField.defaultProps = { + type: 'text', + validate: () => {}, + isRequired: false, +}; + +export default FormField; diff --git a/src/components/FormRow.jsx b/src/components/FormRow.jsx new file mode 100644 index 0000000000..d7e6c0cd11 --- /dev/null +++ b/src/components/FormRow.jsx @@ -0,0 +1,15 @@ +import React from 'react'; + +export default function FormRow ({ children }) { + return ( + + {children} + + ); +} diff --git a/src/components/Lookup/Lookup.jsx b/src/components/Lookup/Lookup.jsx index 35cff54921..c5a287a2b5 100644 --- a/src/components/Lookup/Lookup.jsx +++ b/src/components/Lookup/Lookup.jsx @@ -157,7 +157,7 @@ class Lookup extends React.Component { sortedColumnKey, sortOrder } = this.state; - const { lookupHeader = 'items', value, columns } = this.props; + const { id, lookupHeader = 'items', value, columns } = this.props; const chips = value ? ( @@ -173,7 +173,12 @@ class Lookup extends React.Component { {({ i18n }) => ( - + {chips} @@ -248,6 +253,7 @@ class Lookup extends React.Component { } Lookup.propTypes = { + id: PropTypes.string, getItems: PropTypes.func.isRequired, lookupHeader: PropTypes.string, name: PropTypes.string, @@ -256,6 +262,7 @@ Lookup.propTypes = { }; Lookup.defaultProps = { + id: 'lookup-search', lookupHeader: 'items', name: null, }; diff --git a/src/pages/Organizations/components/InstanceGroupsLookup.jsx b/src/pages/Organizations/components/InstanceGroupsLookup.jsx new file mode 100644 index 0000000000..ac48d25972 --- /dev/null +++ b/src/pages/Organizations/components/InstanceGroupsLookup.jsx @@ -0,0 +1,84 @@ +import React, { Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { I18n, i18nMark } from '@lingui/react'; +import { FormGroup, Tooltip } from '@patternfly/react-core'; +import { QuestionCircleIcon } from '@patternfly/react-icons'; +import { t } from '@lingui/macro'; + +import Lookup from '../../../components/Lookup'; + +const INSTANCE_GROUPS_LOOKUP_COLUMNS = [ + { name: i18nMark('Name'), key: 'name', isSortable: true }, + { name: i18nMark('Modified'), key: 'modified', isSortable: false, isNumeric: true }, + { name: i18nMark('Created'), key: 'created', isSortable: false, isNumeric: true } +]; + +class InstanceGroupsLookup extends React.Component { + constructor (props) { + super(props); + + this.getInstanceGroups = this.getInstanceGroups.bind(this); + } + + async getInstanceGroups (params) { + const { api } = this.props; + const data = await api.getInstanceGroups(params); + return data; + } + + render () { + const { value, tooltip, onChange } = this.props; + + return ( + + {({ i18n }) => ( + + {i18n._(t`Instance Groups`)} + {' '} + { + tooltip && ( + + + + ) + } + + )} + fieldId="org-instance-groups" + > + + + )} + + ); + } +} + +InstanceGroupsLookup.propTypes = { + api: PropTypes.shape({ + getInstanceGroups: PropTypes.func, + }).isRequired, + value: PropTypes.arrayOf(PropTypes.object).isRequired, + tooltip: PropTypes.string, + onChange: PropTypes.func.isRequired, +}; + +InstanceGroupsLookup.defaultProps = { + tooltip: '', +}; + +export default InstanceGroupsLookup; diff --git a/src/pages/Organizations/components/OrganizationForm.jsx b/src/pages/Organizations/components/OrganizationForm.jsx new file mode 100644 index 0000000000..4000bf1a1c --- /dev/null +++ b/src/pages/Organizations/components/OrganizationForm.jsx @@ -0,0 +1,178 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { withRouter } from 'react-router-dom'; +import { Formik, Field } from 'formik'; +import { I18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { + Form, + FormGroup, +} from '@patternfly/react-core'; + +import { ConfigContext } from '../../../context'; +import FormRow from '../../../components/FormRow'; +import FormField from '../../../components/FormField'; +import FormActionGroup from '../../../components/FormActionGroup'; +import AnsibleSelect from '../../../components/AnsibleSelect'; +import InstanceGroupsLookup from './InstanceGroupsLookup'; +import { required } from '../../../util/validators'; + +class OrganizationForm extends Component { + constructor (props) { + super(props); + + this.getRelatedInstanceGroups = this.getRelatedInstanceGroups.bind(this); + this.handleInstanceGroupsChange = this.handleInstanceGroupsChange.bind(this); + this.handleSubmit = this.handleSubmit.bind(this); + + this.state = { + instanceGroups: [], + formIsValid: true, + }; + } + + async componentDidMount () { + let instanceGroups = []; + + if (!this.isEditingNewOrganization()) { + try { + instanceGroups = await this.getRelatedInstanceGroups(); + } catch (err) { + this.setState({ error: err }); + } + } + + this.setState({ + instanceGroups, + initialInstanceGroups: [...instanceGroups], + }); + } + + async getRelatedInstanceGroups () { + const { + api, + organization: { id } + } = this.props; + const { data } = await api.getOrganizationInstanceGroups(id); + return data.results; + } + + isEditingNewOrganization () { + const { organization } = this.props; + return !organization.id; + } + + handleInstanceGroupsChange (instanceGroups) { + this.setState({ instanceGroups }); + } + + handleSubmit (values) { + const { handleSubmit } = this.props; + const { instanceGroups, initialInstanceGroups } = this.state; + + const initialIds = initialInstanceGroups.map(ig => ig.id); + const updatedIds = instanceGroups.map(ig => ig.id); + const groupsToAssociate = [...updatedIds] + .filter(x => !initialIds.includes(x)); + const groupsToDisassociate = [...initialIds] + .filter(x => !updatedIds.includes(x)); + + handleSubmit(values, groupsToAssociate, groupsToDisassociate); + } + + render () { + const { api, organization, handleCancel } = this.props; + const { instanceGroups, formIsValid, error } = this.state; + const defaultVenv = '/venv/ansible/'; + + return ( + + {({ i18n }) => ( + ( + + + + + + {({ custom_virtualenvs }) => ( + custom_virtualenvs && custom_virtualenvs.length > 1 && ( + ( + + + + )} + /> + ) + )} + + + + + {error ? error : null} + + )} + /> + )} + + ); + } +} + +OrganizationForm.propTypes = { + api: PropTypes.shape().isRequired, + organization: PropTypes.shape(), + handleSubmit: PropTypes.func.isRequired, + handleCancel: PropTypes.func.isRequired, +}; + +OrganizationForm.defaultProps = { + organization: { + name: '', + description: '', + custom_virtualenv: '', + } +}; + +OrganizationForm.contextTypes = { + custom_virtualenvs: PropTypes.arrayOf(PropTypes.string) +}; + +export default withRouter(OrganizationForm); diff --git a/src/pages/Organizations/screens/Organization/OrganizationEdit.jsx b/src/pages/Organizations/screens/Organization/OrganizationEdit.jsx index 0560b588c5..74b30d3f5e 100644 --- a/src/pages/Organizations/screens/Organization/OrganizationEdit.jsx +++ b/src/pages/Organizations/screens/Organization/OrganizationEdit.jsx @@ -1,171 +1,50 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import { withRouter } from 'react-router-dom'; -import { I18n, i18nMark } from '@lingui/react'; -import { t } from '@lingui/macro'; -import { - CardBody, - Form, - FormGroup, - TextInput, -} from '@patternfly/react-core'; +import { CardBody } from '@patternfly/react-core'; -import { ConfigContext } from '../../../../context'; -import Lookup from '../../../../components/Lookup'; -import FormActionGroup from '../../../../components/FormActionGroup'; -import AnsibleSelect from '../../../../components/AnsibleSelect'; +import OrganizationForm from '../../components/OrganizationForm'; class OrganizationEdit extends Component { constructor (props) { super(props); - this.getInstanceGroups = this.getInstanceGroups.bind(this); - this.getRelatedInstanceGroups = this.getRelatedInstanceGroups.bind(this); - this.checkValidity = this.checkValidity.bind(this); - this.onFieldChange = this.onFieldChange.bind(this); - this.onLookupSave = this.onLookupSave.bind(this); - this.onSubmit = this.onSubmit.bind(this); - this.postInstanceGroups = this.postInstanceGroups.bind(this); - this.onCancel = this.onCancel.bind(this); - this.onSuccess = this.onSuccess.bind(this); + this.handleSubmit = this.handleSubmit.bind(this); + this.submitInstanceGroups = this.submitInstanceGroups.bind(this); + this.handleCancel = this.handleCancel.bind(this); + this.handleSuccess = this.handleSuccess.bind(this); this.state = { - form: { - name: { - value: '', - isValid: true, - validation: { - required: true - }, - helperTextInvalid: i18nMark('This field must not be blank') - }, - description: { - value: '' - }, - instanceGroups: { - value: [], - initialValue: [] - }, - custom_virtualenv: { - value: '', - defaultValue: '/venv/ansible/' - } - }, error: '', - formIsValid: true }; } - async componentDidMount () { - const { organization } = this.props; - const { form: formData } = this.state; - - formData.name.value = organization.name; - formData.description.value = organization.description; - formData.custom_virtualenv.value = organization.custom_virtualenv; - - try { - formData.instanceGroups.value = await this.getRelatedInstanceGroups(); - formData.instanceGroups.initialValue = [...formData.instanceGroups.value]; - } catch (err) { - this.setState({ error: err }); - } - - this.setState({ form: formData }); - } - - onFieldChange (val, evt) { - const targetName = evt.target.name; - const value = val; - - const { form: updatedForm } = this.state; - const updatedFormEl = { ...updatedForm[targetName] }; - - updatedFormEl.value = value; - updatedForm[targetName] = updatedFormEl; - - updatedFormEl.isValid = (updatedFormEl.validation) - ? this.checkValidity(updatedFormEl.value, updatedFormEl.validation) : true; - - const formIsValid = (updatedFormEl.validation) ? updatedFormEl.isValid : true; - - this.setState({ form: updatedForm, formIsValid }); - } - - onLookupSave (val, targetName) { - const { form: updatedForm } = this.state; - updatedForm[targetName].value = val; - - this.setState({ form: updatedForm }); - } - - async onSubmit () { + async handleSubmit (values, groupsToAssociate, groupsToDisassociate) { const { api, organization } = this.props; - const { form: { name, description, custom_virtualenv } } = this.state; - const formData = { name, description, custom_virtualenv }; - - const updatedData = {}; - Object.keys(formData) - .forEach(formId => { - updatedData[formId] = formData[formId].value; - }); - try { - await api.updateOrganizationDetails(organization.id, updatedData); - await this.postInstanceGroups(); + await api.updateOrganizationDetails(organization.id, values); + await this.submitInstanceGroups(groupsToAssociate, groupsToDisassociate); } catch (err) { this.setState({ error: err }); } finally { - this.onSuccess(); + this.handleSuccess(); } } - onCancel () { + handleCancel () { const { organization: { id }, history } = this.props; history.push(`/organizations/${id}`); } - onSuccess () { + handleSuccess () { const { organization: { id }, history } = this.props; history.push(`/organizations/${id}`); } - async getInstanceGroups (params) { - const { api } = this.props; - const data = await api.getInstanceGroups(params); - return data; - } - - async getRelatedInstanceGroups () { - const { - api, - organization: { id } - } = this.props; - const { data } = await api.getOrganizationInstanceGroups(id); - const { results } = data; - return results; - } - - checkValidity = (value, validation) => { - const isValid = (validation.required) - ? (value.trim() !== '') : true; - - return isValid; - } - - async postInstanceGroups () { + async submitInstanceGroups (groupsToAssociate, groupsToDisassociate) { const { api, organization } = this.props; - const { form: { instanceGroups } } = this.state; const url = organization.related.instance_groups; - const initialInstanceGroups = instanceGroups.initialValue.map(ig => ig.id); - const updatedInstanceGroups = instanceGroups.value.map(ig => ig.id); - - const groupsToAssociate = [...updatedInstanceGroups] - .filter(x => !initialInstanceGroups.includes(x)); - const groupsToDisassociate = [...initialInstanceGroups] - .filter(x => !updatedInstanceGroups.includes(x)); - try { await Promise.all(groupsToAssociate.map(async id => { await api.associateInstanceGroup(url, id); @@ -179,106 +58,31 @@ class OrganizationEdit extends Component { } render () { - const { - form: { - name, - description, - instanceGroups, - custom_virtualenv - }, - formIsValid, - error - } = this.state; - - const instanceGroupsLookupColumns = [ - { name: i18nMark('Name'), key: 'name', isSortable: true }, - { name: i18nMark('Modified'), key: 'modified', isSortable: false, isNumeric: true }, - { name: i18nMark('Created'), key: 'created', isSortable: false, isNumeric: true } - ]; + const { api, organization } = this.props; + const { error } = this.state; return ( - - {({ i18n }) => ( - - - - - - - - - - {({ custom_virtualenvs }) => ( - custom_virtualenvs && custom_virtualenvs.length > 1 && ( - - - - ) - )} - - - - - - - { error ? error : '' } - - )} - + + {error ? error : null} ); } } +OrganizationEdit.propTypes = { + api: PropTypes.shape().isRequired, + organization: PropTypes.shape().isRequired, +}; + OrganizationEdit.contextTypes = { custom_virtualenvs: PropTypes.arrayOf(PropTypes.string) }; +export { OrganizationEdit as _OrganizationEdit }; export default withRouter(OrganizationEdit); diff --git a/src/pages/Organizations/screens/OrganizationAdd.jsx b/src/pages/Organizations/screens/OrganizationAdd.jsx index 9a1f8e6c01..e02a287972 100644 --- a/src/pages/Organizations/screens/OrganizationAdd.jsx +++ b/src/pages/Organizations/screens/OrganizationAdd.jsx @@ -1,114 +1,65 @@ -import React, { Fragment } from 'react'; +import React from 'react'; import PropTypes from 'prop-types'; import { withRouter } from 'react-router-dom'; -import { I18n, i18nMark } from '@lingui/react'; +import { I18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { PageSection, - Form, - FormGroup, - TextInput, - Gallery, Card, CardHeader, CardBody, Button, Tooltip, } from '@patternfly/react-core'; -import { QuestionCircleIcon, TimesIcon } from '@patternfly/react-icons'; +import { TimesIcon } from '@patternfly/react-icons'; -import { ConfigContext } from '../../../context'; -import Lookup from '../../../components/Lookup'; -import AnsibleSelect from '../../../components/AnsibleSelect'; -import FormActionGroup from '../../../components/FormActionGroup'; +import OrganizationForm from '../components/OrganizationForm'; class OrganizationAdd extends React.Component { constructor (props) { super(props); - this.getInstanceGroups = this.getInstanceGroups.bind(this); - this.onFieldChange = this.onFieldChange.bind(this); - this.onLookupSave = this.onLookupSave.bind(this); - this.onSubmit = this.onSubmit.bind(this); - this.onCancel = this.onCancel.bind(this); - this.onSuccess = this.onSuccess.bind(this); + this.handleSubmit = this.handleSubmit.bind(this); + this.handleCancel = this.handleCancel.bind(this); + this.handleSuccess = this.handleSuccess.bind(this); this.state = { - name: '', - description: '', - custom_virtualenv: '', - instanceGroups: [], error: '', - defaultEnv: '/venv/ansible/', }; } - onFieldChange (val, evt) { - this.setState({ [evt.target.name]: val || evt.target.value }); - } - - onLookupSave (val, targetName) { - this.setState({ [targetName]: val }); - } - - async onSubmit () { + async handleSubmit (values, groupsToAssociate) { const { api } = this.props; - const { name, description, custom_virtualenv, instanceGroups } = this.state; - const data = { - name, - description, - custom_virtualenv - }; try { - const { data: response } = await api.createOrganization(data); + const { data: response } = await api.createOrganization(values); const instanceGroupsUrl = response.related.instance_groups; try { - if (instanceGroups.length > 0) { - instanceGroups.forEach(async (select) => { - await api.associateInstanceGroup(instanceGroupsUrl, select.id); - }); - } + await Promise.all(groupsToAssociate.map(async id => { + await api.associateInstanceGroup(instanceGroupsUrl, id); + })); } catch (err) { this.setState({ error: err }); } finally { - this.onSuccess(response.id); + this.handleSuccess(response.id); } } catch (err) { this.setState({ error: err }); } } - onCancel () { + handleCancel () { const { history } = this.props; history.push('/organizations'); } - onSuccess (id) { + handleSuccess (id) { const { history } = this.props; history.push(`/organizations/${id}`); } - async getInstanceGroups (params) { - const { api } = this.props; - const data = await api.getInstanceGroups(params); - return data; - } - render () { - const { - name, - description, - custom_virtualenv, - defaultEnv, - instanceGroups, - error - } = this.state; - const enabled = name.length > 0; // TODO: add better form validation - const instanceGroupsLookupColumns = [ - { name: i18nMark('Name'), key: 'name', isSortable: true }, - { name: i18nMark('Modified'), key: 'modified', isSortable: false, isNumeric: true }, - { name: i18nMark('Created'), key: 'created', isSortable: false, isNumeric: true } - ]; + const { api } = this.props; + const { error } = this.state; return ( @@ -123,99 +74,19 @@ class OrganizationAdd extends React.Component { - - - - - - - - - - {i18n._(t`Instance Groups`)} - {' '} - - - - - )} - fieldId="add-org-form-instance-groups" - > - - - - {({ custom_virtualenvs }) => ( - custom_virtualenvs && custom_virtualenvs.length > 1 && ( - - {i18n._(t`Ansible Environment`)} - {' '} - - - - - )} - fieldId="add-org-custom-virtualenv" - > - - - ) - )} - - - - {error ? error : ''} - + + {error ? error : ''} )} @@ -225,8 +96,13 @@ class OrganizationAdd extends React.Component { } } +OrganizationAdd.propTypes = { + api: PropTypes.shape().isRequired, +}; + OrganizationAdd.contextTypes = { custom_virtualenvs: PropTypes.arrayOf(PropTypes.string) }; +export { OrganizationAdd as _OrganizationAdd }; export default withRouter(OrganizationAdd); diff --git a/src/util/validators.js b/src/util/validators.js new file mode 100644 index 0000000000..939bb1bdec --- /dev/null +++ b/src/util/validators.js @@ -0,0 +1,20 @@ +import { i18nMark } from '@lingui/react'; + +export function required (message) { + return value => { + if (!value.trim()) { + return message || i18nMark('This field must not be blank'); + } + return undefined; + }; +} + +export function maxLength (max) { + return value => { + if (value.trim().length + > max) { + return i18nMark(`This field must not exceed ${max} characters`); + } + return undefined; + }; +}