From de130eb79820358e57d6c87095a6d30bb929caa3 Mon Sep 17 00:00:00 2001 From: nixocio Date: Mon, 24 Aug 2020 21:23:22 -0400 Subject: [PATCH 01/22] Add advanced search keys for InstanceGroup and CredentialType Lists Add advanced search keys for `InstanceGroup` and `CredentialType` Lists. See: https://github.com/ansible/awx/pull/7895/files --- .../CredentialTypeList/CredentialTypeList.jsx | 18 +++++++++++++++++- .../InstanceGroupList/InstanceGroupList.jsx | 18 +++++++++++++++++- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/awx/ui_next/src/screens/CredentialType/CredentialTypeList/CredentialTypeList.jsx b/awx/ui_next/src/screens/CredentialType/CredentialTypeList/CredentialTypeList.jsx index 4051adee05..b212f0b76e 100644 --- a/awx/ui_next/src/screens/CredentialType/CredentialTypeList/CredentialTypeList.jsx +++ b/awx/ui_next/src/screens/CredentialType/CredentialTypeList/CredentialTypeList.jsx @@ -32,7 +32,13 @@ function CredentialTypeList({ i18n }) { error: contentError, isLoading, request: fetchCredentialTypes, - result: { credentialTypes, credentialTypesCount, actions }, + result: { + credentialTypes, + credentialTypesCount, + actions, + relatedSearchableKeys, + searchableKeys, + }, } = useRequest( useCallback(async () => { const params = parseQueryString(QS_CONFIG, location.search); @@ -46,12 +52,20 @@ function CredentialTypeList({ i18n }) { credentialTypes: response.data.results, credentialTypesCount: response.data.count, actions: responseActions.data.actions, + relatedSearchableKeys: ( + responseActions?.data?.related_search_fields || [] + ).map(val => val.slice(0, -8)), + searchableKeys: Object.keys( + responseActions.data.actions?.GET || {} + ).filter(key => responseActions.data.actions?.GET[key].filterable), }; }, [location]), { credentialTypes: [], credentialTypesCount: 0, actions: {}, + relatedSearchableKeys: [], + searchableKeys: [], } ); @@ -100,6 +114,8 @@ function CredentialTypeList({ i18n }) { pluralizedItemName={i18n._(t`Credential Types`)} qsConfig={QS_CONFIG} onRowClick={handleSelect} + toolbarSearchableKeys={searchableKeys} + toolbarRelatedSearchableKeys={relatedSearchableKeys} renderToolbar={props => ( { const params = parseQueryString(QS_CONFIG, location.search); @@ -63,12 +69,20 @@ function InstanceGroupList({ i18n }) { instanceGroups: response.data.results, instanceGroupsCount: response.data.count, actions: responseActions.data.actions, + relatedSearchableKeys: ( + responseActions?.data?.related_search_fields || [] + ).map(val => val.slice(0, -8)), + searchableKeys: Object.keys( + responseActions.data.actions?.GET || {} + ).filter(key => responseActions.data.actions?.GET[key].filterable), }; }, [location]), { instanceGroups: [], instanceGroupsCount: 0, actions: {}, + relatedSearchableKeys: [], + searchableKeys: [], } ); @@ -171,6 +185,8 @@ function InstanceGroupList({ i18n }) { pluralizedItemName={pluralizedItemName} qsConfig={QS_CONFIG} onRowClick={handleSelect} + toolbarSearchableKeys={searchableKeys} + toolbarRelatedSearchableKeys={relatedSearchableKeys} renderToolbar={props => ( Date: Tue, 25 Aug 2020 16:02:55 -0400 Subject: [PATCH 02/22] Fixes bug where users were unable to turn webhooks off when editing job templates/workflow job templates --- .../Template/shared/JobTemplateForm.jsx | 24 ++++++++++++++++++ .../Template/shared/WebhookSubForm.jsx | 8 +----- .../shared/WorkflowJobTemplateForm.jsx | 25 ++++++++++++++++++- 3 files changed, 49 insertions(+), 8 deletions(-) diff --git a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx index c9c5bdf3db..a7d540a7a7 100644 --- a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx +++ b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx @@ -91,6 +91,15 @@ function JobTemplateForm({ const [jobTagsField, , jobTagsHelpers] = useField('job_tags'); const [skipTagsField, , skipTagsHelpers] = useField('skip_tags'); + const [, webhookServiceMeta, webhookServiceHelpers] = useField( + 'webhook_service' + ); + const [, webhookUrlMeta, webhookUrlHelpers] = useField('webhook_url'); + const [, webhookKeyMeta, webhookKeyHelpers] = useField('webhook_key'); + const [, webhookCredentialMeta, webhookCredentialHelpers] = useField( + 'webhook_credential' + ); + const { request: fetchProject, error: projectContentError, @@ -126,6 +135,21 @@ function JobTemplateForm({ loadRelatedInstanceGroups(); }, [loadRelatedInstanceGroups]); + useEffect(() => { + if (enableWebhooks) { + webhookServiceHelpers.setValue(webhookServiceMeta.initialValue); + webhookUrlHelpers.setValue(webhookUrlMeta.initialValue); + webhookKeyHelpers.setValue(webhookKeyMeta.initialValue); + webhookCredentialHelpers.setValue(webhookCredentialMeta.initialValue); + } else { + webhookServiceHelpers.setValue(''); + webhookUrlHelpers.setValue(''); + webhookKeyHelpers.setValue(''); + webhookCredentialHelpers.setValue(null); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [enableWebhooks]); + const handleProjectValidation = project => { if (!project && projectMeta.touched) { return i18n._(t`Select a value for this field`); diff --git a/awx/ui_next/src/screens/Template/shared/WebhookSubForm.jsx b/awx/ui_next/src/screens/Template/shared/WebhookSubForm.jsx index 74b85aa7dd..8e32561eaa 100644 --- a/awx/ui_next/src/screens/Template/shared/WebhookSubForm.jsx +++ b/awx/ui_next/src/screens/Template/shared/WebhookSubForm.jsx @@ -25,9 +25,7 @@ import { function WebhookSubForm({ i18n, templateType }) { const { id } = useParams(); - const { pathname } = useLocation(); - const { origin } = document.location; const [ @@ -35,11 +33,7 @@ function WebhookSubForm({ i18n, templateType }) { webhookServiceMeta, webhookServiceHelpers, ] = useField('webhook_service'); - - // eslint-disable-next-line no-unused-vars - const [webhookUrlField, webhookUrlMeta, webhookUrlHelpers] = useField( - 'webhook_url' - ); + const [webhookUrlField, , webhookUrlHelpers] = useField('webhook_url'); const [webhookKeyField, webhookKeyMeta, webhookKeyHelpers] = useField( 'webhook_key' ); diff --git a/awx/ui_next/src/screens/Template/shared/WorkflowJobTemplateForm.jsx b/awx/ui_next/src/screens/Template/shared/WorkflowJobTemplateForm.jsx index 4a135682a1..4ea77786f9 100644 --- a/awx/ui_next/src/screens/Template/shared/WorkflowJobTemplateForm.jsx +++ b/awx/ui_next/src/screens/Template/shared/WorkflowJobTemplateForm.jsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { t } from '@lingui/macro'; import PropTypes, { shape } from 'prop-types'; @@ -57,6 +57,29 @@ function WorkflowJobTemplateForm({ 'organization' ); const [scmField, , scmHelpers] = useField('scm_branch'); + const [, webhookServiceMeta, webhookServiceHelpers] = useField( + 'webhook_service' + ); + const [, webhookUrlMeta, webhookUrlHelpers] = useField('webhook_url'); + const [, webhookKeyMeta, webhookKeyHelpers] = useField('webhook_key'); + const [, webhookCredentialMeta, webhookCredentialHelpers] = useField( + 'webhook_credential' + ); + + useEffect(() => { + if (enableWebhooks) { + webhookServiceHelpers.setValue(webhookServiceMeta.initialValue); + webhookUrlHelpers.setValue(webhookUrlMeta.initialValue); + webhookKeyHelpers.setValue(webhookKeyMeta.initialValue); + webhookCredentialHelpers.setValue(webhookCredentialMeta.initialValue); + } else { + webhookServiceHelpers.setValue(''); + webhookUrlHelpers.setValue(''); + webhookKeyHelpers.setValue(''); + webhookCredentialHelpers.setValue(null); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [enableWebhooks]); if (hasContentError) { return ; From 73d21a01cbef3294de2f92ec40f9e90434973d9d Mon Sep 17 00:00:00 2001 From: mabashian Date: Wed, 26 Aug 2020 17:03:48 -0400 Subject: [PATCH 03/22] Adds visualizer button to workflow template rows on templates list --- .../TemplateList/TemplateListItem.jsx | 21 +++++++++++++--- .../TemplateList/TemplateListItem.test.jsx | 25 +++++++++++++++++++ 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/awx/ui_next/src/screens/Template/TemplateList/TemplateListItem.jsx b/awx/ui_next/src/screens/Template/TemplateList/TemplateListItem.jsx index eb0be40346..10a34bedc7 100644 --- a/awx/ui_next/src/screens/Template/TemplateList/TemplateListItem.jsx +++ b/awx/ui_next/src/screens/Template/TemplateList/TemplateListItem.jsx @@ -15,6 +15,7 @@ import { withI18n } from '@lingui/react'; import { ExclamationTriangleIcon, PencilAltIcon, + ProjectDiagramIcon, RocketIcon, } from '@patternfly/react-icons'; import styled from 'styled-components'; @@ -32,7 +33,7 @@ const DataListAction = styled(_DataListAction)` align-items: center; display: grid; grid-gap: 16px; - grid-template-columns: repeat(3, 40px); + grid-template-columns: repeat(4, 40px); `; function TemplateListItem({ @@ -104,6 +105,20 @@ function TemplateListItem({ ]} /> + {template.type === 'workflow_job_template' && ( + + + + )} {template.summary_fields.user_capabilities.start && ( @@ -111,7 +126,7 @@ function TemplateListItem({ ); } return ( - ); } diff --git a/awx/ui_next/src/screens/InstanceGroup/InstanceGroup.jsx b/awx/ui_next/src/screens/InstanceGroup/InstanceGroup.jsx index 4597bbd61c..06c348a8b9 100644 --- a/awx/ui_next/src/screens/InstanceGroup/InstanceGroup.jsx +++ b/awx/ui_next/src/screens/InstanceGroup/InstanceGroup.jsx @@ -21,7 +21,7 @@ import JobList from '../../components/JobList'; import InstanceGroupDetails from './InstanceGroupDetails'; import InstanceGroupEdit from './InstanceGroupEdit'; -import Instances from './Instances'; +import InstanceList from './Instances/InstanceList'; function InstanceGroup({ i18n, setBreadcrumb }) { const { id } = useParams(); @@ -123,7 +123,7 @@ function InstanceGroup({ i18n, setBreadcrumb }) { - + { +describe('', () => { let wrapper; test('should have data fetched and render 3 rows', async () => { diff --git a/awx/ui_next/src/screens/InstanceGroup/Instances/InstanceList.jsx b/awx/ui_next/src/screens/InstanceGroup/Instances/InstanceList.jsx new file mode 100644 index 0000000000..f666cecd27 --- /dev/null +++ b/awx/ui_next/src/screens/InstanceGroup/Instances/InstanceList.jsx @@ -0,0 +1,245 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { useLocation, useParams } from 'react-router-dom'; +import 'styled-components/macro'; + +import DataListToolbar from '../../../components/DataListToolbar'; +import PaginatedDataList, { + ToolbarAddButton, +} from '../../../components/PaginatedDataList'; +import DisassociateButton from '../../../components/DisassociateButton'; +import AssociateModal from '../../../components/AssociateModal'; +import AlertModal from '../../../components/AlertModal'; +import ErrorDetail from '../../../components/ErrorDetail'; + +import useRequest, { + useDeleteItems, + useDismissableError, +} from '../../../util/useRequest'; +import useSelected from '../../../util/useSelected'; +import { InstanceGroupsAPI, InstancesAPI } from '../../../api'; +import { getQSConfig, parseQueryString, mergeParams } from '../../../util/qs'; + +import InstanceListItem from './InstanceListItem'; + +const QS_CONFIG = getQSConfig('instance', { + page: 1, + page_size: 20, + order_by: 'hostname', +}); + +function InstanceList({ i18n }) { + const [isModalOpen, setIsModalOpen] = useState(false); + const location = useLocation(); + const { id: instanceGroupId } = useParams(); + + const { + result: { + instances, + count, + actions, + relatedSearchableKeys, + searchableKeys, + }, + error: contentError, + isLoading, + request: fetchInstances, + } = useRequest( + useCallback(async () => { + const params = parseQueryString(QS_CONFIG, location.search); + const [response, responseActions] = await Promise.all([ + InstanceGroupsAPI.readInstances(instanceGroupId, params), + InstanceGroupsAPI.readInstanceOptions(instanceGroupId), + ]); + return { + instances: response.data.results, + count: response.data.count, + actions: responseActions.data.actions, + relatedSearchableKeys: ( + responseActions?.data?.related_search_fields || [] + ).map(val => val.slice(0, -8)), + searchableKeys: Object.keys( + responseActions.data.actions?.GET || {} + ).filter(key => responseActions.data.actions?.GET[key].filterable), + }; + }, [location.search, instanceGroupId]), + { + instances: [], + count: 0, + actions: {}, + relatedSearchableKeys: [], + searchableKeys: [], + } + ); + + const { selected, isAllSelected, handleSelect, setSelected } = useSelected( + instances + ); + + useEffect(() => { + fetchInstances(); + }, [fetchInstances]); + + const { + isLoading: isDisassociateLoading, + deleteItems: disassociateInstances, + deletionError: disassociateError, + } = useDeleteItems( + useCallback(async () => { + return Promise.all( + selected.map(instance => + InstanceGroupsAPI.disassociateInstance(instanceGroupId, instance.id) + ) + ); + }, [instanceGroupId, selected]), + { + qsConfig: QS_CONFIG, + allItemsSelected: isAllSelected, + fetchItems: fetchInstances, + } + ); + + const { request: handleAssociate, error: associateError } = useRequest( + useCallback( + async instancesToAssociate => { + await Promise.all( + instancesToAssociate.map(instance => + InstanceGroupsAPI.associateInstance(instanceGroupId, instance.id) + ) + ); + fetchInstances(); + }, + [instanceGroupId, fetchInstances] + ) + ); + + const handleDisassociate = async () => { + await disassociateInstances(); + setSelected([]); + }; + + const { error, dismissError } = useDismissableError( + associateError || disassociateError + ); + + const canAdd = + actions && Object.prototype.hasOwnProperty.call(actions, 'POST'); + + const fetchInstancesToAssociate = useCallback( + params => { + return InstancesAPI.read( + mergeParams(params, { not__rampart_groups__id: instanceGroupId }) + ); + }, + [instanceGroupId] + ); + + const readInstancesOptions = () => + InstanceGroupsAPI.readInstanceOptions(instanceGroupId); + + return ( + <> + ( + + setSelected(isSelected ? [...instances] : []) + } + qsConfig={QS_CONFIG} + additionalControls={[ + ...(canAdd + ? [ + setIsModalOpen(true)} + defaultLabel={i18n._(t`Associate`)} + />, + ] + : []), + , + ]} + emptyStateControls={ + canAdd ? ( + setIsModalOpen(true)} + /> + ) : null + } + /> + )} + renderItem={instance => ( + handleSelect(instance)} + isSelected={selected.some(row => row.id === instance.id)} + fetchInstances={fetchInstances} + /> + )} + /> + {isModalOpen && ( + setIsModalOpen(false)} + title={i18n._(t`Select Instances`)} + optionsRequest={readInstancesOptions} + displayKey="hostname" + /> + )} + {error && ( + + {associateError + ? i18n._(t`Failed to associate.`) + : i18n._(t`Failed to disassociate one or more instances.`)} + + + )} + + ); +} + +export default withI18n()(InstanceList); diff --git a/awx/ui_next/src/screens/InstanceGroup/Instances/InstanceList.test.jsx b/awx/ui_next/src/screens/InstanceGroup/Instances/InstanceList.test.jsx new file mode 100644 index 0000000000..d2c7edf32d --- /dev/null +++ b/awx/ui_next/src/screens/InstanceGroup/Instances/InstanceList.test.jsx @@ -0,0 +1,156 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { Route } from 'react-router-dom'; +import { createMemoryHistory } from 'history'; + +import { + mountWithContexts, + waitForElement, +} from '../../../../testUtils/enzymeHelpers'; +import { InstanceGroupsAPI } from '../../../api'; + +import InstanceList from './InstanceList'; + +jest.mock('../../../api'); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ + id: 1, + instanceGroupId: 2, + }), +})); + +const instances = [ + { + id: 1, + type: 'instance', + url: '/api/v2/instances/1/', + related: { + jobs: '/api/v2/instances/1/jobs/', + instance_groups: '/api/v2/instances/1/instance_groups/', + }, + uuid: '00000000-0000-0000-0000-000000000000', + hostname: 'awx', + created: '2020-07-14T19:03:49.000054Z', + modified: '2020-08-12T20:08:02.836748Z', + capacity_adjustment: '0.40', + version: '13.0.0', + capacity: 10, + consumed_capacity: 0, + percent_capacity_remaining: 60.0, + jobs_running: 0, + jobs_total: 68, + cpu: 6, + memory: 2087469056, + cpu_capacity: 24, + mem_capacity: 1, + enabled: true, + managed_by_policy: true, + }, + { + id: 2, + type: 'instance', + url: '/api/v2/instances/2/', + related: { + jobs: '/api/v2/instances/2/jobs/', + instance_groups: '/api/v2/instances/2/instance_groups/', + }, + uuid: '00000000-0000-0000-0000-000000000000', + hostname: 'foo', + created: '2020-07-14T19:03:49.000054Z', + modified: '2020-08-12T20:08:02.836748Z', + capacity_adjustment: '0.40', + version: '13.0.0', + capacity: 10, + consumed_capacity: 0, + percent_capacity_remaining: 60.0, + jobs_running: 0, + jobs_total: 68, + cpu: 6, + memory: 2087469056, + cpu_capacity: 24, + mem_capacity: 1, + enabled: true, + managed_by_policy: false, + }, + { + id: 3, + type: 'instance', + url: '/api/v2/instances/3/', + related: { + jobs: '/api/v2/instances/3/jobs/', + instance_groups: '/api/v2/instances/3/instance_groups/', + }, + uuid: '00000000-0000-0000-0000-000000000000', + hostname: 'bar', + created: '2020-07-14T19:03:49.000054Z', + modified: '2020-08-12T20:08:02.836748Z', + capacity_adjustment: '0.40', + version: '13.0.0', + capacity: 10, + consumed_capacity: 0, + percent_capacity_remaining: 60.0, + jobs_running: 0, + jobs_total: 68, + cpu: 6, + memory: 2087469056, + cpu_capacity: 24, + mem_capacity: 1, + enabled: false, + managed_by_policy: true, + }, +]; + +const options = { data: { actions: { POST: true } } }; + +describe('', () => { + let wrapper; + + beforeEach(async () => { + InstanceGroupsAPI.readInstances.mockResolvedValue({ + data: { + count: instances.length, + results: instances, + }, + }); + InstanceGroupsAPI.readInstanceOptions.mockResolvedValue(options); + const history = createMemoryHistory({ + initialEntries: ['/instance_groups/1/instances'], + }); + await act(async () => { + wrapper = mountWithContexts( + + + , + { + context: { + router: { history, route: { location: history.location } }, + }, + } + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); + + afterEach(() => { + jest.clearAllMocks(); + wrapper.unmount(); + }); + + test('should have data fetched', () => { + expect(wrapper.find('InstanceList').length).toBe(1); + }); + + test('should fetch instances from the api and render them in the list', () => { + expect(InstanceGroupsAPI.readInstances).toHaveBeenCalled(); + expect(InstanceGroupsAPI.readInstanceOptions).toHaveBeenCalled(); + expect(wrapper.find('InstanceListItem').length).toBe(3); + }); + + test('should show associate group modal when adding an existing group', () => { + wrapper.find('ToolbarAddButton').simulate('click'); + expect(wrapper.find('AssociateModal').length).toBe(1); + wrapper.find('ModalBoxCloseButton').simulate('click'); + expect(wrapper.find('AssociateModal').length).toBe(0); + }); +}); diff --git a/awx/ui_next/src/screens/InstanceGroup/Instances/InstanceListItem.jsx b/awx/ui_next/src/screens/InstanceGroup/Instances/InstanceListItem.jsx new file mode 100644 index 0000000000..83f2fef753 --- /dev/null +++ b/awx/ui_next/src/screens/InstanceGroup/Instances/InstanceListItem.jsx @@ -0,0 +1,136 @@ +import React from 'react'; +import { bool, func } from 'prop-types'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import styled from 'styled-components'; +import 'styled-components/macro'; +import { + Badge as PFBadge, + Progress, + ProgressMeasureLocation, + ProgressSize, + DataListAction, + DataListCheck, + DataListItem, + DataListItemRow, + DataListItemCells, +} from '@patternfly/react-core'; + +import _DataListCell from '../../../components/DataListCell'; +import InstanceToggle from '../../../components/InstanceToggle'; +import { Instance } from '../../../types'; + +const Unavailable = styled.span` + color: var(--pf-global--danger-color--200); +`; + +const DataListCell = styled(_DataListCell)` + white-space: nowrap; +`; + +const Badge = styled(PFBadge)` + margin-left: 8px; +`; + +const ListGroup = styled.span` + margin-left: 12px; + + &:first-of-type { + margin-left: 0; + } +`; + +function InstanceListItem({ + instance, + isSelected, + onSelect, + fetchInstances, + i18n, +}) { + const labelId = `check-action-${instance.id}`; + + function usedCapacity(item) { + if (item.enabled) { + return ( + + ); + } + return {i18n._(t`Unavailable`)}; + } + + return ( + + + + + + {instance.hostname} + , + + {i18n._(t`Type`)} + + {instance.managed_by_policy + ? i18n._(t`Auto`) + : i18n._(t`Manual`)} + + , + + + {i18n._(t`Running jobs`)} + {instance.jobs_running} + + + {i18n._(t`Total jobs`)} + {instance.jobs_total} + + , + + {usedCapacity(instance)} + , + ]} + /> + + + + + + ); +} +InstanceListItem.prototype = { + instance: Instance.isRequired, + isSelected: bool.isRequired, + onSelect: func.isRequired, +}; + +export default withI18n()(InstanceListItem); diff --git a/awx/ui_next/src/screens/InstanceGroup/Instances/InstanceListItem.test.jsx b/awx/ui_next/src/screens/InstanceGroup/Instances/InstanceListItem.test.jsx new file mode 100644 index 0000000000..5e3a138f90 --- /dev/null +++ b/awx/ui_next/src/screens/InstanceGroup/Instances/InstanceListItem.test.jsx @@ -0,0 +1,89 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; + +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; + +import InstanceListItem from './InstanceListItem'; + +const instance = [ + { + id: 1, + type: 'instance', + url: '/api/v2/instances/1/', + related: { + jobs: '/api/v2/instances/1/jobs/', + instance_groups: '/api/v2/instances/1/instance_groups/', + }, + uuid: '00000000-0000-0000-0000-000000000000', + hostname: 'awx', + created: '2020-07-14T19:03:49.000054Z', + modified: '2020-08-12T20:08:02.836748Z', + capacity_adjustment: '0.40', + version: '13.0.0', + capacity: 10, + consumed_capacity: 0, + percent_capacity_remaining: 60.0, + jobs_running: 0, + jobs_total: 68, + cpu: 6, + memory: 2087469056, + cpu_capacity: 24, + mem_capacity: 1, + enabled: true, + managed_by_policy: true, + }, +]; + +describe('', () => { + let wrapper; + + test('should mount successfully', async () => { + await act(async () => { + wrapper = mountWithContexts( + {}} + /> + ); + }); + expect(wrapper.find('InstanceListItem').length).toBe(1); + }); + + test('should render the proper data instance', async () => { + await act(async () => { + wrapper = mountWithContexts( + {}} + /> + ); + }); + expect( + wrapper.find('PFDataListCell[aria-label="instance host name"]').text() + ).toBe('awx'); + expect(wrapper.find('Progress').prop('value')).toBe(40); + expect( + wrapper.find('PFDataListCell[aria-label="instance type"]').text() + ).toBe('TypeAuto'); + expect(wrapper.find('input#instances-1').prop('checked')).toBe(false); + }); + + test('should be checked', async () => { + await act(async () => { + wrapper = mountWithContexts( + {}} + /> + ); + }); + expect(wrapper.find('input#instances-1').prop('checked')).toBe(true); + }); + + test('should display instance toggle', () => { + expect(wrapper.find('InstanceToggle').length).toBe(1); + }); +}); diff --git a/awx/ui_next/src/screens/InstanceGroup/Instances/Instances.jsx b/awx/ui_next/src/screens/InstanceGroup/Instances/Instances.jsx deleted file mode 100644 index b41760edd5..0000000000 --- a/awx/ui_next/src/screens/InstanceGroup/Instances/Instances.jsx +++ /dev/null @@ -1,14 +0,0 @@ -import React from 'react'; -import { Card, PageSection } from '@patternfly/react-core'; - -function Instances() { - return ( - - -
Instances
-
-
- ); -} - -export default Instances; diff --git a/awx/ui_next/src/screens/InstanceGroup/Instances/index.js b/awx/ui_next/src/screens/InstanceGroup/Instances/index.js index b018ebb049..2567e3c8e7 100644 --- a/awx/ui_next/src/screens/InstanceGroup/Instances/index.js +++ b/awx/ui_next/src/screens/InstanceGroup/Instances/index.js @@ -1 +1,2 @@ -export { default } from './Instances'; +export { default as InstanceList } from './InstanceList'; +export { default as InstanceListItem } from './InstanceListItem'; diff --git a/awx/ui_next/src/types.js b/awx/ui_next/src/types.js index 5ee55ef5d9..e1fa5a5163 100644 --- a/awx/ui_next/src/types.js +++ b/awx/ui_next/src/types.js @@ -118,6 +118,11 @@ export const InstanceGroup = shape({ name: string.isRequired, }); +export const Instance = shape({ + id: number.isRequired, + name: string.isRequired, +}); + export const Label = shape({ id: number.isRequired, name: string.isRequired, diff --git a/awx/ui_next/testUtils/enzymeHelpers.jsx b/awx/ui_next/testUtils/enzymeHelpers.jsx index bc6666a8ba..c9077ac006 100644 --- a/awx/ui_next/testUtils/enzymeHelpers.jsx +++ b/awx/ui_next/testUtils/enzymeHelpers.jsx @@ -42,6 +42,7 @@ const defaultContexts = { ansible_version: null, custom_virtualenvs: [], version: null, + me: { is_superuser: true }, toJSON: () => '/config/', }, router: { From dc7e7219685c4512636be4ff3395cc8982654bdd Mon Sep 17 00:00:00 2001 From: Daniel Sami Date: Thu, 27 Aug 2020 11:54:38 -0400 Subject: [PATCH 12/22] Change WFJT details sparkline hyperlink --- .../WorkflowJobTemplateDetail/WorkflowJobTemplateDetail.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateDetail/WorkflowJobTemplateDetail.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateDetail/WorkflowJobTemplateDetail.jsx index eb768ec5ca..2f6b99821a 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateDetail/WorkflowJobTemplateDetail.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateDetail/WorkflowJobTemplateDetail.jsx @@ -99,7 +99,7 @@ function WorkflowJobTemplateDetail({ template, i18n }) { const canLaunch = summary_fields?.user_capabilities?.start; const recentPlaybookJobs = summary_fields.recent_jobs.map(job => ({ ...job, - type: 'job', + type: 'workflow_job', })); return ( From 51f4aa2b48a50ab18cc896b1aa6b788363c61eb2 Mon Sep 17 00:00:00 2001 From: John Westcott IV Date: Fri, 28 Aug 2020 11:04:15 -0400 Subject: [PATCH 13/22] Adding check that we are authenticated and also have a token --- awx_collection/plugins/module_utils/tower_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx_collection/plugins/module_utils/tower_api.py b/awx_collection/plugins/module_utils/tower_api.py index 36e3d045f0..c42e6733a5 100644 --- a/awx_collection/plugins/module_utils/tower_api.py +++ b/awx_collection/plugins/module_utils/tower_api.py @@ -551,7 +551,7 @@ class TowerAPIModule(TowerModule): return self.create_if_needed(existing_item, new_item, endpoint, on_create=on_create, item_type=item_type, associations=associations) def logout(self): - if self.authenticated: + if self.authenticated and self.oauth_token_id: # Attempt to delete our current token from /api/v2/tokens/ # Post to the tokens endpoint with baisc auth to try and get a token api_token_url = ( From 21330a54cbe1b7014fcfe69105aca2d62954fb06 Mon Sep 17 00:00:00 2001 From: nixocio Date: Thu, 27 Aug 2020 16:40:09 -0400 Subject: [PATCH 14/22] Update instance groups * Simplify criteria to instance group to be considered unavailable * Round values for used capacity See: https://github.com/ansible/awx/issues/7467 --- .../InstanceGroupDetails/InstanceGroupDetails.jsx | 13 ++++--------- .../InstanceGroupList/InstanceGroupListItem.jsx | 11 ++--------- .../InstanceGroup/Instances/InstanceListItem.jsx | 2 +- 3 files changed, 7 insertions(+), 19 deletions(-) diff --git a/awx/ui_next/src/screens/InstanceGroup/InstanceGroupDetails/InstanceGroupDetails.jsx b/awx/ui_next/src/screens/InstanceGroup/InstanceGroupDetails/InstanceGroupDetails.jsx index 0d6964559a..b7c10da67d 100644 --- a/awx/ui_next/src/screens/InstanceGroup/InstanceGroupDetails/InstanceGroupDetails.jsx +++ b/awx/ui_next/src/screens/InstanceGroup/InstanceGroupDetails/InstanceGroupDetails.jsx @@ -39,13 +39,6 @@ function InstanceGroupDetails({ instanceGroup, i18n }) { const { error, dismissError } = useDismissableError(deleteError); - const isAvailable = item => { - return ( - (item.policy_instance_minimum || item.policy_instance_percentage) && - item.capacity - ); - }; - const verifyIsIsolated = item => { if (item.is_isolated) { return ( @@ -89,10 +82,12 @@ function InstanceGroupDetails({ instanceGroup, i18n }) { dataCy="instance-group-policy-instance-percentage" content={`${instanceGroup.policy_instance_percentage} %`} /> - {isAvailable(instanceGroup) ? ( + {instanceGroup.capacity ? ( ) : ( diff --git a/awx/ui_next/src/screens/InstanceGroup/InstanceGroupList/InstanceGroupListItem.jsx b/awx/ui_next/src/screens/InstanceGroup/InstanceGroupList/InstanceGroupListItem.jsx index f531f8b182..9ea19a5dd2 100644 --- a/awx/ui_next/src/screens/InstanceGroup/InstanceGroupList/InstanceGroupListItem.jsx +++ b/awx/ui_next/src/screens/InstanceGroup/InstanceGroupList/InstanceGroupListItem.jsx @@ -60,23 +60,16 @@ function InstanceGroupListItem({ }) { const labelId = `check-action-${instanceGroup.id}`; - const isAvailable = item => { - return ( - (item.policy_instance_minimum || item.policy_instance_percentage) && - item.capacity - ); - }; - const isContainerGroup = item => { return item.is_containerized; }; function usedCapacity(item) { if (!isContainerGroup(item)) { - if (isAvailable(item)) { + if (item.capacity) { return ( Date: Fri, 28 Aug 2020 17:33:19 +0200 Subject: [PATCH 15/22] [credential_plugin/hashivault] fix typo --- awx/main/credential_plugins/hashivault.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/main/credential_plugins/hashivault.py b/awx/main/credential_plugins/hashivault.py index 2406623231..28f213061b 100644 --- a/awx/main/credential_plugins/hashivault.py +++ b/awx/main/credential_plugins/hashivault.py @@ -152,7 +152,7 @@ def kv_backend(**kwargs): sess = requests.Session() sess.headers['Authorization'] = 'Bearer {}'.format(token) - # Compatability header for older installs of Hashicorp Vault + # Compatibility header for older installs of Hashicorp Vault sess.headers['X-Vault-Token'] = token if api_version == 'v2': From feb9bcff4d3adbc7a884a908ccc5ec6b2518b328 Mon Sep 17 00:00:00 2001 From: John Westcott IV Date: Fri, 28 Aug 2020 12:43:33 -0400 Subject: [PATCH 16/22] Adding transaction to mock requests --- awx_collection/test/awx/conftest.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/awx_collection/test/awx/conftest.py b/awx_collection/test/awx/conftest.py index 5db00d6325..10774b9b34 100644 --- a/awx_collection/test/awx/conftest.py +++ b/awx_collection/test/awx/conftest.py @@ -17,6 +17,8 @@ import pytest from awx.main.tests.functional.conftest import _request from awx.main.models import Organization, Project, Inventory, JobTemplate, Credential, CredentialType +from django.db import transaction + try: import tower_cli # noqa HAS_TOWER_CLI = True @@ -107,8 +109,9 @@ def run_module(request, collection_import): kwargs_copy['data'][k] = v # make request - rf = _request(method.lower()) - django_response = rf(url, user=request_user, expect=None, **kwargs_copy) + with transaction.atomic(): + rf = _request(method.lower()) + django_response = rf(url, user=request_user, expect=None, **kwargs_copy) # requests library response object is different from the Django response, but they are the same concept # this converts the Django response object into a requests response object for consumption From 4ea648307ec3cc7efc5ef57ad4f615b44dc650c2 Mon Sep 17 00:00:00 2001 From: "Christian M. Adams" Date: Wed, 26 Aug 2020 18:20:53 -0400 Subject: [PATCH 17/22] Accept all responses <300 from Insights API --- awx/main/analytics/core.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/awx/main/analytics/core.py b/awx/main/analytics/core.py index 3ff61b82f9..bab62b4a3c 100644 --- a/awx/main/analytics/core.py +++ b/awx/main/analytics/core.py @@ -180,7 +180,8 @@ def ship(path): auth=(rh_user, rh_password), headers=s.headers, timeout=(31, 31)) - if response.status_code != 202: + # Accept 2XX status_codes + if response.status_code >= 300: return logger.exception('Upload failed with status {}, {}'.format(response.status_code, response.text)) run_now = now() From e93aa34864abf49ea5b3d09b1a470057b31802d8 Mon Sep 17 00:00:00 2001 From: mabashian Date: Mon, 27 Jul 2020 16:11:45 -0400 Subject: [PATCH 18/22] Adds support for a Test button on the credential form when the credential type is 'external' --- awx/ui_next/src/api/models/CredentialTypes.js | 4 + awx/ui_next/src/api/models/Credentials.js | 4 + .../Credential/shared/CredentialForm.jsx | 81 ++++++-- .../Credential/shared/CredentialForm.test.jsx | 15 ++ .../CredentialPluginTestAlert.jsx | 91 +++++++++ .../CredentialPlugins/index.js | 1 + .../Credential/shared/ExternalTestModal.jsx | 192 ++++++++++++++++++ .../shared/ExternalTestModal.test.jsx | 180 ++++++++++++++++ .../src/screens/Credential/shared/index.js | 1 + awx/ui_next/src/util/useRequest.js | 3 + 10 files changed, 554 insertions(+), 18 deletions(-) create mode 100644 awx/ui_next/src/screens/Credential/shared/CredentialFormFields/CredentialPlugins/CredentialPluginTestAlert.jsx create mode 100644 awx/ui_next/src/screens/Credential/shared/ExternalTestModal.jsx create mode 100644 awx/ui_next/src/screens/Credential/shared/ExternalTestModal.test.jsx diff --git a/awx/ui_next/src/api/models/CredentialTypes.js b/awx/ui_next/src/api/models/CredentialTypes.js index dab1676231..39247b5ebc 100644 --- a/awx/ui_next/src/api/models/CredentialTypes.js +++ b/awx/ui_next/src/api/models/CredentialTypes.js @@ -27,6 +27,10 @@ class CredentialTypes extends Base { .concat(nextResults) .filter(type => acceptableKinds.includes(type.kind)); } + + test(id, data) { + return this.http.post(`${this.baseUrl}${id}/test/`, data); + } } export default CredentialTypes; diff --git a/awx/ui_next/src/api/models/Credentials.js b/awx/ui_next/src/api/models/Credentials.js index 95e954fc0c..13ee1f8a9c 100644 --- a/awx/ui_next/src/api/models/Credentials.js +++ b/awx/ui_next/src/api/models/Credentials.js @@ -25,6 +25,10 @@ class Credentials extends Base { params, }); } + + test(id, data) { + return this.http.post(`${this.baseUrl}${id}/test/`, data); + } } export default Credentials; diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialForm.jsx b/awx/ui_next/src/screens/Credential/shared/CredentialForm.jsx index bc486bb8b1..5a76a76271 100644 --- a/awx/ui_next/src/screens/Credential/shared/CredentialForm.jsx +++ b/awx/ui_next/src/screens/Credential/shared/CredentialForm.jsx @@ -1,16 +1,19 @@ -import React from 'react'; +import React, { useState } from 'react'; import { Formik, useField } from 'formik'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { arrayOf, func, object, shape } from 'prop-types'; -import { Form, FormGroup } from '@patternfly/react-core'; +import { ActionGroup, Button, Form, FormGroup } from '@patternfly/react-core'; import FormField, { FormSubmitError } from '../../../components/FormField'; -import FormActionGroup from '../../../components/FormActionGroup/FormActionGroup'; +import { + FormColumnLayout, + FormFullWidthLayout, +} from '../../../components/FormLayout'; import AnsibleSelect from '../../../components/AnsibleSelect'; import { required } from '../../../util/validators'; import OrganizationLookup from '../../../components/Lookup/OrganizationLookup'; -import { FormColumnLayout } from '../../../components/FormLayout'; import TypeInputsSubForm from './TypeInputsSubForm'; +import ExternalTestModal from './ExternalTestModal'; function CredentialFormFields({ i18n, @@ -139,6 +142,7 @@ function CredentialFormFields({ } function CredentialForm({ + i18n, credential = {}, credentialTypes, inputSources, @@ -147,6 +151,7 @@ function CredentialForm({ submitError, ...rest }) { + const [showExternalTestModal, setShowExternalTestModal] = useState(false); const initialValues = { name: credential.name || '', description: credential.description || '', @@ -205,21 +210,61 @@ function CredentialForm({ }} > {formik => ( -
- - + + + + + + + + {formik?.values?.credential_type && + credentialTypes[formik.values.credential_type]?.kind === + 'external' && ( + + )} + + + + + + {showExternalTestModal && ( + setShowExternalTestModal(false)} /> - - -
- + )} + )} ); diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialForm.test.jsx b/awx/ui_next/src/screens/Credential/shared/CredentialForm.test.jsx index d23e5347ee..f4360e75ba 100644 --- a/awx/ui_next/src/screens/Credential/shared/CredentialForm.test.jsx +++ b/awx/ui_next/src/screens/Credential/shared/CredentialForm.test.jsx @@ -99,6 +99,9 @@ describe('', () => { test('should display form fields on add properly', async () => { addFieldExpects(); }); + test('should hide Test button initially', () => { + expect(wrapper.find('Button[children="Test"]').length).toBe(0); + }); test('should update form values', async () => { // name and description change await act(async () => { @@ -221,6 +224,18 @@ describe('', () => { 'There was an error parsing the file. Please check the file formatting and try again.' ); }); + 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); + }); + wrapper.update(); + expect(wrapper.find('Button[children="Test"]').length).toBe(1); + expect(wrapper.find('Button[children="Test"]').props().isDisabled).toBe( + true + ); + }); test('should call handleCancel when Cancel button is clicked', async () => { expect(onCancel).not.toHaveBeenCalled(); wrapper.find('button[aria-label="Cancel"]').invoke('onClick')(); diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialFormFields/CredentialPlugins/CredentialPluginTestAlert.jsx b/awx/ui_next/src/screens/Credential/shared/CredentialFormFields/CredentialPlugins/CredentialPluginTestAlert.jsx new file mode 100644 index 0000000000..f1c4a4ae97 --- /dev/null +++ b/awx/ui_next/src/screens/Credential/shared/CredentialFormFields/CredentialPlugins/CredentialPluginTestAlert.jsx @@ -0,0 +1,91 @@ +import React, { useEffect, useState } from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { string, shape } from 'prop-types'; +import { + Alert, + AlertActionCloseButton, + AlertGroup, +} from '@patternfly/react-core'; + +function CredentialPluginTestAlert({ + i18n, + credentialName, + successResponse, + errorResponse, +}) { + const [testMessage, setTestMessage] = useState(''); + const [testVariant, setTestVariant] = useState(false); + + useEffect(() => { + if (errorResponse) { + if (errorResponse?.response?.data?.inputs) { + if (errorResponse.response.data.inputs.startsWith('HTTP')) { + const [ + errorCode, + errorStr, + ] = errorResponse.response.data.inputs.split('\n'); + try { + const errorJSON = JSON.parse(errorStr); + setTestMessage( + `${errorCode}${ + errorJSON?.errors[0] ? `: ${errorJSON.errors[0]}` : '' + }` + ); + } catch { + setTestMessage(errorResponse.response.data.inputs); + } + } else { + setTestMessage(errorResponse.response.data.inputs); + } + } else { + setTestMessage( + i18n._( + t`Something went wrong with the request to test this credential and metadata.` + ) + ); + } + setTestVariant('danger'); + } else if (successResponse) { + setTestMessage(i18n._(t`Test passed`)); + setTestVariant('success'); + } + }, [i18n, successResponse, errorResponse]); + + return ( + + {testMessage && testVariant && ( + { + setTestMessage(null); + setTestVariant(null); + }} + /> + } + title={ + <> + {credentialName} +

{testMessage}

+ + } + variant={testVariant} + /> + )} +
+ ); +} + +CredentialPluginTestAlert.propTypes = { + credentialName: string.isRequired, + successResponse: shape({}), + errorResponse: shape({}), +}; + +CredentialPluginTestAlert.defaultProps = { + successResponse: null, + errorResponse: null, +}; + +export default withI18n()(CredentialPluginTestAlert); diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialFormFields/CredentialPlugins/index.js b/awx/ui_next/src/screens/Credential/shared/CredentialFormFields/CredentialPlugins/index.js index 033586567f..3799206eb4 100644 --- a/awx/ui_next/src/screens/Credential/shared/CredentialFormFields/CredentialPlugins/index.js +++ b/awx/ui_next/src/screens/Credential/shared/CredentialFormFields/CredentialPlugins/index.js @@ -1,2 +1,3 @@ export { default as CredentialPluginSelected } from './CredentialPluginSelected'; export { default as CredentialPluginField } from './CredentialPluginField'; +export { default as CredentialPluginTestAlert } from './CredentialPluginTestAlert'; diff --git a/awx/ui_next/src/screens/Credential/shared/ExternalTestModal.jsx b/awx/ui_next/src/screens/Credential/shared/ExternalTestModal.jsx new file mode 100644 index 0000000000..9adc6f7124 --- /dev/null +++ b/awx/ui_next/src/screens/Credential/shared/ExternalTestModal.jsx @@ -0,0 +1,192 @@ +import React, { useCallback } from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import styled from 'styled-components'; +import { func, shape } from 'prop-types'; +import { Formik } from 'formik'; +import { + Button, + Form, + FormGroup, + Modal, + Tooltip, +} from '@patternfly/react-core'; +import { QuestionCircleIcon as PFQuestionCircleIcon } from '@patternfly/react-icons'; +import { CredentialsAPI, CredentialTypesAPI } from '../../../api'; +import AnsibleSelect from '../../../components/AnsibleSelect'; +import FormField from '../../../components/FormField'; +import { FormFullWidthLayout } from '../../../components/FormLayout'; +import { required } from '../../../util/validators'; +import useRequest from '../../../util/useRequest'; +import { CredentialPluginTestAlert } from './CredentialFormFields/CredentialPlugins'; + +const QuestionCircleIcon = styled(PFQuestionCircleIcon)` + margin-left: 10px; +`; + +function ExternalTestModal({ + i18n, + credential, + credentialType, + credentialFormValues, + onClose, +}) { + const { + result: testPluginSuccess, + error: testPluginError, + request: testPluginMetadata, + } = useRequest( + useCallback( + async values => { + const payload = { + inputs: credentialType.inputs.fields.reduce( + (filteredInputs, field) => { + filteredInputs[field.id] = credentialFormValues.inputs[field.id]; + return filteredInputs; + }, + {} + ), + metadata: values, + }; + + if (credential && credential.credential_type === credentialType.id) { + return CredentialsAPI.test(credential.id, payload); + } + return CredentialTypesAPI.test(credentialType.id, payload); + }, + [ + credential, + credentialType.id, + credentialType.inputs.fields, + credentialFormValues.inputs, + ] + ), + null + ); + + const handleTest = async values => { + await testPluginMetadata(values); + }; + + return ( + <> + { + if (field.type === 'string' && field.choices) { + initialValues[field.id] = field.default || field.choices[0]; + } else { + initialValues[field.id] = ''; + } + return initialValues; + }, + {} + )} + onSubmit={values => handleTest(values)} + > + {({ handleSubmit, setFieldValue }) => ( + onClose()} + variant="small" + actions={[ + , + , + ]} + > +
+ + {credentialType.inputs.metadata.map(field => { + const isRequired = credentialType.inputs?.required.includes( + field.id + ); + if (field.type === 'string') { + if (field.choices) { + return ( + + + + ) + } + isRequired={isRequired} + > + { + return { + value: choice, + key: choice, + label: choice, + }; + })} + onChange={(event, value) => { + setFieldValue(field.id, value); + }} + validate={isRequired ? required(null, i18n) : null} + /> + + ); + } + + return ( + + ); + } + + return null; + })} + +
+
+ )} +
+ + + ); +} + +ExternalTestModal.proptype = { + credential: shape({}), + credentialType: shape({}).isRequired, + credentialFormValues: shape({}).isRequired, + onClose: func.isRequired, +}; + +ExternalTestModal.defaultProps = { + credential: null, +}; + +export default withI18n()(ExternalTestModal); diff --git a/awx/ui_next/src/screens/Credential/shared/ExternalTestModal.test.jsx b/awx/ui_next/src/screens/Credential/shared/ExternalTestModal.test.jsx new file mode 100644 index 0000000000..91677795aa --- /dev/null +++ b/awx/ui_next/src/screens/Credential/shared/ExternalTestModal.test.jsx @@ -0,0 +1,180 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import { CredentialsAPI, CredentialTypesAPI } from '../../../api'; +import ExternalTestModal from './ExternalTestModal'; +import credentialTypesArr from './data.credentialTypes.json'; + +jest.mock('../../../api/models/Credentials'); +jest.mock('../../../api/models/CredentialTypes'); + +const credentialType = credentialTypesArr.find( + credType => credType.namespace === 'hashivault_kv' +); + +const credentialFormValues = { + name: 'Foobar', + credential_type: credentialType.id, + inputs: { + api_version: 'v2', + token: '$encrypted$', + url: 'http://hashivault:8200', + }, +}; + +const credential = { + id: 1, + name: 'A credential', + credential_type: credentialType.id, +}; + +describe('', () => { + let wrapper; + afterEach(() => wrapper.unmount()); + test('should display metadata fields correctly', async () => { + wrapper = mountWithContexts( + + ); + expect(wrapper.find('FormField').length).toBe(5); + expect(wrapper.find('input#credential-secret_backend').length).toBe(1); + expect(wrapper.find('input#credential-secret_path').length).toBe(1); + expect(wrapper.find('input#credential-auth_path').length).toBe(1); + expect(wrapper.find('input#credential-secret_key').length).toBe(1); + expect(wrapper.find('input#credential-secret_version').length).toBe(1); + }); + test('should make the test request correctly when testing an existing credential', async () => { + wrapper = mountWithContexts( + + ); + await act(async () => { + wrapper.find('input#credential-secret_path').simulate('change', { + target: { value: '/secret/foo/bar/baz', name: 'secret_path' }, + }); + wrapper.find('input#credential-secret_key').simulate('change', { + target: { value: 'password', name: 'secret_key' }, + }); + }); + wrapper.update(); + await act(async () => { + wrapper.find('Button[children="Run"]').simulate('click'); + }); + expect(CredentialsAPI.test).toHaveBeenCalledWith(1, { + inputs: { + api_version: 'v2', + cacert: undefined, + role_id: undefined, + secret_id: undefined, + token: '$encrypted$', + url: 'http://hashivault:8200', + }, + metadata: { + auth_path: '', + secret_backend: '', + secret_key: 'password', + secret_path: '/secret/foo/bar/baz', + secret_version: '', + }, + }); + }); + test('should make the test request correctly when testing a new credential', async () => { + wrapper = mountWithContexts( + + ); + await act(async () => { + wrapper.find('input#credential-secret_path').simulate('change', { + target: { value: '/secret/foo/bar/baz', name: 'secret_path' }, + }); + wrapper.find('input#credential-secret_key').simulate('change', { + target: { value: 'password', name: 'secret_key' }, + }); + }); + wrapper.update(); + await act(async () => { + wrapper.find('Button[children="Run"]').simulate('click'); + }); + expect(CredentialTypesAPI.test).toHaveBeenCalledWith(21, { + inputs: { + api_version: 'v2', + cacert: undefined, + role_id: undefined, + secret_id: undefined, + token: '$encrypted$', + url: 'http://hashivault:8200', + }, + metadata: { + auth_path: '', + secret_backend: '', + secret_key: 'password', + secret_path: '/secret/foo/bar/baz', + secret_version: '', + }, + }); + }); + test('should display the alert after a successful test', async () => { + CredentialTypesAPI.test.mockResolvedValue({}); + wrapper = mountWithContexts( + + ); + await act(async () => { + wrapper.find('input#credential-secret_path').simulate('change', { + target: { value: '/secret/foo/bar/baz', name: 'secret_path' }, + }); + wrapper.find('input#credential-secret_key').simulate('change', { + target: { value: 'password', name: 'secret_key' }, + }); + }); + wrapper.update(); + await act(async () => { + wrapper.find('Button[children="Run"]').simulate('click'); + }); + wrapper.update(); + expect(wrapper.find('Alert').length).toBe(1); + expect(wrapper.find('Alert').props().variant).toBe('success'); + }); + test('should display the alert after a failed test', async () => { + CredentialTypesAPI.test.mockRejectedValue({ + inputs: `HTTP 404 + {"errors":["no handler for route '/secret/foo/bar/baz'"]} + `, + }); + wrapper = mountWithContexts( + + ); + await act(async () => { + wrapper.find('input#credential-secret_path').simulate('change', { + target: { value: '/secret/foo/bar/baz', name: 'secret_path' }, + }); + wrapper.find('input#credential-secret_key').simulate('change', { + target: { value: 'password', name: 'secret_key' }, + }); + }); + wrapper.update(); + await act(async () => { + wrapper.find('Button[children="Run"]').simulate('click'); + }); + wrapper.update(); + expect(wrapper.find('Alert').length).toBe(1); + expect(wrapper.find('Alert').props().variant).toBe('danger'); + }); +}); diff --git a/awx/ui_next/src/screens/Credential/shared/index.js b/awx/ui_next/src/screens/Credential/shared/index.js index ad01a03c29..28dda5128a 100644 --- a/awx/ui_next/src/screens/Credential/shared/index.js +++ b/awx/ui_next/src/screens/Credential/shared/index.js @@ -1,2 +1,3 @@ export { default as mockCredentials } from './data.credentials.json'; export { default as mockCredentialType } from './data.credential_type.json'; +export { default as ExternalTestModal } from './ExternalTestModal'; diff --git a/awx/ui_next/src/util/useRequest.js b/awx/ui_next/src/util/useRequest.js index 0e95be4a69..027e82f86f 100644 --- a/awx/ui_next/src/util/useRequest.js +++ b/awx/ui_next/src/util/useRequest.js @@ -38,6 +38,9 @@ export default function useRequest(makeRequest, initialValue) { request: useCallback( async (...args) => { setIsLoading(true); + if (isMounted.current) { + setError(null); + } try { const response = await makeRequest(...args); if (isMounted.current) { From ae4f1a15d33024ac7de00ecc46e4a6508061c292 Mon Sep 17 00:00:00 2001 From: mabashian Date: Wed, 26 Aug 2020 13:41:12 -0400 Subject: [PATCH 19/22] Add ID's to the buttons in the external test modal for cred form --- .../src/screens/Credential/shared/ExternalTestModal.jsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/awx/ui_next/src/screens/Credential/shared/ExternalTestModal.jsx b/awx/ui_next/src/screens/Credential/shared/ExternalTestModal.jsx index 9adc6f7124..fda8bc4492 100644 --- a/awx/ui_next/src/screens/Credential/shared/ExternalTestModal.jsx +++ b/awx/ui_next/src/screens/Credential/shared/ExternalTestModal.jsx @@ -92,13 +92,19 @@ function ExternalTestModal({ variant="small" actions={[ , - , ]} From 9d511a4c047978ceb9b26c6631885d67a79b552b Mon Sep 17 00:00:00 2001 From: mabashian Date: Fri, 28 Aug 2020 17:02:25 -0400 Subject: [PATCH 20/22] Fix id on credential select fields --- .../Credential/shared/CredentialFormFields/CredentialField.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialFormFields/CredentialField.jsx b/awx/ui_next/src/screens/Credential/shared/CredentialFormFields/CredentialField.jsx index aafa1c74fe..51c9dfa02d 100644 --- a/awx/ui_next/src/screens/Credential/shared/CredentialFormFields/CredentialField.jsx +++ b/awx/ui_next/src/screens/Credential/shared/CredentialFormFields/CredentialField.jsx @@ -110,7 +110,7 @@ function CredentialField({ credentialType, fieldOptions, i18n }) { > { helpers.setValue(value); From b84343d292a560b11c8a44331e8ce2b2ec0c2792 Mon Sep 17 00:00:00 2001 From: Alan Rominger Date: Mon, 31 Aug 2020 10:18:08 -0400 Subject: [PATCH 21/22] correct name of tower_notification redirect --- awx_collection/meta/runtime.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx_collection/meta/runtime.yml b/awx_collection/meta/runtime.yml index d8c2535871..5fcec0d423 100644 --- a/awx_collection/meta/runtime.yml +++ b/awx_collection/meta/runtime.yml @@ -13,5 +13,5 @@ plugin_routing: deprecation: removal_date: TBD warning_text: see plugin documentation for details - tower_notifitcation: + tower_notification: redirect: tower_notification_template From ff4ed64978bbecc77509c030af4d2459dd28acef Mon Sep 17 00:00:00 2001 From: John Westcott IV Date: Mon, 31 Aug 2020 11:57:01 -0400 Subject: [PATCH 22/22] Cleaning up tower_notification references --- awx_collection/README.md | 4 ++-- .../targets/tower_job_template/tasks/main.yml | 10 +++++----- .../targets/tower_notification_template/tasks/main.yml | 2 +- .../targets/tower_workflow_job_template/tasks/main.yml | 10 +++++----- awx_collection/tests/sanity/ignore-2.10.txt | 2 +- .../tools/roles/template_galaxy/templates/README.md.j2 | 5 +++-- 6 files changed, 17 insertions(+), 16 deletions(-) diff --git a/awx_collection/README.md b/awx_collection/README.md index 47b517c6e6..61480a1b3d 100644 --- a/awx_collection/README.md +++ b/awx_collection/README.md @@ -91,9 +91,9 @@ The following notes are changes that may require changes to playbooks: - Specified `tower_config` file used to handle `k=v` pairs on a single line; this is no longer supported. Please use a file formatted as `yaml`, `json` or `ini` only. - Some return values (e.g., `credential_type`) have been removed. Use of `id` is recommended. - `tower_job_template` no longer supports the deprecated `extra_vars_path` parameter, please use `extra_vars` with the lookup plugin to replace this functionality. - - The `notification_configuration` parameter of `tower_notification` has changed from a string to a dict. Please use the `lookup` plugin to read an existing file into a dict. + - The `notification_configuration` parameter of `tower_notification_template` has changed from a string to a dict. Please use the `lookup` plugin to read an existing file into a dict. - `tower_credential` no longer supports passing a file name to ssh_key_data. - - The HipChat `notification_type` has been removed and can no longer be created using the `tower_notification` module. + - The HipChat `notification_type` has been removed and can no longer be created using the `tower_notification_template` module. ## Running Unit Tests diff --git a/awx_collection/tests/integration/targets/tower_job_template/tasks/main.yml b/awx_collection/tests/integration/targets/tower_job_template/tasks/main.yml index 72711451be..a744b89464 100644 --- a/awx_collection/tests/integration/targets/tower_job_template/tasks/main.yml +++ b/awx_collection/tests/integration/targets/tower_job_template/tasks/main.yml @@ -13,7 +13,7 @@ jt2: "AWX-Collection-tests-tower_job_template-jt2-{{ test_id }}" lab1: "AWX-Collection-tests-tower_job_template-lab1-{{ test_id }}" email_not: "AWX-Collection-tests-tower_job_template-email-not-{{ test_id }}" - webhook_not: "AWX-Collection-tests-tower_notification-wehbook-not-{{ test_id }}" + webhook_not: "AWX-Collection-tests-tower_notification_template-wehbook-not-{{ test_id }}" - name: Create a Demo Project tower_project: @@ -49,7 +49,7 @@ organization: Default - name: Add email notification - tower_notification: + tower_notification_template: name: "{{ email_not }}" organization: Default notification_type: email @@ -65,7 +65,7 @@ state: present - name: Add webhook notification - tower_notification: + tower_notification_template: name: "{{ webhook_not }}" organization: Default notification_type: webhook @@ -366,13 +366,13 @@ # You can't delete a label directly so no cleanup needed - name: Delete email notification - tower_notification: + tower_notification_template: name: "{{ email_not }}" organization: Default state: absent - name: Delete webhook notification - tower_notification: + tower_notification_template: name: "{{ webhook_not }}" organization: Default state: absent diff --git a/awx_collection/tests/integration/targets/tower_notification_template/tasks/main.yml b/awx_collection/tests/integration/targets/tower_notification_template/tasks/main.yml index a4d41571cf..1ab3af794c 100644 --- a/awx_collection/tests/integration/targets/tower_notification_template/tasks/main.yml +++ b/awx_collection/tests/integration/targets/tower_notification_template/tasks/main.yml @@ -9,7 +9,7 @@ irc_not: "AWX-Collection-tests-tower_notification_template-irc-not-{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}" - name: Test deprecation warnings with legacy name - tower_notification: + tower_notification_template: name: "{{ slack_not }}" organization: Default notification_type: slack diff --git a/awx_collection/tests/integration/targets/tower_workflow_job_template/tasks/main.yml b/awx_collection/tests/integration/targets/tower_workflow_job_template/tasks/main.yml index 8a7a977164..a99281f2ba 100644 --- a/awx_collection/tests/integration/targets/tower_workflow_job_template/tasks/main.yml +++ b/awx_collection/tests/integration/targets/tower_workflow_job_template/tasks/main.yml @@ -11,7 +11,7 @@ jt2_name: "AWX-Collection-tests-tower_workflow_job_template-jt2-{{ test_id }}" wfjt_name: "AWX-Collection-tests-tower_workflow_job_template-wfjt-{{ test_id }}" email_not: "AWX-Collection-tests-tower_job_template-email-not-{{ test_id }}" - webhook_not: "AWX-Collection-tests-tower_notification-wehbook-not-{{ test_id }}" + webhook_not: "AWX-Collection-tests-tower_notification_template-wehbook-not-{{ test_id }}" - name: Create an SCM Credential tower_credential: @@ -25,7 +25,7 @@ - "result is changed" - name: Add email notification - tower_notification: + tower_notification_template: name: "{{ email_not }}" organization: Default notification_type: email @@ -41,7 +41,7 @@ state: present - name: Add webhook notification - tower_notification: + tower_notification_template: name: "{{ webhook_not }}" organization: Default notification_type: webhook @@ -264,13 +264,13 @@ - "result is changed" - name: Delete email notification - tower_notification: + tower_notification_template: name: "{{ email_not }}" organization: Default state: absent - name: Delete webhook notification - tower_notification: + tower_notification_template: name: "{{ webhook_not }}" organization: Default state: absent diff --git a/awx_collection/tests/sanity/ignore-2.10.txt b/awx_collection/tests/sanity/ignore-2.10.txt index 93f7dd6d7b..8b5f90b44d 100644 --- a/awx_collection/tests/sanity/ignore-2.10.txt +++ b/awx_collection/tests/sanity/ignore-2.10.txt @@ -3,7 +3,7 @@ plugins/modules/tower_send.py validate-modules:deprecation-mismatch plugins/modules/tower_workflow_template.py validate-modules:deprecation-mismatch plugins/modules/tower_credential.py pylint:wrong-collection-deprecated-version-tag plugins/modules/tower_job_wait.py pylint:wrong-collection-deprecated-version-tag -plugins/modules/tower_notification.py pylint:wrong-collection-deprecated-version-tag +plugins/modules/tower_notification_template.py pylint:wrong-collection-deprecated-version-tag plugins/inventory/tower.py pylint:raise-missing-from plugins/inventory/tower.py pylint:super-with-arguments plugins/lookup/tower_schedule_rrule.py pylint:raise-missing-from diff --git a/awx_collection/tools/roles/template_galaxy/templates/README.md.j2 b/awx_collection/tools/roles/template_galaxy/templates/README.md.j2 index 53cc8bd076..8a5743d34f 100644 --- a/awx_collection/tools/roles/template_galaxy/templates/README.md.j2 +++ b/awx_collection/tools/roles/template_galaxy/templates/README.md.j2 @@ -80,6 +80,7 @@ Notable releases of the `{{ collection_namespace }}.{{ collection_package }}` co The following notes are changes that may require changes to playbooks: + - The module tower_notification was renamed tower_notification_template. In ansible >= 2.10 there is a seemless redirect. Ansible 2.9 does not respect the redirect. - When a project is created, it will wait for the update/sync to finish by default; this can be turned off with the `wait` parameter, if desired. - Creating a "scan" type job template is no longer supported. - Specifying a custom certificate via the `TOWER_CERTIFICATE` environment variable no longer works. @@ -100,9 +101,9 @@ The following notes are changes that may require changes to playbooks: - Specified `tower_config` file used to handle `k=v` pairs on a single line; this is no longer supported. Please use a file formatted as `yaml`, `json` or `ini` only. - Some return values (e.g., `credential_type`) have been removed. Use of `id` is recommended. - `tower_job_template` no longer supports the deprecated `extra_vars_path` parameter, please use `extra_vars` with the lookup plugin to replace this functionality. - - The `notification_configuration` parameter of `tower_notification` has changed from a string to a dict. Please use the `lookup` plugin to read an existing file into a dict. + - The `notification_configuration` parameter of `tower_notification_template` has changed from a string to a dict. Please use the `lookup` plugin to read an existing file into a dict. - `tower_credential` no longer supports passing a file name to ssh_key_data. - - The HipChat `notification_type` has been removed and can no longer be created using the `tower_notification` module. + - The HipChat `notification_type` has been removed and can no longer be created using the `tower_notification_template` module. {% if collection_package | lower() == "awx" %} ## Running Unit Tests