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/components/Lookup/CredentialLookup.jsx b/awx/ui_next/src/components/Lookup/CredentialLookup.jsx new file mode 100644 index 0000000000..6872e09784 --- /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, oneOfType } 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: oneOfType([number, string]).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/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/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(); + }); +}); 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..2b9857bc89 --- /dev/null +++ b/awx/ui_next/src/screens/Project/ProjectAdd/ProjectAdd.test.jsx @@ -0,0 +1,161 @@ +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, CredentialTypesAPI } from '@api'; + +jest.mock('@api'); + +describe('', () => { + let wrapper; + 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', + }; + + 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', async () => { + await act(async () => { + wrapper = mountWithContexts(); + }); + expect(wrapper.length).toBe(1); + }); + + test('handleSubmit should post to the api', async () => { + ProjectsAPI.create.mockResolvedValueOnce({ + data: { ...projectData }, + }); + 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( + { + values: { + ...projectData, + }, + }, + () => resolve() + ); + }); + await changeState; + wrapper.find('form').simulate('submit'); + }); + + test('handleSubmit should throw an error', async () => { + ProjectsAPI.create.mockImplementation(() => Promise.reject(new Error())); + 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( + { + 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', async () => { + const history = createMemoryHistory(); + 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', async () => { + const history = createMemoryHistory(); + 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/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}` 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 new file mode 100644 index 0000000000..ea1e784848 --- /dev/null +++ b/awx/ui_next/src/screens/Project/shared/ProjectForm.jsx @@ -0,0 +1,319 @@ +import React, { useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Formik, Field } from 'formik'; +import { Config } from '@contexts/Config'; +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, { FieldTooltip } from '@components/FormField'; +import FormRow from '@components/FormRow'; +import OrganizationLookup from '@components/Lookup/OrganizationLookup'; +import { CredentialTypesAPI, ProjectsAPI } from '@api'; +import { required } from '@util/validators'; +import styled from 'styled-components'; +import { + GitSubForm, + HgSubForm, + SvnSubForm, + InsightsSubForm, + SubFormTitle, +} from './ProjectSubForms'; + +const ScmTypeFormRow = styled(FormRow)` + background-color: #f5f5f5; + grid-column: 1 / -1; + margin: 0 -24px; + padding: 24px; +`; + +function ProjectForm(props) { + const { project, handleCancel, handleSubmit, i18n } = props; + const [contentError, setContentError] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [organization, setOrganization] = 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 fetchData() { + try { + const [ + { + data: { + results: [scmCredentialType], + }, + }, + { + 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 = form => { + 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); + }); + }; + + if (isLoading) { + return ; + } + + if (contentError) { + return ; + } + + return ( + ( +
+ + + + ( + 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} + /> + + )} + /> + ) + } + + + + + )} + /> + ); +} + +ProjectForm.propTypes = { + handleCancel: PropTypes.func.isRequired, + handleSubmit: PropTypes.func.isRequired, + project: PropTypes.shape({}), +}; + +ProjectForm.defaultProps = { + project: {}, +}; + +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 new file mode 100644 index 0000000000..18b4c5f680 --- /dev/null +++ b/awx/ui_next/src/screens/Project/shared/ProjectForm.test.jsx @@ -0,0 +1,297 @@ +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'; +import { CredentialTypesAPI, ProjectsAPI } from '@api'; + +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', + }; + + 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', async () => { + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + + expect(wrapper.find('ProjectForm').length).toBe(1); + }); + + 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); + 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 () => { + 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( + { + 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( + + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + 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 () => { + 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( + { + 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( + + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + + 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('svn', { target: { name: 'Subversion' } }); + }); + 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, 'ContentLoading', 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, '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); + }); +}); 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';