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