From 9c019e1cc04d1598ef164ddf867a03dffba0ddd4 Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Mon, 28 Oct 2019 10:23:08 -0400 Subject: [PATCH 1/5] Add organization and credential lookups --- .../components/Lookup/CredentialLookup.jsx | 68 +++++++++++++++++++ .../Lookup/CredentialLookup.test.jsx | 41 +++++++++++ .../components/Lookup/OrganizationLookup.jsx | 62 +++++++++++++++++ .../Lookup/OrganizationLookup.test.jsx | 38 +++++++++++ 4 files changed, 209 insertions(+) create mode 100644 awx/ui_next/src/components/Lookup/CredentialLookup.jsx create mode 100644 awx/ui_next/src/components/Lookup/CredentialLookup.test.jsx create mode 100644 awx/ui_next/src/components/Lookup/OrganizationLookup.jsx create mode 100644 awx/ui_next/src/components/Lookup/OrganizationLookup.test.jsx diff --git a/awx/ui_next/src/components/Lookup/CredentialLookup.jsx b/awx/ui_next/src/components/Lookup/CredentialLookup.jsx new file mode 100644 index 0000000000..4d5cf86cb0 --- /dev/null +++ b/awx/ui_next/src/components/Lookup/CredentialLookup.jsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { withI18n } from '@lingui/react'; +import { bool, func, number, string } from 'prop-types'; +import { CredentialsAPI } from '@api'; +import { Credential } from '@types'; +import { mergeParams } from '@util/qs'; +import { FormGroup } from '@patternfly/react-core'; +import Lookup from '@components/Lookup'; + +function CredentialLookup({ + helperTextInvalid, + label, + isValid, + onBlur, + onChange, + required, + credentialTypeId, + value, +}) { + const getCredentials = async params => + CredentialsAPI.read( + mergeParams(params, { credential_type: credentialTypeId }) + ); + + return ( + + + + ); +} + +CredentialLookup.propTypes = { + credentialTypeId: number.isRequired, + helperTextInvalid: string, + isValid: bool, + label: string.isRequired, + onBlur: func, + onChange: func.isRequired, + required: bool, + value: Credential, +}; + +CredentialLookup.defaultProps = { + helperTextInvalid: '', + isValid: true, + onBlur: () => {}, + required: false, + value: null, +}; + +export { CredentialLookup as _CredentialLookup }; +export default withI18n()(CredentialLookup); diff --git a/awx/ui_next/src/components/Lookup/CredentialLookup.test.jsx b/awx/ui_next/src/components/Lookup/CredentialLookup.test.jsx new file mode 100644 index 0000000000..797cbdf6c2 --- /dev/null +++ b/awx/ui_next/src/components/Lookup/CredentialLookup.test.jsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import CredentialLookup, { _CredentialLookup } from './CredentialLookup'; +import { CredentialsAPI } from '@api'; + +jest.mock('@api'); + +describe('CredentialLookup', () => { + let wrapper; + + beforeEach(() => { + wrapper = mountWithContexts( + {}} /> + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('initially renders successfully', () => { + expect(wrapper.find('CredentialLookup')).toHaveLength(1); + }); + test('should fetch credentials', () => { + expect(CredentialsAPI.read).toHaveBeenCalledTimes(1); + expect(CredentialsAPI.read).toHaveBeenCalledWith({ + credential_type: 1, + order_by: 'name', + page: 1, + page_size: 5, + }); + }); + test('should display label', () => { + const title = wrapper.find('FormGroup .pf-c-form__label-text'); + expect(title.text()).toEqual('Foo'); + }); + test('should define default value for function props', () => { + expect(_CredentialLookup.defaultProps.onBlur).toBeInstanceOf(Function); + expect(_CredentialLookup.defaultProps.onBlur).not.toThrow(); + }); +}); diff --git a/awx/ui_next/src/components/Lookup/OrganizationLookup.jsx b/awx/ui_next/src/components/Lookup/OrganizationLookup.jsx new file mode 100644 index 0000000000..8efb43b091 --- /dev/null +++ b/awx/ui_next/src/components/Lookup/OrganizationLookup.jsx @@ -0,0 +1,62 @@ +import React from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { string, func, bool } from 'prop-types'; +import { OrganizationsAPI } from '@api'; +import { Organization } from '@types'; +import { FormGroup } from '@patternfly/react-core'; +import Lookup from '@components/Lookup'; + +const getOrganizations = async params => OrganizationsAPI.read(params); + +function OrganizationLookup({ + helperTextInvalid, + i18n, + isValid, + onBlur, + onChange, + required, + value, +}) { + return ( + + + + ); +} + +OrganizationLookup.propTypes = { + helperTextInvalid: string, + isValid: bool, + onBlur: func, + onChange: func.isRequired, + required: bool, + value: Organization, +}; + +OrganizationLookup.defaultProps = { + helperTextInvalid: '', + isValid: true, + onBlur: () => {}, + required: false, + value: null, +}; + +export default withI18n()(OrganizationLookup); +export { OrganizationLookup as _OrganizationLookup }; diff --git a/awx/ui_next/src/components/Lookup/OrganizationLookup.test.jsx b/awx/ui_next/src/components/Lookup/OrganizationLookup.test.jsx new file mode 100644 index 0000000000..fef9a90281 --- /dev/null +++ b/awx/ui_next/src/components/Lookup/OrganizationLookup.test.jsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import OrganizationLookup, { _OrganizationLookup } from './OrganizationLookup'; +import { OrganizationsAPI } from '@api'; + +jest.mock('@api'); + +describe('OrganizationLookup', () => { + let wrapper; + + beforeEach(() => { + wrapper = mountWithContexts( {}} />); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('initially renders successfully', () => { + expect(wrapper).toHaveLength(1); + }); + test('should fetch organizations', () => { + expect(OrganizationsAPI.read).toHaveBeenCalledTimes(1); + expect(OrganizationsAPI.read).toHaveBeenCalledWith({ + order_by: 'name', + page: 1, + page_size: 5, + }); + }); + test('should display "Organization" label', () => { + const title = wrapper.find('FormGroup .pf-c-form__label-text'); + expect(title.text()).toEqual('Organization'); + }); + test('should define default value for function props', () => { + expect(_OrganizationLookup.defaultProps.onBlur).toBeInstanceOf(Function); + expect(_OrganizationLookup.defaultProps.onBlur).not.toThrow(); + }); +}); From e4bde24f38f24c5b9b7636f19589f551dff5220f Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Mon, 28 Oct 2019 10:24:22 -0400 Subject: [PATCH 2/5] Add project add form and tests --- .../src/components/FormField/FormField.jsx | 19 +- .../src/components/FormRow/FormRow.jsx | 4 +- .../screens/Project/ProjectAdd/ProjectAdd.jsx | 69 ++- .../Project/ProjectAdd/ProjectAdd.test.jsx | 101 ++++ .../screens/Project/shared/ProjectForm.jsx | 485 ++++++++++++++++++ .../Project/shared/ProjectForm.test.jsx | 209 ++++++++ 6 files changed, 876 insertions(+), 11 deletions(-) create mode 100644 awx/ui_next/src/screens/Project/ProjectAdd/ProjectAdd.test.jsx create mode 100644 awx/ui_next/src/screens/Project/shared/ProjectForm.jsx create mode 100644 awx/ui_next/src/screens/Project/shared/ProjectForm.test.jsx diff --git a/awx/ui_next/src/components/FormField/FormField.jsx b/awx/ui_next/src/components/FormField/FormField.jsx index 3bd6370c3c..f40792cc06 100644 --- a/awx/ui_next/src/components/FormField/FormField.jsx +++ b/awx/ui_next/src/components/FormField/FormField.jsx @@ -10,7 +10,16 @@ const QuestionCircleIcon = styled(PFQuestionCircleIcon)` `; function FormField(props) { - const { id, name, label, tooltip, validate, isRequired, ...rest } = props; + const { + id, + name, + label, + tooltip, + tooltipMaxWidth, + validate, + isRequired, + ...rest + } = props; return ( {tooltip && ( - + )} @@ -58,6 +71,7 @@ FormField.propTypes = { validate: PropTypes.func, isRequired: PropTypes.bool, tooltip: PropTypes.node, + tooltipMaxWidth: PropTypes.string, }; FormField.defaultProps = { @@ -65,6 +79,7 @@ FormField.defaultProps = { validate: () => {}, isRequired: false, tooltip: null, + tooltipMaxWidth: '', }; export default FormField; diff --git a/awx/ui_next/src/components/FormRow/FormRow.jsx b/awx/ui_next/src/components/FormRow/FormRow.jsx index de06a481ab..a28913dd37 100644 --- a/awx/ui_next/src/components/FormRow/FormRow.jsx +++ b/awx/ui_next/src/components/FormRow/FormRow.jsx @@ -6,6 +6,6 @@ const Row = styled.div` grid-gap: 20px; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); `; -export default function FormRow({ children }) { - return {children}; +export default function FormRow({ children, className }) { + return {children}; } diff --git a/awx/ui_next/src/screens/Project/ProjectAdd/ProjectAdd.jsx b/awx/ui_next/src/screens/Project/ProjectAdd/ProjectAdd.jsx index 36df6994d3..526d918629 100644 --- a/awx/ui_next/src/screens/Project/ProjectAdd/ProjectAdd.jsx +++ b/awx/ui_next/src/screens/Project/ProjectAdd/ProjectAdd.jsx @@ -1,10 +1,65 @@ -import React, { Component } from 'react'; -import { PageSection } from '@patternfly/react-core'; +import React, { useState } from 'react'; +import { withRouter } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import styled from 'styled-components'; +import { + Card as _Card, + CardBody, + CardHeader, + PageSection, + Tooltip, +} from '@patternfly/react-core'; +import CardCloseButton from '@components/CardCloseButton'; +import ProjectForm from '../shared/ProjectForm'; +import { ProjectsAPI } from '@api'; -class ProjectAdd extends Component { - render() { - return Coming soon :); - } +const Card = styled(_Card)` + --pf-c-card--child--PaddingLeft: 0; + --pf-c-card--child--PaddingRight: 0; +`; + +function ProjectAdd({ history, i18n }) { + const [formSubmitError, setFormSubmitError] = useState(null); + + const handleSubmit = async values => { + setFormSubmitError(null); + try { + const { + data: { id }, + } = await ProjectsAPI.create(values); + history.push(`/projects/${id}/details`); + } catch (error) { + setFormSubmitError(error); + } + }; + + const handleCancel = () => { + history.push(`/projects`); + }; + + return ( + + + + + + + + + + + {formSubmitError ? ( +
formSubmitError
+ ) : ( + '' + )} +
+
+ ); } -export default ProjectAdd; +export default withI18n()(withRouter(ProjectAdd)); diff --git a/awx/ui_next/src/screens/Project/ProjectAdd/ProjectAdd.test.jsx b/awx/ui_next/src/screens/Project/ProjectAdd/ProjectAdd.test.jsx new file mode 100644 index 0000000000..f315294b30 --- /dev/null +++ b/awx/ui_next/src/screens/Project/ProjectAdd/ProjectAdd.test.jsx @@ -0,0 +1,101 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; +import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; +import ProjectAdd from './ProjectAdd'; +import { ProjectsAPI } from '@api'; + +jest.mock('@api'); + +describe('', () => { + const projectData = { + name: 'foo', + description: 'bar', + scm_type: 'git', + scm_url: 'https://foo.bar', + scm_clean: true, + credential: 100, + organization: 2, + scm_update_on_launch: true, + scm_update_cache_timeout: 3, + allow_override: false, + custom_virtualenv: '/venv/custom-env', + }; + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('initially renders successfully', () => { + const wrapper = mountWithContexts(); + expect(wrapper.length).toBe(1); + }); + + test('handleSubmit should post to the api', async () => { + ProjectsAPI.create.mockResolvedValueOnce({ + data: { ...projectData }, + }); + let wrapper; + await act(async () => { + wrapper = mountWithContexts(); + }); + await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0); + const formik = wrapper.find('Formik').instance(); + const changeState = new Promise(resolve => { + formik.setState( + { + values: { + ...projectData, + }, + }, + () => resolve() + ); + }); + await changeState; + wrapper.find('form').simulate('submit'); + }); + + test('handleSubmit should throw an error', async () => { + ProjectsAPI.create.mockImplementation(() => Promise.reject(new Error())); + let wrapper; + await act(async () => { + wrapper = mountWithContexts(); + }); + await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0); + const formik = wrapper.find('Formik').instance(); + const changeState = new Promise(resolve => { + formik.setState( + { + values: { + ...projectData, + }, + }, + () => resolve() + ); + }); + await changeState; + await act(async () => { + wrapper.find('form').simulate('submit'); + }); + wrapper.update(); + expect(wrapper.find('ProjectAdd .formSubmitError').length).toBe(1); + }); + + test('CardHeader close button should navigate to projects list', () => { + const history = createMemoryHistory(); + const wrapper = mountWithContexts(, { + context: { router: { history } }, + }).find('ProjectAdd CardHeader'); + wrapper.find('CardCloseButton').simulate('click'); + expect(history.location.pathname).toEqual('/projects'); + }); + + test('CardBody cancel button should navigate to projects list', () => { + const history = createMemoryHistory(); + const wrapper = mountWithContexts(, { + context: { router: { history } }, + }).find('ProjectAdd CardBody'); + wrapper.find('button[aria-label="Cancel"]').simulate('click'); + expect(history.location.pathname).toEqual('/projects'); + }); +}); diff --git a/awx/ui_next/src/screens/Project/shared/ProjectForm.jsx b/awx/ui_next/src/screens/Project/shared/ProjectForm.jsx new file mode 100644 index 0000000000..16593e737e --- /dev/null +++ b/awx/ui_next/src/screens/Project/shared/ProjectForm.jsx @@ -0,0 +1,485 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { withRouter, Link } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { withFormik, Field } from 'formik'; +import { Config } from '@contexts/Config'; +import { + Form as _Form, + FormGroup, + Title as _Title, +} from '@patternfly/react-core'; +import AnsibleSelect from '@components/AnsibleSelect'; +import FormActionGroup from '@components/FormActionGroup/FormActionGroup'; +import FormField, { CheckboxField, FieldTooltip } from '@components/FormField'; +import FormRow from '@components/FormRow'; +import OrganizationLookup from '@components/Lookup/OrganizationLookup'; +import CredentialLookup from '@components/Lookup/CredentialLookup'; +import { required } from '@util/validators'; +import styled from 'styled-components'; + +const Form = styled(_Form)` + padding: 0 24px; +`; + +const ScmTypeFormRow = styled(FormRow)` + background-color: #f5f5f5; + grid-column: 1 / -1; + margin: 0 -24px; + padding: 24px; +`; + +const OptionsFormGroup = styled.div` + grid-column: 1/-1; +`; + +const Title = styled(_Title)` + --pf-c-title--m-md--FontWeight: 700; + grid-column: 1 / -1; +`; + +function ProjectForm(props) { + const { values, handleCancel, handleSubmit, i18n } = props; + const [organization, setOrganization] = useState(null); + const [scmCredential, setScmCredential] = useState(null); + const [insightsCredential, setInsightsCredential] = useState(null); + + const resetScmTypeFields = (value, form) => { + if (form.initialValues.scm_type === value) { + return; + } + const scmFormFields = [ + 'scm_url', + 'scm_branch', + 'scm_refspec', + 'credential', + 'scm_clean', + 'scm_delete_on_update', + 'scm_update_on_launch', + 'allow_override', + 'scm_update_cache_timeout', + ]; + + scmFormFields.forEach(field => { + form.setFieldValue(field, form.initialValues[field]); + form.setFieldTouched(field, false); + }); + }; + + const scmTypeOptions = [ + { + value: '', + key: '', + label: i18n._(t`Choose a SCM Type`), + isDisabled: true, + }, + { value: 'manual', key: 'manual', label: i18n._(t`Manual`) }, + { + value: 'git', + key: 'git', + label: i18n._(t`Git`), + }, + { + value: 'hg', + key: 'hg', + label: i18n._(t`Mercurial`), + }, + { + value: 'svn', + key: 'svn', + label: i18n._(t`Subversion`), + }, + { + value: 'insights', + key: 'insights', + label: i18n._(t`Red Hat Insights`), + }, + ]; + + const gitScmTooltip = ( + + {i18n._(t`Example URLs for GIT SCM include:`)} +
    +
  • https://github.com/ansible/ansible.git
  • +
  • git@github.com:ansible/ansible.git
  • +
  • git://servername.example.com/ansible.git
  • +
+ + {i18n._(t`Note: When using SSH protocol for GitHub or + Bitbucket, enter an SSH key only, do not enter a username + (other than git). Additionally, GitHub and Bitbucket do + not support password authentication when using SSH. GIT + read only protocol (git://) does not use username or + password information.`)} +
+ ); + + const hgScmTooltip = ( + + {i18n._(t`Example URLs for Mercurial SCM include:`)} +
    +
  • https://bitbucket.org/username/project
  • +
  • ssh://hg@bitbucket.org/username/project
  • +
  • ssh://server.example.com/path
  • +
+ {i18n._(t`Note: Mercurial does not support password authentication + for SSH. Do not put the username and key in the URL. If using + Bitbucket and SSH, do not supply your Bitbucket username. + `)} +
+ ); + + const svnScmTooltip = ( + + {i18n._(t`Example URLs for Subversion SCM include:`)} +
    +
  • https://github.com/ansible/ansible
  • +
  • svn://servername.example.com/path
  • +
  • svn+ssh://servername.example.com/path
  • +
+
+ ); + + const scmUrlTooltips = { + git: gitScmTooltip, + hg: hgScmTooltip, + svn: svnScmTooltip, + }; + + const scmBranchLabels = { + git: i18n._(t`SCM Branch/Tag/Commit`), + hg: i18n._(t`SCM Branch/Tag/Revision`), + svn: i18n._(t`Revision #`), + }; + + return ( +
+ + + + { + return ( + form.setFieldTouched('organization')} + onChange={value => { + form.setFieldValue('organization', value.id); + setOrganization(value); + }} + value={organization} + required + /> + ); + }} + /> + ( + + { + form.setFieldValue('scm_type', value); + resetScmTypeFields(value, form); + }} + /> + + )} + /> + {values.scm_type !== '' && ( + + {i18n._(t`Type Details`)} + {(values.scm_type === 'git' || + values.scm_type === 'hg' || + values.scm_type === 'svn') && ( + + )} + {(values.scm_type === 'git' || + values.scm_type === 'hg' || + values.scm_type === 'svn') && ( + + )} + {values.scm_type === 'git' && ( + + {i18n._(t`A refspec to fetch (passed to the Ansible git + module). This parameter allows access to references via + the branch field not otherwise available.`)} +
+
+ {i18n._( + t`Note: This field assumes the remote name is "origin".` + )} +
+
+ {i18n._(t`Examples include:`)} +
    +
  • refs/*:refs/remotes/origin/*
  • +
  • + refs/pull/62/head:refs/remotes/origin/pull/62/head +
  • +
+ {i18n._(t`The first fetches all references. The second + fetches the Github pull request number 62, in this example + the branch needs to be "pull/62/head".`)} +
+
+ {i18n._(t`For more information, refer to the`)}{' '} + + {i18n._(t`Ansible Tower Documentation.`)} + + + } + /> + )} + {(values.scm_type === 'git' || + values.scm_type === 'hg' || + values.scm_type === 'svn') && ( + ( + { + form.setFieldValue('credential', value.id); + setScmCredential(value); + }} + /> + )} + /> + )} + {values.scm_type === 'insights' && ( + ( + form.setFieldTouched('credential')} + onChange={value => { + form.setFieldValue('credential', value.id); + setInsightsCredential(value); + }} + value={insightsCredential} + required + /> + )} + /> + )} + {/* + PF Bug: FormGroup doesn't pass down className + Workaround is to wrap FormGroup with an extra div + Cleanup when upgraded to @patternfly/react-core@3.103.4 + */} + {values.scm_type !== 'manual' && ( + + + + + + + {values.scm_type !== 'insights' && ( + + )} + + + + )} + {values.scm_type !== 'manual' && values.scm_update_on_launch && ( + <> + {i18n._(t`Option Details`)} + + + )} +
+ )} + + {({ custom_virtualenvs }) => + custom_virtualenvs && + custom_virtualenvs.length > 1 && ( + ( + + + datum !== '/venv/ansible/') + .map(datum => ({ + label: datum, + value: datum, + key: datum, + })), + ]} + {...field} + /> + + )} + /> + ) + } + +
+ + + ); +} + +const FormikApp = withFormik({ + mapPropsToValues(props) { + const { project = {} } = props; + + return { + credential: project.credential || '', + custom_virtualenv: project.custom_virtualenv || '', + description: project.description || '', + name: project.name || '', + organization: project.organization || '', + scm_branch: project.scm_branch || '', + scm_clean: project.scm_clean || false, + scm_delete_on_update: project.scm_delete_on_update || false, + scm_refspec: project.scm_refspec || '', + scm_type: project.scm_type || '', + scm_update_on_launch: project.scm_update_on_launch || false, + scm_url: project.scm_url || '', + scm_update_cache_timeout: project.scm_update_cache_timeout || 0, + allow_override: project.allow_override || false, + }; + }, + handleSubmit: (values, { props }) => props.handleSubmit(values), +})(ProjectForm); + +ProjectForm.propTypes = { + handleCancel: PropTypes.func.isRequired, + handleSubmit: PropTypes.func.isRequired, + project: PropTypes.shape({}), +}; + +ProjectForm.defaultProps = { + project: {}, +}; + +export default withI18n()(withRouter(FormikApp)); diff --git a/awx/ui_next/src/screens/Project/shared/ProjectForm.test.jsx b/awx/ui_next/src/screens/Project/shared/ProjectForm.test.jsx new file mode 100644 index 0000000000..9fd351833a --- /dev/null +++ b/awx/ui_next/src/screens/Project/shared/ProjectForm.test.jsx @@ -0,0 +1,209 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; +import { sleep } from '@testUtils/testUtils'; +import ProjectForm from './ProjectForm'; + +jest.mock('@api'); + +describe('', () => { + let wrapper; + const mockData = { + name: 'foo', + description: 'bar', + scm_type: 'git', + scm_url: 'https://foo.bar', + scm_clean: true, + credential: 100, + organization: 2, + scm_update_on_launch: true, + scm_update_cache_timeout: 3, + allow_override: false, + custom_virtualenv: '/venv/custom-env', + }; + + beforeEach(() => { + const config = { + custom_virtualenvs: ['venv/foo', 'venv/bar'], + }; + wrapper = mountWithContexts( + , + { + context: { config }, + } + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('initially renders successfully', () => { + expect(wrapper.find('ProjectForm').length).toBe(1); + }); + + test('new form displays primary form fields', () => { + expect(wrapper.find('FormGroup[label="Name"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="Description"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="Organization"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="SCM Type"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="Ansible Environment"]').length).toBe( + 1 + ); + expect(wrapper.find('FormGroup[label="Options"]').length).toBe(0); + }); + + test('should display scm subform when scm type select has a value', async () => { + const formik = wrapper.find('Formik').instance(); + const changeState = new Promise(resolve => { + formik.setState( + { + values: { + ...mockData, + }, + }, + () => resolve() + ); + }); + await changeState; + wrapper.update(); + expect(wrapper.find('FormGroup[label="SCM URL"]').length).toBe(1); + expect( + wrapper.find('FormGroup[label="SCM Branch/Tag/Commit"]').length + ).toBe(1); + expect(wrapper.find('FormGroup[label="SCM Refspec"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="SCM Credential"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="Options"]').length).toBe(1); + }); + + test('inputs should update form value on change', async () => { + const project = { ...mockData }; + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + const form = wrapper.find('Formik'); + act(() => { + wrapper.find('OrganizationLookup').invoke('onBlur')(); + wrapper.find('OrganizationLookup').invoke('onChange')({ + id: 1, + name: 'organization', + }); + }); + expect(form.state('values').organization).toEqual(1); + act(() => { + wrapper.find('CredentialLookup').invoke('onBlur')(); + wrapper.find('CredentialLookup').invoke('onChange')({ + id: 10, + name: 'credential', + }); + }); + expect(form.state('values').credential).toEqual(10); + }); + + test('should display insights credential lookup when scm type is "Insights"', async () => { + const formik = wrapper.find('Formik').instance(); + const changeState = new Promise(resolve => { + formik.setState( + { + values: { + ...mockData, + scm_type: 'insights', + }, + }, + () => resolve() + ); + }); + await changeState; + wrapper.update(); + expect(wrapper.find('FormGroup[label="Insights Credential"]').length).toBe( + 1 + ); + act(() => { + wrapper.find('CredentialLookup').invoke('onBlur')(); + wrapper.find('CredentialLookup').invoke('onChange')({ + id: 123, + name: 'credential', + }); + }); + expect(formik.state.values.credential).toEqual(123); + }); + + test('should reset scm subform values when scm type changes', async () => { + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + const scmTypeSelect = wrapper.find( + 'FormGroup[label="SCM Type"] FormSelect' + ); + const formik = wrapper.find('Formik').instance(); + expect(formik.state.values.scm_url).toEqual(''); + await act(async () => { + scmTypeSelect.props().onChange('hg', { target: { name: 'Mercurial' } }); + }); + wrapper.update(); + await act(async () => { + wrapper.find('FormGroup[label="SCM URL"] input').simulate('change', { + target: { value: 'baz', name: 'scm_url' }, + }); + }); + expect(formik.state.values.scm_url).toEqual('baz'); + await act(async () => { + scmTypeSelect + .props() + .onChange('insights', { target: { name: 'insights' } }); + }); + wrapper.update(); + await act(async () => { + scmTypeSelect.props().onChange('git', { target: { name: 'insights' } }); + }); + wrapper.update(); + expect(formik.state.values.scm_url).toEqual(''); + }); + + test('should call handleSubmit when Submit button is clicked', async () => { + const handleSubmit = jest.fn(); + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0); + expect(handleSubmit).not.toHaveBeenCalled(); + wrapper.find('button[aria-label="Save"]').simulate('click'); + await sleep(1); + expect(handleSubmit).toBeCalled(); + }); + + test('should call handleCancel when Cancel button is clicked', async () => { + const handleCancel = jest.fn(); + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0); + expect(handleCancel).not.toHaveBeenCalled(); + wrapper.find('button[aria-label="Cancel"]').invoke('onClick')(); + expect(handleCancel).toBeCalled(); + }); +}); From 31fdd5e85ce1315fabd8560c7ff620334136e56c Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Mon, 28 Oct 2019 10:25:10 -0400 Subject: [PATCH 3/5] Add scm refspec to project detail and remove org id from top level shared component --- awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx | 4 ++-- .../src/screens/Project/ProjectDetail/ProjectDetail.jsx | 2 ++ .../src/screens/Project/ProjectDetail/ProjectDetail.test.jsx | 1 + 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx index b73ca373a3..5b978b2c66 100644 --- a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx +++ b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx @@ -101,7 +101,7 @@ class MultiCredentialsLookup extends React.Component { const { selectedCredentialType, credentialTypes } = this.state; const { tooltip, i18n, credentials } = this.props; return ( - + {tooltip && ( @@ -114,7 +114,7 @@ class MultiCredentialsLookup extends React.Component { selectedCategory={selectedCredentialType} onToggleItem={this.toggleCredentialSelection} onloadCategories={this.loadCredentialTypes} - id="org-credentials" + id="multiCredential" lookupHeader={i18n._(t`Credentials`)} name="credentials" value={credentials} diff --git a/awx/ui_next/src/screens/Project/ProjectDetail/ProjectDetail.jsx b/awx/ui_next/src/screens/Project/ProjectDetail/ProjectDetail.jsx index 037092ae7f..ab738c97b3 100644 --- a/awx/ui_next/src/screens/Project/ProjectDetail/ProjectDetail.jsx +++ b/awx/ui_next/src/screens/Project/ProjectDetail/ProjectDetail.jsx @@ -30,6 +30,7 @@ function ProjectDetail({ project, i18n }) { scm_branch, scm_clean, scm_delete_on_update, + scm_refspec, scm_type, scm_update_on_launch, scm_update_cache_timeout, @@ -98,6 +99,7 @@ function ProjectDetail({ project, i18n }) { + {summary_fields.credential && ( ', () => { assertDetail('SCM Type', mockProject.scm_type); assertDetail('SCM URL', mockProject.scm_url); assertDetail('SCM Branch', mockProject.scm_branch); + assertDetail('SCM Refspec', mockProject.scm_refspec); assertDetail( 'SCM Credential', `Scm: ${mockProject.summary_fields.credential.name}` From ae349addfe2ece85907f1a93c459cec1a7356105 Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Tue, 29 Oct 2019 14:56:03 -0400 Subject: [PATCH 4/5] Resolve credential type id and retrieve scm_type choices from OPTIONS --- .../components/Lookup/CredentialLookup.jsx | 4 +- .../Project/ProjectAdd/ProjectAdd.test.jsx | 92 ++++++++-- .../screens/Project/shared/ProjectForm.jsx | 167 +++++++++++------- .../Project/shared/ProjectForm.test.jsx | 114 ++++++++++-- 4 files changed, 285 insertions(+), 92 deletions(-) diff --git a/awx/ui_next/src/components/Lookup/CredentialLookup.jsx b/awx/ui_next/src/components/Lookup/CredentialLookup.jsx index 4d5cf86cb0..6872e09784 100644 --- a/awx/ui_next/src/components/Lookup/CredentialLookup.jsx +++ b/awx/ui_next/src/components/Lookup/CredentialLookup.jsx @@ -1,6 +1,6 @@ import React from 'react'; import { withI18n } from '@lingui/react'; -import { bool, func, number, string } from 'prop-types'; +import { bool, func, number, string, oneOfType } from 'prop-types'; import { CredentialsAPI } from '@api'; import { Credential } from '@types'; import { mergeParams } from '@util/qs'; @@ -46,7 +46,7 @@ function CredentialLookup({ } CredentialLookup.propTypes = { - credentialTypeId: number.isRequired, + credentialTypeId: oneOfType([number, string]).isRequired, helperTextInvalid: string, isValid: bool, label: string.isRequired, diff --git a/awx/ui_next/src/screens/Project/ProjectAdd/ProjectAdd.test.jsx b/awx/ui_next/src/screens/Project/ProjectAdd/ProjectAdd.test.jsx index f315294b30..2b9857bc89 100644 --- a/awx/ui_next/src/screens/Project/ProjectAdd/ProjectAdd.test.jsx +++ b/awx/ui_next/src/screens/Project/ProjectAdd/ProjectAdd.test.jsx @@ -3,11 +3,12 @@ import { act } from 'react-dom/test-utils'; import { createMemoryHistory } from 'history'; import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; import ProjectAdd from './ProjectAdd'; -import { ProjectsAPI } from '@api'; +import { ProjectsAPI, CredentialTypesAPI } from '@api'; jest.mock('@api'); describe('', () => { + let wrapper; const projectData = { name: 'foo', description: 'bar', @@ -22,12 +23,68 @@ describe('', () => { custom_virtualenv: '/venv/custom-env', }; + const projectOptionsResolve = { + data: { + actions: { + GET: { + scm_type: { + choices: [ + ['', 'Manual'], + ['git', 'Git'], + ['hg', 'Mercurial'], + ['svn', 'Subversion'], + ['insights', 'Red Hat Insights'], + ], + }, + }, + }, + }, + }; + + const scmCredentialResolve = { + data: { + results: [ + { + id: 4, + name: 'Source Control', + kind: 'scm', + }, + ], + }, + }; + + const insightsCredentialResolve = { + data: { + results: [ + { + id: 5, + name: 'Insights', + kind: 'insights', + }, + ], + }, + }; + + beforeEach(async () => { + await ProjectsAPI.readOptions.mockImplementation( + () => projectOptionsResolve + ); + await CredentialTypesAPI.read.mockImplementationOnce( + () => scmCredentialResolve + ); + await CredentialTypesAPI.read.mockImplementationOnce( + () => insightsCredentialResolve + ); + }); + afterEach(() => { jest.clearAllMocks(); }); - test('initially renders successfully', () => { - const wrapper = mountWithContexts(); + test('initially renders successfully', async () => { + await act(async () => { + wrapper = mountWithContexts(); + }); expect(wrapper.length).toBe(1); }); @@ -35,11 +92,10 @@ describe('', () => { ProjectsAPI.create.mockResolvedValueOnce({ data: { ...projectData }, }); - let wrapper; await act(async () => { wrapper = mountWithContexts(); }); - await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); const formik = wrapper.find('Formik').instance(); const changeState = new Promise(resolve => { formik.setState( @@ -57,11 +113,10 @@ describe('', () => { test('handleSubmit should throw an error', async () => { ProjectsAPI.create.mockImplementation(() => Promise.reject(new Error())); - let wrapper; await act(async () => { wrapper = mountWithContexts(); }); - await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); const formik = wrapper.find('Formik').instance(); const changeState = new Promise(resolve => { formik.setState( @@ -81,21 +136,26 @@ describe('', () => { expect(wrapper.find('ProjectAdd .formSubmitError').length).toBe(1); }); - test('CardHeader close button should navigate to projects list', () => { + test('CardHeader close button should navigate to projects list', async () => { const history = createMemoryHistory(); - const wrapper = mountWithContexts(, { - context: { router: { history } }, - }).find('ProjectAdd CardHeader'); + await act(async () => { + wrapper = mountWithContexts(, { + context: { router: { history } }, + }).find('ProjectAdd CardHeader'); + }); wrapper.find('CardCloseButton').simulate('click'); expect(history.location.pathname).toEqual('/projects'); }); - test('CardBody cancel button should navigate to projects list', () => { + test('CardBody cancel button should navigate to projects list', async () => { const history = createMemoryHistory(); - const wrapper = mountWithContexts(, { - context: { router: { history } }, - }).find('ProjectAdd CardBody'); - wrapper.find('button[aria-label="Cancel"]').simulate('click'); + await act(async () => { + wrapper = mountWithContexts(, { + context: { router: { history } }, + }); + }); + await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0); + wrapper.find('ProjectAdd button[aria-label="Cancel"]').simulate('click'); expect(history.location.pathname).toEqual('/projects'); }); }); diff --git a/awx/ui_next/src/screens/Project/shared/ProjectForm.jsx b/awx/ui_next/src/screens/Project/shared/ProjectForm.jsx index 16593e737e..7478954a5e 100644 --- a/awx/ui_next/src/screens/Project/shared/ProjectForm.jsx +++ b/awx/ui_next/src/screens/Project/shared/ProjectForm.jsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import PropTypes from 'prop-types'; import { withRouter, Link } from 'react-router-dom'; import { withI18n } from '@lingui/react'; @@ -11,11 +11,14 @@ import { Title as _Title, } from '@patternfly/react-core'; import AnsibleSelect from '@components/AnsibleSelect'; +import ContentError from '@components/ContentError'; +import ContentLoading from '@components/ContentLoading'; import FormActionGroup from '@components/FormActionGroup/FormActionGroup'; import FormField, { CheckboxField, FieldTooltip } from '@components/FormField'; import FormRow from '@components/FormRow'; import OrganizationLookup from '@components/Lookup/OrganizationLookup'; import CredentialLookup from '@components/Lookup/CredentialLookup'; +import { CredentialTypesAPI, ProjectsAPI } from '@api'; import { required } from '@util/validators'; import styled from 'styled-components'; @@ -41,9 +44,54 @@ const Title = styled(_Title)` function ProjectForm(props) { const { values, handleCancel, handleSubmit, i18n } = props; + const [contentError, setContentError] = useState(null); + const [hasContentLoading, setHasContentLoading] = useState(true); const [organization, setOrganization] = useState(null); - const [scmCredential, setScmCredential] = useState(null); - const [insightsCredential, setInsightsCredential] = useState(null); + const [scmTypeOptions, setScmTypeOptions] = useState(null); + const [scmCredential, setScmCredential] = useState({ + typeId: null, + value: null, + }); + const [insightsCredential, setInsightsCredential] = useState({ + typeId: null, + value: null, + }); + + useEffect(() => { + async function fetchCredTypeId(params) { + try { + const { + data: { + results: [credential], + }, + } = await CredentialTypesAPI.read(params); + return credential.id; + } catch (error) { + setContentError(error); + return null; + } + } + + async function fetchData() { + const insightsTypeId = await fetchCredTypeId({ name: 'Insights' }); + const scmTypeId = await fetchCredTypeId({ kind: 'scm' }); + const { + data: { + actions: { + GET: { + scm_type: { choices }, + }, + }, + }, + } = await ProjectsAPI.readOptions(); + setInsightsCredential({ typeId: insightsTypeId }); + setScmCredential({ typeId: scmTypeId }); + setScmTypeOptions(choices); + setHasContentLoading(false); + } + + fetchData(); + }, []); const resetScmTypeFields = (value, form) => { if (form.initialValues.scm_type === value) { @@ -67,36 +115,6 @@ function ProjectForm(props) { }); }; - const scmTypeOptions = [ - { - value: '', - key: '', - label: i18n._(t`Choose a SCM Type`), - isDisabled: true, - }, - { value: 'manual', key: 'manual', label: i18n._(t`Manual`) }, - { - value: 'git', - key: 'git', - label: i18n._(t`Git`), - }, - { - value: 'hg', - key: 'hg', - label: i18n._(t`Mercurial`), - }, - { - value: 'svn', - key: 'svn', - label: i18n._(t`Subversion`), - }, - { - value: 'insights', - key: 'insights', - label: i18n._(t`Red Hat Insights`), - }, - ]; - const gitScmTooltip = ( {i18n._(t`Example URLs for GIT SCM include:`)} @@ -153,6 +171,14 @@ function ProjectForm(props) { svn: i18n._(t`Revision #`), }; + if (hasContentLoading) { + return ; + } + + if (contentError) { + return ; + } + return (
@@ -173,23 +199,19 @@ function ProjectForm(props) { { - return ( - form.setFieldTouched('organization')} - onChange={value => { - form.setFieldValue('organization', value.id); - setOrganization(value); - }} - value={organization} - required - /> - ); - }} + render={({ form }) => ( + form.setFieldTouched('organization')} + onChange={value => { + form.setFieldValue('organization', value.id); + setOrganization(value); + }} + value={organization} + required + /> + )} /> { + if (option[1] === 'Manual') { + option[0] = 'manual'; + } + return { + label: option[1], + value: option[0], + key: option[0], + }; + }), + ]} onChange={(event, value) => { form.setFieldValue('scm_type', value); resetScmTypeFields(value, form); @@ -291,12 +330,15 @@ function ProjectForm(props) { name="credential" render={({ form }) => ( { - form.setFieldValue('credential', value.id); - setScmCredential(value); + value={scmCredential.value} + onChange={credential => { + form.setFieldValue('credential', credential.id); + setScmCredential({ + ...scmCredential, + value: credential, + }); }} /> )} @@ -311,18 +353,21 @@ function ProjectForm(props) { )} render={({ form }) => ( form.setFieldTouched('credential')} - onChange={value => { - form.setFieldValue('credential', value.id); - setInsightsCredential(value); + onChange={credential => { + form.setFieldValue('credential', credential.id); + setInsightsCredential({ + ...insightsCredential, + value: credential, + }); }} - value={insightsCredential} + value={insightsCredential.value} required /> )} diff --git a/awx/ui_next/src/screens/Project/shared/ProjectForm.test.jsx b/awx/ui_next/src/screens/Project/shared/ProjectForm.test.jsx index 9fd351833a..00ee3fbf99 100644 --- a/awx/ui_next/src/screens/Project/shared/ProjectForm.test.jsx +++ b/awx/ui_next/src/screens/Project/shared/ProjectForm.test.jsx @@ -3,6 +3,7 @@ import { act } from 'react-dom/test-utils'; import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; import { sleep } from '@testUtils/testUtils'; import ProjectForm from './ProjectForm'; +import { CredentialTypesAPI, ProjectsAPI } from '@api'; jest.mock('@api'); @@ -22,27 +23,88 @@ describe('', () => { custom_virtualenv: '/venv/custom-env', }; - beforeEach(() => { - const config = { - custom_virtualenvs: ['venv/foo', 'venv/bar'], - }; - wrapper = mountWithContexts( - , - { - context: { config }, - } + const projectOptionsResolve = { + data: { + actions: { + GET: { + scm_type: { + choices: [ + ['', 'Manual'], + ['git', 'Git'], + ['hg', 'Mercurial'], + ['svn', 'Subversion'], + ['insights', 'Red Hat Insights'], + ], + }, + }, + }, + }, + }; + + const scmCredentialResolve = { + data: { + results: [ + { + id: 4, + name: 'Source Control', + kind: 'scm', + }, + ], + }, + }; + + const insightsCredentialResolve = { + data: { + results: [ + { + id: 5, + name: 'Insights', + kind: 'insights', + }, + ], + }, + }; + + beforeEach(async () => { + await ProjectsAPI.readOptions.mockImplementation( + () => projectOptionsResolve + ); + await CredentialTypesAPI.read.mockImplementationOnce( + () => scmCredentialResolve + ); + await CredentialTypesAPI.read.mockImplementationOnce( + () => insightsCredentialResolve ); }); afterEach(() => { + wrapper.unmount(); jest.clearAllMocks(); }); - test('initially renders successfully', () => { + test('initially renders successfully', async () => { + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + expect(wrapper.find('ProjectForm').length).toBe(1); }); - test('new form displays primary form fields', () => { + test('new form displays primary form fields', async () => { + const config = { + custom_virtualenvs: ['venv/foo', 'venv/bar'], + }; + await act(async () => { + wrapper = mountWithContexts( + , + { + context: { config }, + } + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); expect(wrapper.find('FormGroup[label="Name"]').length).toBe(1); expect(wrapper.find('FormGroup[label="Description"]').length).toBe(1); expect(wrapper.find('FormGroup[label="Organization"]').length).toBe(1); @@ -54,6 +116,12 @@ describe('', () => { }); test('should display scm subform when scm type select has a value', async () => { + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); const formik = wrapper.find('Formik').instance(); const changeState = new Promise(resolve => { formik.setState( @@ -87,6 +155,7 @@ describe('', () => { /> ); }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); const form = wrapper.find('Formik'); act(() => { wrapper.find('OrganizationLookup').invoke('onBlur')(); @@ -107,6 +176,12 @@ describe('', () => { }); test('should display insights credential lookup when scm type is "Insights"', async () => { + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); const formik = wrapper.find('Formik').instance(); const changeState = new Promise(resolve => { formik.setState( @@ -144,6 +219,8 @@ describe('', () => { /> ); }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + const scmTypeSelect = wrapper.find( 'FormGroup[label="SCM Type"] FormSelect' ); @@ -183,7 +260,7 @@ describe('', () => { /> ); }); - await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); expect(handleSubmit).not.toHaveBeenCalled(); wrapper.find('button[aria-label="Save"]').simulate('click'); await sleep(1); @@ -201,9 +278,20 @@ describe('', () => { /> ); }); - await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); expect(handleCancel).not.toHaveBeenCalled(); wrapper.find('button[aria-label="Cancel"]').invoke('onClick')(); expect(handleCancel).toBeCalled(); }); + + test('should display ContentError on throw', async () => { + CredentialTypesAPI.read = () => Promise.reject(new Error()); + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + expect(wrapper.find('ContentError').length).toBe(1); + }); }); From a8140e86d7f38fec3d2fc4dac13eeae0862c6b15 Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Wed, 30 Oct 2019 07:55:43 -0400 Subject: [PATCH 5/5] Encapsulate each scm type subform in its own component --- .../Project/ProjectList/ProjectListItem.jsx | 2 +- .../screens/Project/shared/ProjectForm.jsx | 677 ++++++------------ .../Project/shared/ProjectForm.test.jsx | 2 +- .../shared/ProjectSubForms/GitSubForm.jsx | 84 +++ .../shared/ProjectSubForms/HgSubForm.jsx | 44 ++ .../ProjectSubForms/InsightsSubForm.jsx | 42 ++ .../shared/ProjectSubForms/SharedFields.jsx | 135 ++++ .../shared/ProjectSubForms/SvnSubForm.jsx | 40 ++ .../Project/shared/ProjectSubForms/index.js | 5 + 9 files changed, 585 insertions(+), 446 deletions(-) create mode 100644 awx/ui_next/src/screens/Project/shared/ProjectSubForms/GitSubForm.jsx create mode 100644 awx/ui_next/src/screens/Project/shared/ProjectSubForms/HgSubForm.jsx create mode 100644 awx/ui_next/src/screens/Project/shared/ProjectSubForms/InsightsSubForm.jsx create mode 100644 awx/ui_next/src/screens/Project/shared/ProjectSubForms/SharedFields.jsx create mode 100644 awx/ui_next/src/screens/Project/shared/ProjectSubForms/SvnSubForm.jsx create mode 100644 awx/ui_next/src/screens/Project/shared/ProjectSubForms/index.js diff --git a/awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.jsx b/awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.jsx index 3df2871ac6..965c5d33af 100644 --- a/awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.jsx +++ b/awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.jsx @@ -91,7 +91,7 @@ class ProjectListItem extends React.Component { {project.name} diff --git a/awx/ui_next/src/screens/Project/shared/ProjectForm.jsx b/awx/ui_next/src/screens/Project/shared/ProjectForm.jsx index 7478954a5e..ea1e784848 100644 --- a/awx/ui_next/src/screens/Project/shared/ProjectForm.jsx +++ b/awx/ui_next/src/screens/Project/shared/ProjectForm.jsx @@ -1,30 +1,27 @@ import React, { useState, useEffect } from 'react'; import PropTypes from 'prop-types'; -import { withRouter, Link } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; -import { withFormik, Field } from 'formik'; +import { Formik, Field } from 'formik'; import { Config } from '@contexts/Config'; -import { - Form as _Form, - FormGroup, - Title as _Title, -} from '@patternfly/react-core'; +import { Form, FormGroup } from '@patternfly/react-core'; import AnsibleSelect from '@components/AnsibleSelect'; import ContentError from '@components/ContentError'; import ContentLoading from '@components/ContentLoading'; import FormActionGroup from '@components/FormActionGroup/FormActionGroup'; -import FormField, { CheckboxField, FieldTooltip } from '@components/FormField'; +import FormField, { FieldTooltip } from '@components/FormField'; import FormRow from '@components/FormRow'; import OrganizationLookup from '@components/Lookup/OrganizationLookup'; -import CredentialLookup from '@components/Lookup/CredentialLookup'; import { CredentialTypesAPI, ProjectsAPI } from '@api'; import { required } from '@util/validators'; import styled from 'styled-components'; - -const Form = styled(_Form)` - padding: 0 24px; -`; +import { + GitSubForm, + HgSubForm, + SvnSubForm, + InsightsSubForm, + SubFormTitle, +} from './ProjectSubForms'; const ScmTypeFormRow = styled(FormRow)` background-color: #f5f5f5; @@ -33,19 +30,10 @@ const ScmTypeFormRow = styled(FormRow)` padding: 24px; `; -const OptionsFormGroup = styled.div` - grid-column: 1/-1; -`; - -const Title = styled(_Title)` - --pf-c-title--m-md--FontWeight: 700; - grid-column: 1 / -1; -`; - function ProjectForm(props) { - const { values, handleCancel, handleSubmit, i18n } = props; + const { project, handleCancel, handleSubmit, i18n } = props; const [contentError, setContentError] = useState(null); - const [hasContentLoading, setHasContentLoading] = useState(true); + const [isLoading, setIsLoading] = useState(true); const [organization, setOrganization] = useState(null); const [scmTypeOptions, setScmTypeOptions] = useState(null); const [scmCredential, setScmCredential] = useState({ @@ -58,45 +46,48 @@ function ProjectForm(props) { }); useEffect(() => { - async function fetchCredTypeId(params) { - try { - const { - data: { - results: [credential], - }, - } = await CredentialTypesAPI.read(params); - return credential.id; - } catch (error) { - setContentError(error); - return null; - } - } - async function fetchData() { - const insightsTypeId = await fetchCredTypeId({ name: 'Insights' }); - const scmTypeId = await fetchCredTypeId({ kind: 'scm' }); - const { - data: { - actions: { - GET: { - scm_type: { choices }, + try { + const [ + { + data: { + results: [scmCredentialType], }, }, - }, - } = await ProjectsAPI.readOptions(); - setInsightsCredential({ typeId: insightsTypeId }); - setScmCredential({ typeId: scmTypeId }); - setScmTypeOptions(choices); - setHasContentLoading(false); + { + data: { + results: [insightsCredentialType], + }, + }, + { + data: { + actions: { + GET: { + scm_type: { choices }, + }, + }, + }, + }, + ] = await Promise.all([ + CredentialTypesAPI.read({ kind: 'scm' }), + CredentialTypesAPI.read({ name: 'Insights' }), + ProjectsAPI.readOptions(), + ]); + + setScmCredential({ typeId: scmCredentialType.id }); + setInsightsCredential({ typeId: insightsCredentialType.id }); + setScmTypeOptions(choices); + } catch (error) { + setContentError(error); + } finally { + setIsLoading(false); + } } fetchData(); }, []); - const resetScmTypeFields = (value, form) => { - if (form.initialValues.scm_type === value) { - return; - } + const resetScmTypeFields = form => { const scmFormFields = [ 'scm_url', 'scm_branch', @@ -115,63 +106,7 @@ function ProjectForm(props) { }); }; - const gitScmTooltip = ( - - {i18n._(t`Example URLs for GIT SCM include:`)} -
    -
  • https://github.com/ansible/ansible.git
  • -
  • git@github.com:ansible/ansible.git
  • -
  • git://servername.example.com/ansible.git
  • -
- - {i18n._(t`Note: When using SSH protocol for GitHub or - Bitbucket, enter an SSH key only, do not enter a username - (other than git). Additionally, GitHub and Bitbucket do - not support password authentication when using SSH. GIT - read only protocol (git://) does not use username or - password information.`)} -
- ); - - const hgScmTooltip = ( - - {i18n._(t`Example URLs for Mercurial SCM include:`)} -
    -
  • https://bitbucket.org/username/project
  • -
  • ssh://hg@bitbucket.org/username/project
  • -
  • ssh://server.example.com/path
  • -
- {i18n._(t`Note: Mercurial does not support password authentication - for SSH. Do not put the username and key in the URL. If using - Bitbucket and SSH, do not supply your Bitbucket username. - `)} -
- ); - - const svnScmTooltip = ( - - {i18n._(t`Example URLs for Subversion SCM include:`)} -
    -
  • https://github.com/ansible/ansible
  • -
  • svn://servername.example.com/path
  • -
  • svn+ssh://servername.example.com/path
  • -
-
- ); - - const scmUrlTooltips = { - git: gitScmTooltip, - hg: hgScmTooltip, - svn: svnScmTooltip, - }; - - const scmBranchLabels = { - git: i18n._(t`SCM Branch/Tag/Commit`), - hg: i18n._(t`SCM Branch/Tag/Revision`), - svn: i18n._(t`Revision #`), - }; - - if (hasContentLoading) { + if (isLoading) { return ; } @@ -180,343 +115,197 @@ function ProjectForm(props) { } return ( - - - - - ( - form.setFieldTouched('organization')} - onChange={value => { - form.setFieldValue('organization', value.id); - setOrganization(value); - }} - value={organization} - required - /> - )} - /> - ( - ( + + + - { - if (option[1] === 'Manual') { - option[0] = 'manual'; - } - return { - label: option[1], - value: option[0], - key: option[0], - }; - }), - ]} - onChange={(event, value) => { - form.setFieldValue('scm_type', value); - resetScmTypeFields(value, form); - }} - /> - - )} - /> - {values.scm_type !== '' && ( - - {i18n._(t`Type Details`)} - {(values.scm_type === 'git' || - values.scm_type === 'hg' || - values.scm_type === 'svn') && ( - - )} - {(values.scm_type === 'git' || - values.scm_type === 'hg' || - values.scm_type === 'svn') && ( - - )} - {values.scm_type === 'git' && ( - - {i18n._(t`A refspec to fetch (passed to the Ansible git - module). This parameter allows access to references via - the branch field not otherwise available.`)} -
-
- {i18n._( - t`Note: This field assumes the remote name is "origin".` - )} -
-
- {i18n._(t`Examples include:`)} -
    -
  • refs/*:refs/remotes/origin/*
  • -
  • - refs/pull/62/head:refs/remotes/origin/pull/62/head -
  • -
- {i18n._(t`The first fetches all references. The second - fetches the Github pull request number 62, in this example - the branch needs to be "pull/62/head".`)} -
-
- {i18n._(t`For more information, refer to the`)}{' '} - - {i18n._(t`Ansible Tower Documentation.`)} - - - } - /> - )} - {(values.scm_type === 'git' || - values.scm_type === 'hg' || - values.scm_type === 'svn') && ( - ( - { - form.setFieldValue('credential', credential.id); - setScmCredential({ - ...scmCredential, - value: credential, - }); - }} - /> - )} - /> - )} - {values.scm_type === 'insights' && ( - ( - form.setFieldTouched('credential')} - onChange={credential => { - form.setFieldValue('credential', credential.id); - setInsightsCredential({ - ...insightsCredential, - value: credential, - }); - }} - value={insightsCredential.value} - required - /> - )} - /> - )} - {/* - PF Bug: FormGroup doesn't pass down className - Workaround is to wrap FormGroup with an extra div - Cleanup when upgraded to @patternfly/react-core@3.103.4 - */} - {values.scm_type !== 'manual' && ( - - - - - - - {values.scm_type !== 'insights' && ( - - )} - - - - )} - {values.scm_type !== 'manual' && values.scm_update_on_launch && ( - <> - {i18n._(t`Option Details`)} - + + ( + form.setFieldTouched('organization')} + onChange={value => { + form.setFieldValue('organization', value.id); + setOrganization(value); + }} + value={organization} + required /> - + )} + /> + ( + + { + if (label === 'Manual') { + value = 'manual'; + } + return { + label, + value, + key: value, + }; + }), + ]} + onChange={(event, value) => { + form.setFieldValue('scm_type', value); + resetScmTypeFields(form); + }} + /> + + )} + /> + {formik.values.scm_type !== '' && ( + + {i18n._(t`Type Details`)} + { + { + git: ( + + ), + hg: ( + + ), + svn: ( + + ), + insights: ( + + ), + }[formik.values.scm_type] + } + )} -
- )} - - {({ custom_virtualenvs }) => - custom_virtualenvs && - custom_virtualenvs.length > 1 && ( - ( - - - datum !== '/venv/ansible/') - .map(datum => ({ - label: datum, - value: datum, - key: datum, - })), - ]} - {...field} - /> - - )} - /> - ) - } - -
- - + + {({ custom_virtualenvs }) => + custom_virtualenvs && + custom_virtualenvs.length > 1 && ( + ( + + + datum !== '/venv/ansible/') + .map(datum => ({ + label: datum, + value: datum, + key: datum, + })), + ]} + {...field} + /> + + )} + /> + ) + } + +
+ + + )} + /> ); } -const FormikApp = withFormik({ - mapPropsToValues(props) { - const { project = {} } = props; - - return { - credential: project.credential || '', - custom_virtualenv: project.custom_virtualenv || '', - description: project.description || '', - name: project.name || '', - organization: project.organization || '', - scm_branch: project.scm_branch || '', - scm_clean: project.scm_clean || false, - scm_delete_on_update: project.scm_delete_on_update || false, - scm_refspec: project.scm_refspec || '', - scm_type: project.scm_type || '', - scm_update_on_launch: project.scm_update_on_launch || false, - scm_url: project.scm_url || '', - scm_update_cache_timeout: project.scm_update_cache_timeout || 0, - allow_override: project.allow_override || false, - }; - }, - handleSubmit: (values, { props }) => props.handleSubmit(values), -})(ProjectForm); - ProjectForm.propTypes = { handleCancel: PropTypes.func.isRequired, handleSubmit: PropTypes.func.isRequired, @@ -527,4 +316,4 @@ ProjectForm.defaultProps = { project: {}, }; -export default withI18n()(withRouter(FormikApp)); +export default withI18n()(ProjectForm); diff --git a/awx/ui_next/src/screens/Project/shared/ProjectForm.test.jsx b/awx/ui_next/src/screens/Project/shared/ProjectForm.test.jsx index 00ee3fbf99..18b4c5f680 100644 --- a/awx/ui_next/src/screens/Project/shared/ProjectForm.test.jsx +++ b/awx/ui_next/src/screens/Project/shared/ProjectForm.test.jsx @@ -243,7 +243,7 @@ describe('', () => { }); wrapper.update(); await act(async () => { - scmTypeSelect.props().onChange('git', { target: { name: 'insights' } }); + scmTypeSelect.props().onChange('svn', { target: { name: 'Subversion' } }); }); wrapper.update(); expect(formik.state.values.scm_url).toEqual(''); diff --git a/awx/ui_next/src/screens/Project/shared/ProjectSubForms/GitSubForm.jsx b/awx/ui_next/src/screens/Project/shared/ProjectSubForms/GitSubForm.jsx new file mode 100644 index 0000000000..6bcbb83c48 --- /dev/null +++ b/awx/ui_next/src/screens/Project/shared/ProjectSubForms/GitSubForm.jsx @@ -0,0 +1,84 @@ +import React from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import FormField from '@components/FormField'; +import { + UrlFormField, + BranchFormField, + ScmCredentialFormField, + ScmTypeOptions, +} from './SharedFields'; + +const GitSubForm = ({ + i18n, + scmCredential, + setScmCredential, + scmUpdateOnLaunch, +}) => ( + <> + + {i18n._(t`Example URLs for GIT SCM include:`)} +
    +
  • https://github.com/ansible/ansible.git
  • +
  • git@github.com:ansible/ansible.git
  • +
  • git://servername.example.com/ansible.git
  • +
+ {i18n._(t`Note: When using SSH protocol for GitHub or + Bitbucket, enter an SSH key only, do not enter a username + (other than git). Additionally, GitHub and Bitbucket do + not support password authentication when using SSH. GIT + read only protocol (git://) does not use username or + password information.`)} +
+ } + /> + + + {i18n._(t`A refspec to fetch (passed to the Ansible git + module). This parameter allows access to references via + the branch field not otherwise available.`)} +
+
+ {i18n._(t`Note: This field assumes the remote name is "origin".`)} +
+
+ {i18n._(t`Examples include:`)} +
    +
  • refs/*:refs/remotes/origin/*
  • +
  • refs/pull/62/head:refs/remotes/origin/pull/62/head
  • +
+ {i18n._(t`The first fetches all references. The second + fetches the Github pull request number 62, in this example + the branch needs to be "pull/62/head".`)} +
+
+ {i18n._(t`For more information, refer to the`)}{' '} + + {i18n._(t`Ansible Tower Documentation.`)} + + + } + /> + + + +); + +export default withI18n()(GitSubForm); diff --git a/awx/ui_next/src/screens/Project/shared/ProjectSubForms/HgSubForm.jsx b/awx/ui_next/src/screens/Project/shared/ProjectSubForms/HgSubForm.jsx new file mode 100644 index 0000000000..6cc642686e --- /dev/null +++ b/awx/ui_next/src/screens/Project/shared/ProjectSubForms/HgSubForm.jsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { + UrlFormField, + BranchFormField, + ScmCredentialFormField, + ScmTypeOptions, +} from './SharedFields'; + +const HgSubForm = ({ + i18n, + scmCredential, + setScmCredential, + scmUpdateOnLaunch, +}) => ( + <> + + {i18n._(t`Example URLs for Mercurial SCM include:`)} +
    +
  • https://bitbucket.org/username/project
  • +
  • ssh://hg@bitbucket.org/username/project
  • +
  • ssh://server.example.com/path
  • +
+ {i18n._(t`Note: Mercurial does not support password authentication + for SSH. Do not put the username and key in the URL. If using + Bitbucket and SSH, do not supply your Bitbucket username. + `)} + + } + /> + + + + +); + +export default withI18n()(HgSubForm); diff --git a/awx/ui_next/src/screens/Project/shared/ProjectSubForms/InsightsSubForm.jsx b/awx/ui_next/src/screens/Project/shared/ProjectSubForms/InsightsSubForm.jsx new file mode 100644 index 0000000000..4e854c8b20 --- /dev/null +++ b/awx/ui_next/src/screens/Project/shared/ProjectSubForms/InsightsSubForm.jsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Field } from 'formik'; +import CredentialLookup from '@components/Lookup/CredentialLookup'; +import { required } from '@util/validators'; +import { ScmTypeOptions } from './SharedFields'; + +const InsightsSubForm = ({ + i18n, + setInsightsCredential, + insightsCredential, + scmUpdateOnLaunch, +}) => ( + <> + ( + form.setFieldTouched('credential')} + onChange={credential => { + form.setFieldValue('credential', credential.id); + setInsightsCredential({ + ...insightsCredential, + value: credential, + }); + }} + value={insightsCredential.value} + required + /> + )} + /> + + +); + +export default withI18n()(InsightsSubForm); diff --git a/awx/ui_next/src/screens/Project/shared/ProjectSubForms/SharedFields.jsx b/awx/ui_next/src/screens/Project/shared/ProjectSubForms/SharedFields.jsx new file mode 100644 index 0000000000..40b1dce826 --- /dev/null +++ b/awx/ui_next/src/screens/Project/shared/ProjectSubForms/SharedFields.jsx @@ -0,0 +1,135 @@ +import React from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Field } from 'formik'; +import CredentialLookup from '@components/Lookup/CredentialLookup'; +import FormField, { CheckboxField } from '@components/FormField'; +import { required } from '@util/validators'; +import FormRow from '@components/FormRow'; +import { FormGroup, Title } from '@patternfly/react-core'; +import styled from 'styled-components'; + +export const SubFormTitle = styled(Title)` + --pf-c-title--m-md--FontWeight: 700; + grid-column: 1 / -1; +`; + +export const UrlFormField = withI18n()(({ i18n, tooltip }) => ( + +)); + +export const BranchFormField = withI18n()(({ i18n, label }) => ( + +)); + +export const ScmCredentialFormField = withI18n()( + ({ i18n, setScmCredential, scmCredential }) => ( + ( + { + form.setFieldValue('credential', credential.id); + setScmCredential({ + ...scmCredential, + value: credential, + }); + }} + /> + )} + /> + ) +); + +export const ScmTypeOptions = withI18n()( + ({ i18n, scmUpdateOnLaunch, hideAllowOverride }) => ( + <> + + + + + + {!hideAllowOverride && ( + + )} + + + {scmUpdateOnLaunch && ( + <> + {i18n._(t`Option Details`)} + + + )} + + ) +); diff --git a/awx/ui_next/src/screens/Project/shared/ProjectSubForms/SvnSubForm.jsx b/awx/ui_next/src/screens/Project/shared/ProjectSubForms/SvnSubForm.jsx new file mode 100644 index 0000000000..9f7e9a4659 --- /dev/null +++ b/awx/ui_next/src/screens/Project/shared/ProjectSubForms/SvnSubForm.jsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { + UrlFormField, + BranchFormField, + ScmCredentialFormField, + ScmTypeOptions, +} from './SharedFields'; + +const SvnSubForm = ({ + i18n, + scmCredential, + setScmCredential, + scmUpdateOnLaunch, +}) => ( + <> + + {i18n._(t`Example URLs for Subversion SCM include:`)} +
    +
  • https://github.com/ansible/ansible
  • +
  • svn://servername.example.com/path
  • +
  • svn+ssh://servername.example.com/path
  • +
+ + } + /> + + + + +); + +export default withI18n()(SvnSubForm); diff --git a/awx/ui_next/src/screens/Project/shared/ProjectSubForms/index.js b/awx/ui_next/src/screens/Project/shared/ProjectSubForms/index.js new file mode 100644 index 0000000000..9443f17054 --- /dev/null +++ b/awx/ui_next/src/screens/Project/shared/ProjectSubForms/index.js @@ -0,0 +1,5 @@ +export { default as GitSubForm } from './GitSubForm'; +export { default as HgSubForm } from './HgSubForm'; +export { default as SvnSubForm } from './SvnSubForm'; +export { default as InsightsSubForm } from './InsightsSubForm'; +export { SubFormTitle } from './SharedFields';