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(); + }); +});