diff --git a/awx/ui_next/src/components/LaunchButton/LaunchButton.jsx b/awx/ui_next/src/components/LaunchButton/LaunchButton.jsx index a832a929f8..a8322d86c5 100644 --- a/awx/ui_next/src/components/LaunchButton/LaunchButton.jsx +++ b/awx/ui_next/src/components/LaunchButton/LaunchButton.jsx @@ -1,4 +1,4 @@ -import React, { Fragment } from 'react'; +import React, { Fragment, useState } from 'react'; import { withRouter } from 'react-router-dom'; import { number, shape } from 'prop-types'; import { withI18n } from '@lingui/react'; @@ -32,40 +32,12 @@ function canLaunchWithoutPrompt(launchData) { ); } -class LaunchButton extends React.Component { - static propTypes = { - resource: shape({ - id: number.isRequired, - }).isRequired, - }; - - constructor(props) { - super(props); - - this.state = { - showLaunchPrompt: false, - launchConfig: null, - launchError: false, - surveyConfig: null, - }; - - this.handleLaunch = this.handleLaunch.bind(this); - this.launchWithParams = this.launchWithParams.bind(this); - this.handleRelaunch = this.handleRelaunch.bind(this); - this.handleLaunchErrorClose = this.handleLaunchErrorClose.bind(this); - this.handlePromptErrorClose = this.handlePromptErrorClose.bind(this); - } - - handleLaunchErrorClose() { - this.setState({ launchError: null }); - } - - handlePromptErrorClose() { - this.setState({ showLaunchPrompt: false }); - } - - async handleLaunch() { - const { resource } = this.props; +function LaunchButton({ resource, i18n, children, history }) { + const [showLaunchPrompt, setShowLaunchPrompt] = useState(false); + const [launchConfig, setLaunchConfig] = useState(null); + const [surveyConfig, setSurveyConfig] = useState(null); + const [error, setError] = useState(null); + const handleLaunch = async () => { const readLaunch = resource.type === 'workflow_job_template' ? WorkflowJobTemplatesAPI.readLaunch(resource.id) @@ -75,33 +47,27 @@ class LaunchButton extends React.Component { ? WorkflowJobTemplatesAPI.readSurvey(resource.id) : JobTemplatesAPI.readSurvey(resource.id); try { - const { data: launchConfig } = await readLaunch; + const { data: launch } = await readLaunch; + setLaunchConfig(launch); - let surveyConfig = null; - - if (launchConfig.survey_enabled) { + if (launch.survey_enabled) { const { data } = await readSurvey; - surveyConfig = data; + setSurveyConfig(data); } - if (canLaunchWithoutPrompt(launchConfig)) { - this.launchWithParams({}); + if (canLaunchWithoutPrompt(launch)) { + launchWithParams({}); } else { - this.setState({ - showLaunchPrompt: true, - launchConfig, - surveyConfig, - }); + setShowLaunchPrompt(true); } } catch (err) { - this.setState({ launchError: err }); + setError(err); } - } + }; - async launchWithParams(params) { + const launchWithParams = async params => { try { - const { history, resource } = this.props; let jobPromise; if (resource.type === 'job_template') { @@ -117,13 +83,11 @@ class LaunchButton extends React.Component { const { data: job } = await jobPromise; history.push(`/jobs/${job.id}/output`); } catch (launchError) { - this.setState({ launchError }); + setError(launchError); } - } - - async handleRelaunch() { - const { history, resource } = this.props; + }; + const handleRelaunch = async () => { let readRelaunch; let relaunch; @@ -145,6 +109,7 @@ class LaunchButton extends React.Component { try { const { data: relaunchConfig } = await readRelaunch; + setLaunchConfig(relaunchConfig); if ( !relaunchConfig.passwords_needed_to_start || relaunchConfig.passwords_needed_to_start.length === 0 @@ -165,53 +130,47 @@ class LaunchButton extends React.Component { const { data: job } = await relaunch; history.push(`/jobs/${job.id}/output`); } else { - this.setState({ - showLaunchPrompt: true, - launchConfig: relaunchConfig, - }); + setShowLaunchPrompt(true); } } catch (err) { - this.setState({ launchError: err }); + setError(err); } - } + }; - render() { - const { - launchError, - showLaunchPrompt, - launchConfig, - surveyConfig, - } = this.state; - const { resource, i18n, children } = this.props; - return ( - - {children({ - handleLaunch: this.handleLaunch, - handleRelaunch: this.handleRelaunch, - })} - {launchError && ( - - {i18n._(t`Failed to launch job.`)} - - - )} - {showLaunchPrompt && ( - this.setState({ showLaunchPrompt: false })} - /> - )} - - ); - } + return ( + + {children({ + handleLaunch, + handleRelaunch, + })} + {error && ( + setError(null)} + > + {i18n._(t`Failed to launch job.`)} + + + )} + {showLaunchPrompt && ( + setShowLaunchPrompt(false)} + /> + )} + + ); } +LaunchButton.propTypes = { + resource: shape({ + id: number.isRequired, + }).isRequired, +}; + export default withI18n()(withRouter(LaunchButton)); diff --git a/awx/ui_next/src/components/LaunchButton/LaunchButton.test.jsx b/awx/ui_next/src/components/LaunchButton/LaunchButton.test.jsx index c84c7fde4d..4d4ce3ac2e 100644 --- a/awx/ui_next/src/components/LaunchButton/LaunchButton.test.jsx +++ b/awx/ui_next/src/components/LaunchButton/LaunchButton.test.jsx @@ -1,5 +1,6 @@ import React from 'react'; import { createMemoryHistory } from 'history'; +import { act } from 'react-dom/test-utils'; import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; import { sleep } from '../../../testUtils/testUtils'; @@ -69,7 +70,7 @@ describe('LaunchButton', () => { } ); const button = wrapper.find('button'); - button.prop('onClick')(); + await act(() => button.prop('onClick')()); expect(JobTemplatesAPI.readLaunch).toHaveBeenCalledWith(1); await sleep(0); expect(JobTemplatesAPI.launch).toHaveBeenCalledWith(1, {}); @@ -106,7 +107,7 @@ describe('LaunchButton', () => { } ); const button = wrapper.find('button'); - button.prop('onClick')(); + await act(() => button.prop('onClick')()); expect(WorkflowJobTemplatesAPI.readLaunch).toHaveBeenCalledWith(1); await sleep(0); expect(WorkflowJobTemplatesAPI.launch).toHaveBeenCalledWith(1, {}); @@ -143,7 +144,7 @@ describe('LaunchButton', () => { } ); const button = wrapper.find('button'); - button.prop('onClick')(); + await act(() => button.prop('onClick')()); expect(JobsAPI.readRelaunch).toHaveBeenCalledWith(1); await sleep(0); expect(JobsAPI.relaunch).toHaveBeenCalledWith(1); @@ -180,7 +181,7 @@ describe('LaunchButton', () => { } ); const button = wrapper.find('button'); - button.prop('onClick')(); + await act(() => button.prop('onClick')()); expect(WorkflowJobsAPI.readRelaunch).toHaveBeenCalledWith(1); await sleep(0); expect(WorkflowJobsAPI.relaunch).toHaveBeenCalledWith(1); @@ -218,7 +219,7 @@ describe('LaunchButton', () => { } ); const button = wrapper.find('button'); - button.prop('onClick')(); + await act(() => button.prop('onClick')()); expect(ProjectsAPI.readLaunchUpdate).toHaveBeenCalledWith(5); await sleep(0); expect(ProjectsAPI.launchUpdate).toHaveBeenCalledWith(5); @@ -256,7 +257,7 @@ describe('LaunchButton', () => { } ); const button = wrapper.find('button'); - button.prop('onClick')(); + await act(() => button.prop('onClick')()); expect(InventorySourcesAPI.readLaunchUpdate).toHaveBeenCalledWith(5); await sleep(0); expect(InventorySourcesAPI.launchUpdate).toHaveBeenCalledWith(5); @@ -280,7 +281,7 @@ describe('LaunchButton', () => { }) ); expect(wrapper.find('Modal').length).toBe(0); - wrapper.find('button').prop('onClick')(); + await act(() => wrapper.find('button').prop('onClick')()); await sleep(0); wrapper.update(); expect(wrapper.find('Modal').length).toBe(1); diff --git a/awx/ui_next/src/components/Lookup/Lookup.jsx b/awx/ui_next/src/components/Lookup/Lookup.jsx index d6dea5e49e..197981678d 100644 --- a/awx/ui_next/src/components/Lookup/Lookup.jsx +++ b/awx/ui_next/src/components/Lookup/Lookup.jsx @@ -123,6 +123,7 @@ function Lookup(props) { + {i18n._(t`Select`)} , - , ]} diff --git a/awx/ui_next/src/components/StatusLabel/StatusLabel.jsx b/awx/ui_next/src/components/StatusLabel/StatusLabel.jsx index bd0466f5ce..9074deda1a 100644 --- a/awx/ui_next/src/components/StatusLabel/StatusLabel.jsx +++ b/awx/ui_next/src/components/StatusLabel/StatusLabel.jsx @@ -1,7 +1,7 @@ import 'styled-components/macro'; import React from 'react'; import { oneOf } from 'prop-types'; -import { Label } from '@patternfly/react-core'; +import { Label, Tooltip } from '@patternfly/react-core'; import { CheckCircleIcon, ExclamationCircleIcon, @@ -48,15 +48,19 @@ const icons = { canceled: ExclamationTriangleIcon, }; -export default function StatusLabel({ status }) { +export default function StatusLabel({ status, tooltipContent = '' }) { const label = status.charAt(0).toUpperCase() + status.slice(1); const color = colors[status] || 'grey'; const Icon = icons[status]; return ( - + <> + + + + ); } diff --git a/awx/ui_next/src/screens/Credential/CredentialAdd/CredentialAdd.jsx b/awx/ui_next/src/screens/Credential/CredentialAdd/CredentialAdd.jsx index c5df022772..ecffa873c1 100644 --- a/awx/ui_next/src/screens/Credential/CredentialAdd/CredentialAdd.jsx +++ b/awx/ui_next/src/screens/Credential/CredentialAdd/CredentialAdd.jsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState, useEffect } from 'react'; +import React, { useCallback, useEffect } from 'react'; import { useHistory } from 'react-router-dom'; import { PageSection, Card } from '@patternfly/react-core'; import { CardBody } from '../../../components/Card'; @@ -14,9 +14,6 @@ import CredentialForm from '../shared/CredentialForm'; import useRequest from '../../../util/useRequest'; function CredentialAdd({ me }) { - const [error, setError] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [credentialTypes, setCredentialTypes] = useState(null); const history = useHistory(); const { @@ -85,34 +82,38 @@ function CredentialAdd({ me }) { history.push(`/credentials/${credentialId}/details`); } }, [credentialId, history]); - - useEffect(() => { - const loadData = async () => { - try { + const { isLoading, error, request: loadData, result } = useRequest( + useCallback(async () => { + const { data } = await CredentialTypesAPI.read({ page_size: 200 }); + const credTypes = data.results; + if (data.next && data.next.includes('page=2')) { const { - data: { results: loadedCredentialTypes }, - } = await CredentialTypesAPI.read(); - setCredentialTypes( - loadedCredentialTypes.reduce((credentialTypesMap, credentialType) => { - credentialTypesMap[credentialType.id] = credentialType; - return credentialTypesMap; - }, {}) - ); - } catch (err) { - setError(err); - } finally { - setIsLoading(false); + data: { results }, + } = await CredentialTypesAPI.read({ + page_size: 200, + page: 2, + }); + credTypes.concat(results); } - }; + + const creds = credTypes.reduce((credentialTypesMap, credentialType) => { + credentialTypesMap[credentialType.id] = credentialType; + return credentialTypesMap; + }, {}); + return creds; + }, []), + {} + ); + useEffect(() => { loadData(); - }, []); + }, [loadData]); const handleCancel = () => { history.push('/credentials'); }; const handleSubmit = async values => { - await submitRequest(values, credentialTypes); + await submitRequest(values, result); }; if (error) { @@ -126,7 +127,7 @@ function CredentialAdd({ me }) { ); } - if (isLoading) { + if (isLoading && !result) { return ( @@ -144,7 +145,7 @@ function CredentialAdd({ me }) { diff --git a/awx/ui_next/src/screens/Credential/CredentialEdit/CredentialEdit.jsx b/awx/ui_next/src/screens/Credential/CredentialEdit/CredentialEdit.jsx index 83e64e81f8..9ffc29f39b 100644 --- a/awx/ui_next/src/screens/Credential/CredentialEdit/CredentialEdit.jsx +++ b/awx/ui_next/src/screens/Credential/CredentialEdit/CredentialEdit.jsx @@ -1,5 +1,5 @@ -import React, { useCallback, useState, useEffect } from 'react'; -import { useHistory } from 'react-router-dom'; +import React, { useCallback, useEffect } from 'react'; +import { useHistory, useParams } from 'react-router-dom'; import { object } from 'prop-types'; import { CardBody } from '../../../components/Card'; import { @@ -13,11 +13,8 @@ import CredentialForm from '../shared/CredentialForm'; import useRequest from '../../../util/useRequest'; function CredentialEdit({ credential, me }) { - const [error, setError] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [credentialTypes, setCredentialTypes] = useState(null); - const [inputSources, setInputSources] = useState({}); const history = useHistory(); + const { id: credId } = useParams(); const { error: submitError, request: submitRequest, result } = useRequest( useCallback( @@ -55,7 +52,7 @@ function CredentialEdit({ credential, me }) { input_field_name: fieldName, metadata: fieldValue.inputs, source_credential: fieldValue.credential.id, - target_credential: credential.id, + target_credential: credId, }); } if (fieldValue.touched) { @@ -88,7 +85,7 @@ function CredentialEdit({ credential, me }) { modifiedData.user = me.id; } const [{ data }] = await Promise.all([ - CredentialsAPI.update(credential.id, modifiedData), + CredentialsAPI.update(credId, modifiedData), ...destroyInputSources(), ]); @@ -96,7 +93,7 @@ function CredentialEdit({ credential, me }) { return data; }, - [me, credential.id] + [me, credId] ) ); @@ -105,56 +102,63 @@ function CredentialEdit({ credential, me }) { history.push(`/credentials/${result.id}/details`); } }, [result, history]); + const { + isLoading, + error, + request: loadData, + result: { credentialTypes, loadedInputSources }, + } = useRequest( + useCallback(async () => { + const [ + { data }, + { + data: { results }, + }, + ] = await Promise.all([ + CredentialTypesAPI.read({ page_size: 200 }), + CredentialsAPI.readInputSources(credId, { page_size: 200 }), + ]); + const credTypes = data.results; + if (data.next && data.next.includes('page=2')) { + const { + data: { results: additionalCredTypes }, + } = await CredentialTypesAPI.read({ + page_size: 200, + page: 2, + }); + credTypes.concat([...additionalCredTypes]); + } + const creds = credTypes.reduce((credentialTypesMap, credentialType) => { + credentialTypesMap[credentialType.id] = credentialType; + return credentialTypesMap; + }, {}); + const inputSources = results.reduce((inputSourcesMap, inputSource) => { + inputSourcesMap[inputSource.input_field_name] = inputSource; + return inputSourcesMap; + }, {}); + return { credentialTypes: creds, loadedInputSources: inputSources }; + }, [credId]), + { credentialTypes: {}, loadedInputSources: {} } + ); useEffect(() => { - const loadData = async () => { - try { - const [ - { - data: { results: loadedCredentialTypes }, - }, - { - data: { results: loadedInputSources }, - }, - ] = await Promise.all([ - CredentialTypesAPI.read(), - CredentialsAPI.readInputSources(credential.id, { page_size: 200 }), - ]); - setCredentialTypes( - loadedCredentialTypes.reduce((credentialTypesMap, credentialType) => { - credentialTypesMap[credentialType.id] = credentialType; - return credentialTypesMap; - }, {}) - ); - setInputSources( - loadedInputSources.reduce((inputSourcesMap, inputSource) => { - inputSourcesMap[inputSource.input_field_name] = inputSource; - return inputSourcesMap; - }, {}) - ); - } catch (err) { - setError(err); - } finally { - setIsLoading(false); - } - }; loadData(); - }, [credential.id]); + }, [loadData]); const handleCancel = () => { - const url = `/credentials/${credential.id}/details`; + const url = `/credentials/${credId}/details`; history.push(`${url}`); }; const handleSubmit = async values => { - await submitRequest(values, credentialTypes, inputSources); + await submitRequest(values, credentialTypes, loadedInputSources); }; if (error) { return ; } - if (isLoading) { + if (isLoading && !credentialTypes) { return ; } @@ -165,7 +169,7 @@ function CredentialEdit({ credential, me }) { onSubmit={handleSubmit} credential={credential} credentialTypes={credentialTypes} - inputSources={inputSources} + inputSources={loadedInputSources} submitError={submitError} /> diff --git a/awx/ui_next/src/screens/Credential/CredentialEdit/CredentialEdit.test.jsx b/awx/ui_next/src/screens/Credential/CredentialEdit/CredentialEdit.test.jsx index ac85aa7939..17ab9d3ed4 100644 --- a/awx/ui_next/src/screens/Credential/CredentialEdit/CredentialEdit.test.jsx +++ b/awx/ui_next/src/screens/Credential/CredentialEdit/CredentialEdit.test.jsx @@ -14,6 +14,12 @@ import { import CredentialEdit from './CredentialEdit'; jest.mock('../../../api'); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ + id: 3, + }), +})); const mockCredential = { id: 3, diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialForm.jsx b/awx/ui_next/src/screens/Credential/shared/CredentialForm.jsx index c352b0b7e0..b46ddf8573 100644 --- a/awx/ui_next/src/screens/Credential/shared/CredentialForm.jsx +++ b/awx/ui_next/src/screens/Credential/shared/CredentialForm.jsx @@ -3,26 +3,41 @@ import { Formik, useField, useFormikContext } from 'formik'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { arrayOf, func, object, shape } from 'prop-types'; -import { ActionGroup, Button, Form, FormGroup } from '@patternfly/react-core'; +import { + ActionGroup, + Button, + Form, + FormGroup, + Select as PFSelect, + SelectOption as PFSelectOption, + SelectVariant, +} from '@patternfly/react-core'; +import styled from 'styled-components'; import FormField, { FormSubmitError } from '../../../components/FormField'; import { FormColumnLayout, FormFullWidthLayout, } from '../../../components/FormLayout'; -import AnsibleSelect from '../../../components/AnsibleSelect'; import { required } from '../../../util/validators'; import OrganizationLookup from '../../../components/Lookup/OrganizationLookup'; import TypeInputsSubForm from './TypeInputsSubForm'; import ExternalTestModal from './ExternalTestModal'; -function CredentialFormFields({ - i18n, - credentialTypes, - formik, - initialValues, -}) { - const { setFieldValue } = useFormikContext(); +const Select = styled(PFSelect)` + ul { + max-width: 495px; + } +`; +const SelectOption = styled(PFSelectOption)` + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; +`; + +function CredentialFormFields({ i18n, credentialTypes }) { + const { setFieldValue, initialValues, setFieldTouched } = useFormikContext(); + const [isSelectOpen, setIsSelectOpen] = useState(false); const [credTypeField, credTypeMeta, credTypeHelpers] = useField({ name: 'credential_type', validate: required(i18n._(t`Select a value for this field`), i18n), @@ -30,7 +45,7 @@ function CredentialFormFields({ const isGalaxyCredential = !!credTypeField.value && - credentialTypes[credTypeField.value].kind === 'galaxy'; + credentialTypes[credTypeField.value]?.kind === 'galaxy'; const [orgField, orgMeta, orgHelpers] = useField({ name: 'organization', @@ -52,16 +67,14 @@ function CredentialFormFields({ }) .sort((a, b) => (a.label.toLowerCase() > b.label.toLowerCase() ? 1 : -1)); - const resetSubFormFields = (newCredentialType, form) => { + const resetSubFormFields = newCredentialType => { const fields = credentialTypes[newCredentialType].inputs.fields || []; fields.forEach( ({ ask_at_runtime, type, id, choices, default: defaultValue }) => { - if ( - parseInt(newCredentialType, 10) === form.initialValues.credential_type - ) { - form.setFieldValue(`inputs.${id}`, initialValues.inputs[id]); + if (parseInt(newCredentialType, 10) === initialValues.credential_type) { + setFieldValue(`inputs.${id}`, initialValues.inputs[id]); if (ask_at_runtime) { - form.setFieldValue( + setFieldValue( `passwordPrompts.${id}`, initialValues.passwordPrompts[id] ); @@ -69,24 +82,24 @@ function CredentialFormFields({ } else { switch (type) { case 'string': - form.setFieldValue(`inputs.${id}`, defaultValue || ''); + setFieldValue(`inputs.${id}`, defaultValue || ''); break; case 'boolean': - form.setFieldValue(`inputs.${id}`, defaultValue || false); + setFieldValue(`inputs.${id}`, defaultValue || false); break; default: break; } if (choices) { - form.setFieldValue(`inputs.${id}`, defaultValue); + setFieldValue(`inputs.${id}`, defaultValue); } if (ask_at_runtime) { - form.setFieldValue(`passwordPrompts.${id}`, false); + setFieldValue(`passwordPrompts.${id}`, false); } } - form.setFieldTouched(`inputs.${id}`, false); + setFieldTouched(`inputs.${id}`, false); } ); }; @@ -133,23 +146,29 @@ function CredentialFormFields({ } label={i18n._(t`Credential Type`)} > - { + {credTypeField.value !== undefined && credTypeField.value !== '' && @@ -177,7 +196,7 @@ function CredentialForm({ name: credential.name || '', description: credential.description || '', organization: credential?.summary_fields?.organization || null, - credential_type: credential.credential_type || '', + credential_type: credential?.credential_type || '', inputs: {}, passwordPrompts: {}, }; @@ -235,8 +254,6 @@ function CredentialForm({
', () => { test('should display cred type subform when scm type select has a value', async () => { await act(async () => { await wrapper - .find('AnsibleSelect[id="credential-type"]') - .invoke('onChange')(null, 1); + .find('Select[aria-label="Credential Type"]') + .invoke('onToggle')(); }); wrapper.update(); + await act(async () => { + await wrapper + .find('Select[aria-label="Credential Type"]') + .invoke('onSelect')(null, 1); + }); + wrapper.update(); + machineFieldExpects(); await act(async () => { await wrapper - .find('AnsibleSelect[id="credential-type"]') - .invoke('onChange')(null, 2); + .find('Select[aria-label="Credential Type"]') + .invoke('onToggle')(); + }); + wrapper.update(); + await act(async () => { + await wrapper + .find('Select[aria-label="Credential Type"]') + .invoke('onSelect')(null, 2); }); wrapper.update(); sourceFieldExpects(); @@ -154,8 +167,14 @@ describe('', () => { test('should update expected fields when gce service account json file uploaded', async () => { await act(async () => { await wrapper - .find('AnsibleSelect[id="credential-type"]') - .invoke('onChange')(null, 10); + .find('Select[aria-label="Credential Type"]') + .invoke('onToggle')(); + }); + wrapper.update(); + await act(async () => { + await wrapper + .find('Select[aria-label="Credential Type"]') + .invoke('onSelect')(null, 10); }); wrapper.update(); gceFieldExpects(); @@ -215,8 +234,14 @@ describe('', () => { test('should show error when error thrown parsing JSON', async () => { await act(async () => { await wrapper - .find('AnsibleSelect[id="credential-type"]') - .invoke('onChange')(null, 10); + .find('Select[aria-label="Credential Type"]') + .invoke('onToggle')(); + }); + wrapper.update(); + await act(async () => { + await wrapper + .find('Select[aria-label="Credential Type"]') + .invoke('onSelect')(null, 10); }); wrapper.update(); expect(wrapper.find('#credential-gce-file-helper').text()).toBe( @@ -246,8 +271,14 @@ describe('', () => { test('should show Test button when external credential type is selected', async () => { await act(async () => { await wrapper - .find('AnsibleSelect[id="credential-type"]') - .invoke('onChange')(null, 21); + .find('Select[aria-label="Credential Type"]') + .invoke('onToggle')(); + }); + wrapper.update(); + await act(async () => { + await wrapper + .find('Select[aria-label="Credential Type"]') + .invoke('onSelect')(null, 21); }); wrapper.update(); expect(wrapper.find('Button[children="Test"]').length).toBe(1); diff --git a/awx/ui_next/src/screens/Inventory/InventoryList/InventoryListItem.jsx b/awx/ui_next/src/screens/Inventory/InventoryList/InventoryListItem.jsx index 91f7681f98..099fd78767 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryList/InventoryListItem.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryList/InventoryListItem.jsx @@ -55,6 +55,19 @@ function InventoryListItem({ inventory.inventory_sources_with_failures > 0 ? 'error' : 'success'; } + let tooltipContent = ''; + if (inventory.has_inventory_sources) { + if (inventory.inventory_sources_with_failures > 0) { + tooltipContent = i18n._( + t`${inventory.inventory_sources_with_failures} sources with sync failures.` + ); + } else { + tooltipContent = i18n._(t`No inventory sync failures.`); + } + } else { + tooltipContent = i18n._(t`Not configured for inventory sync.`); + } + return ( - {inventory.kind !== 'smart' && } + {inventory.kind !== 'smart' && ( + + )} {inventory.kind === 'smart' diff --git a/awx/ui_next/src/screens/Inventory/InventoryList/InventoryListItem.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryList/InventoryListItem.test.jsx index 6be246df39..179a089e52 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryList/InventoryListItem.test.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryList/InventoryListItem.test.jsx @@ -7,24 +7,33 @@ import InventoryListItem from './InventoryListItem'; jest.mock('../../../api/models/Inventories'); describe('', () => { - test('initially renders succesfully', () => { + const inventory = { + id: 1, + name: 'Inventory', + kind: '', + has_active_failures: true, + total_hosts: 10, + hosts_with_active_failures: 4, + has_inventory_sources: true, + total_inventory_sources: 4, + inventory_sources_with_failures: 5, + summary_fields: { + organization: { + id: 1, + name: 'Default', + }, + user_capabilities: { + edit: true, + }, + }, + }; + + test('initially renders successfully', () => { mountWithContexts( {}} @@ -34,25 +43,50 @@ describe('', () => { ); }); + test('should render not configured tooltip', () => { + const wrapper = mountWithContexts( +
+ + {}} + /> + +
+ ); + + expect(wrapper.find('StatusLabel').prop('tooltipContent')).toBe( + 'Not configured for inventory sync.' + ); + }); + + test('should render success tooltip', () => { + const wrapper = mountWithContexts( + + + {}} + /> + +
+ ); + + expect(wrapper.find('StatusLabel').prop('tooltipContent')).toBe( + 'No inventory sync failures.' + ); + }); + test('should render prompt list item data', () => { const wrapper = mountWithContexts( {}} @@ -61,6 +95,9 @@ describe('', () => {
); expect(wrapper.find('StatusLabel').length).toBe(1); + expect(wrapper.find('StatusLabel').prop('tooltipContent')).toBe( + `${inventory.inventory_sources_with_failures} sources with sync failures.` + ); expect( wrapper .find('Td') @@ -72,7 +109,7 @@ describe('', () => { .find('Td') .at(2) .text() - ).toBe('Disabled'); + ).toBe('Error'); expect( wrapper .find('Td') @@ -92,19 +129,7 @@ describe('', () => { {}} diff --git a/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx b/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx index 9ee8e2a94e..d41632008b 100644 --- a/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx +++ b/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx @@ -281,6 +281,42 @@ function JobDetail({ job, i18n }) { } /> )} + {job.job_tags && job.job_tags.length > 0 && ( + + {job.job_tags.split(',').map(jobTag => ( + + {jobTag} + + ))} + + } + /> + )} + {job.skip_tags && job.skip_tags.length > 0 && ( + + {job.skip_tags.split(',').map(skipTag => ( + + {skipTag} + + ))} + + } + /> + )} ', () => { mockJobData.summary_fields.credentials[0] ); + expect( + wrapper + .find('Detail[label="Job Tags"]') + .containsAnyMatchingElements([a, b]) + ).toEqual(true); + + expect( + wrapper + .find('Detail[label="Skip Tags"]') + .containsAnyMatchingElements([c, d]) + ).toEqual(true); + const statusDetail = wrapper.find('Detail[label="Status"]'); expect(statusDetail.find('StatusIcon SuccessfulTop')).toHaveLength(1); expect(statusDetail.find('StatusIcon SuccessfulBottom')).toHaveLength(1); diff --git a/awx/ui_next/src/screens/Job/JobOutput/JobOutput.jsx b/awx/ui_next/src/screens/Job/JobOutput/JobOutput.jsx index 97c6220d0c..ac974c44ff 100644 --- a/awx/ui_next/src/screens/Job/JobOutput/JobOutput.jsx +++ b/awx/ui_next/src/screens/Job/JobOutput/JobOutput.jsx @@ -1,6 +1,6 @@ import React, { Component, Fragment } from 'react'; import { withRouter } from 'react-router-dom'; -import { withI18n } from '@lingui/react'; +import { I18n } from '@lingui/react'; import { t } from '@lingui/macro'; import styled from 'styled-components'; import { @@ -578,7 +578,7 @@ class JobOutput extends Component { } render() { - const { job, i18n } = this.props; + const { job } = this.props; const { contentError, @@ -666,64 +666,72 @@ class JobOutput extends Component { {showCancelPrompt && ['pending', 'waiting', 'running'].includes(jobStatus) && ( - + {({ i18n }) => ( + + {i18n._(t`Cancel job`)} + , + , + ]} > - {i18n._(t`Cancel job`)} - , - , - ]} - > - {i18n._( - t`Are you sure you want to submit the request to cancel this job?` + {i18n._( + t`Are you sure you want to submit the request to cancel this job?` + )} + )} - + )} {cancelError && ( - <> - this.setState({ cancelError: null })} - title={i18n._(t`Job Cancel Error`)} - label={i18n._(t`Job Cancel Error`)} - > - - - + + {({ i18n }) => ( + this.setState({ cancelError: null })} + title={i18n._(t`Job Cancel Error`)} + label={i18n._(t`Job Cancel Error`)} + > + + + )} + )} {deletionError && ( - <> - this.setState({ deletionError: null })} - title={i18n._(t`Job Delete Error`)} - label={i18n._(t`Job Delete Error`)} - > - - - + + {({ i18n }) => ( + this.setState({ deletionError: null })} + title={i18n._(t`Job Delete Error`)} + label={i18n._(t`Job Delete Error`)} + > + + + )} + )} ); @@ -731,4 +739,4 @@ class JobOutput extends Component { } export { JobOutput as _JobOutput }; -export default withI18n()(withRouter(JobOutput)); +export default withRouter(JobOutput); diff --git a/awx/ui_next/src/screens/Job/shared/data.job.json b/awx/ui_next/src/screens/Job/shared/data.job.json index 98d071c876..778c2fcc84 100644 --- a/awx/ui_next/src/screens/Job/shared/data.job.json +++ b/awx/ui_next/src/screens/Job/shared/data.job.json @@ -101,9 +101,9 @@ "limit": "", "verbosity": 0, "extra_vars": "{\"num_messages\": 94}", - "job_tags": "", + "job_tags": "a,b", "force_handlers": false, - "skip_tags": "", + "skip_tags": "c,d", "start_at_task": "", "timeout": 0, "use_fact_cache": false, diff --git a/awx/ui_next/src/util/validators.jsx b/awx/ui_next/src/util/validators.jsx index 561f3051ca..c7b3c7ab6b 100644 --- a/awx/ui_next/src/util/validators.jsx +++ b/awx/ui_next/src/util/validators.jsx @@ -50,10 +50,23 @@ export function requiredEmail(i18n) { if (!value) { return i18n._(t`This field must not be blank`); } - if (!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,4}$/i.test(value)) { - return i18n._(t`Invalid email address`); + + // This isn't a perfect validator. It's likely to let a few + // invalid (though unlikely) email addresses through. + + // This is ok, because the server will always do strict validation for us. + + const splitVals = value.split('@'); + + if (splitVals.length >= 2) { + if (splitVals[0] && splitVals[1]) { + // We get here if the string has an '@' that is enclosed by + // non-empty substrings + return undefined; + } } - return undefined; + + return i18n._(t`Invalid email address`); }; } diff --git a/awx/ui_next/src/util/validators.test.js b/awx/ui_next/src/util/validators.test.js index a660c8ea5c..db1285bb21 100644 --- a/awx/ui_next/src/util/validators.test.js +++ b/awx/ui_next/src/util/validators.test.js @@ -8,6 +8,7 @@ import { url, combine, regExp, + requiredEmail, } from './validators'; const i18n = { _: val => val }; @@ -187,4 +188,14 @@ describe('validators', () => { expect(regExp(i18n)('ok')).toBeUndefined(); expect(regExp(i18n)('[^a-zA-Z]')).toBeUndefined(); }); + + test('email validator rejects obviously invalid email ', () => { + expect(requiredEmail(i18n)('foobar321')).toEqual({ + id: 'Invalid email address', + }); + }); + + test('bob has email', () => { + expect(requiredEmail(i18n)('bob@localhost')).toBeUndefined(); + }); }); diff --git a/awx_collection/plugins/modules/tower_instance_group.py b/awx_collection/plugins/modules/tower_instance_group.py index 076610f7c0..f32b60aebf 100644 --- a/awx_collection/plugins/modules/tower_instance_group.py +++ b/awx_collection/plugins/modules/tower_instance_group.py @@ -31,7 +31,6 @@ options: new_name: description: - Setting this option will change the existing name (looked up via the name field. - required: True type: str credential: description: diff --git a/awx_collection/test/awx/test_inventory_source.py b/awx_collection/test/awx/test_inventory_source.py index 3e65feaddb..fa01b16ddb 100644 --- a/awx_collection/test/awx/test_inventory_source.py +++ b/awx_collection/test/awx/test_inventory_source.py @@ -169,7 +169,7 @@ def test_falsy_value(run_module, admin_user, base_inventory): result = run_module('tower_inventory_source', dict( name='falsy-test', inventory=base_inventory.name, - # source='ec2', + source='ec2', update_on_launch=False ), admin_user) diff --git a/installer/roles/kubernetes/templates/deployment.yml.j2 b/installer/roles/kubernetes/templates/deployment.yml.j2 index 5902d8d3ae..da329d23de 100644 --- a/installer/roles/kubernetes/templates/deployment.yml.j2 +++ b/installer/roles/kubernetes/templates/deployment.yml.j2 @@ -150,7 +150,8 @@ spec: virtualenv -p {{ custom_venv.python | default(custom_venvs_python) }} \ {{ custom_venvs_path }}/{{ custom_venv.name }} && source {{ custom_venvs_path }}/{{ custom_venv.name }}/bin/activate && - {{ custom_venvs_path }}/{{ custom_venv.name }}/bin/pip install {{ trusted_hosts }} -U psutil \ + {{ custom_venvs_path }}/{{ custom_venv.name }}/bin/pip install {{ trusted_hosts }} -U pip && + {{ custom_venvs_path }}/{{ custom_venv.name }}/bin/pip install {{ trusted_hosts }} -U psutil \ "ansible=={{ custom_venv.python_ansible_version }}" && {% if custom_venv.python_modules is defined %} {{ custom_venvs_path }}/{{ custom_venv.name }}/bin/pip install {{ trusted_hosts }} -U \ diff --git a/requirements/requirements.in b/requirements/requirements.in index 75d1b7afd0..263b95dfbe 100644 --- a/requirements/requirements.in +++ b/requirements/requirements.in @@ -1,5 +1,5 @@ aiohttp -ansible-runner>=1.4.6 +ansible-runner>=1.4.7 ansiconv==1.0.0 # UPGRADE BLOCKER: from 2013, consider replacing instead of upgrading asciichartpy autobahn>=20.12.3 # CVE-2020-35678 diff --git a/requirements/requirements.txt b/requirements/requirements.txt index 9668f2d0fb..fd1591dc29 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -1,7 +1,7 @@ adal==1.2.2 # via msrestazure aiohttp==3.6.2 # via -r /awx_devel/requirements/requirements.in aioredis==1.3.1 # via channels-redis -ansible-runner==1.4.6 # via -r /awx_devel/requirements/requirements.in +ansible-runner==1.4.7 # via -r /awx_devel/requirements/requirements.in ansiconv==1.0.0 # via -r /awx_devel/requirements/requirements.in asciichartpy==1.5.25 # via -r /awx_devel/requirements/requirements.in asgiref==3.2.5 # via channels, channels-redis, daphne