From 4e8bbdaae724c4551e4e64240233e9f7325e4647 Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Fri, 8 May 2020 15:02:06 -0400 Subject: [PATCH] Add inventory source details --- awx/ui_next/src/api/models/Inventories.js | 16 ++ .../src/api/models/InventorySources.js | 10 + .../src/screens/Inventory/Inventories.jsx | 2 + .../src/screens/Inventory/Inventory.jsx | 7 +- .../InventorySource/InventorySource.jsx | 120 +++++++++ .../InventorySource/InventorySource.test.jsx | 83 ++++++ .../Inventory/InventorySource/index.js | 1 + .../InventorySourceDetail.jsx | 255 ++++++++++++++++++ .../InventorySourceDetail.test.jsx | 160 +++++++++++ .../Inventory/InventorySourceDetail/index.js | 1 + .../InventorySources/InventorySources.jsx | 6 +- .../shared/data.inventory_source.json | 118 ++++++++ 12 files changed, 776 insertions(+), 3 deletions(-) create mode 100644 awx/ui_next/src/screens/Inventory/InventorySource/InventorySource.jsx create mode 100644 awx/ui_next/src/screens/Inventory/InventorySource/InventorySource.test.jsx create mode 100644 awx/ui_next/src/screens/Inventory/InventorySource/index.js create mode 100644 awx/ui_next/src/screens/Inventory/InventorySourceDetail/InventorySourceDetail.jsx create mode 100644 awx/ui_next/src/screens/Inventory/InventorySourceDetail/InventorySourceDetail.test.jsx create mode 100644 awx/ui_next/src/screens/Inventory/InventorySourceDetail/index.js create mode 100644 awx/ui_next/src/screens/Inventory/shared/data.inventory_source.json diff --git a/awx/ui_next/src/api/models/Inventories.js b/awx/ui_next/src/api/models/Inventories.js index d21fedcfce..9769eb4707 100644 --- a/awx/ui_next/src/api/models/Inventories.js +++ b/awx/ui_next/src/api/models/Inventories.js @@ -72,6 +72,22 @@ class Inventories extends InstanceGroupsMixin(Base) { params, }); } + + async readSourceDetail(inventoryId, sourceId) { + const { + data: { results }, + } = await this.http.get( + `${this.baseUrl}${inventoryId}/inventory_sources/?id=${sourceId}` + ); + + if (Array.isArray(results) && results.length) { + return results[0]; + } + + throw new Error( + `How did you get here? Source not found for Inventory ID: ${inventoryId}` + ); + } } export default Inventories; diff --git a/awx/ui_next/src/api/models/InventorySources.js b/awx/ui_next/src/api/models/InventorySources.js index d525f992f0..4889a9434c 100644 --- a/awx/ui_next/src/api/models/InventorySources.js +++ b/awx/ui_next/src/api/models/InventorySources.js @@ -7,6 +7,8 @@ class InventorySources extends LaunchUpdateMixin(Base) { this.baseUrl = '/api/v2/inventory_sources/'; this.createSyncStart = this.createSyncStart.bind(this); + this.destroyGroups = this.destroyGroups.bind(this); + this.destroyHosts = this.destroyHosts.bind(this); } createSyncStart(sourceId, extraVars) { @@ -14,5 +16,13 @@ class InventorySources extends LaunchUpdateMixin(Base) { extra_vars: extraVars, }); } + + destroyGroups(id) { + return this.http.delete(`${this.baseUrl}${id}/groups/`); + } + + destroyHosts(id) { + return this.http.delete(`${this.baseUrl}${id}/hosts/`); + } } export default InventorySources; diff --git a/awx/ui_next/src/screens/Inventory/Inventories.jsx b/awx/ui_next/src/screens/Inventory/Inventories.jsx index a5cedf5897..f4e9f8c6f3 100644 --- a/awx/ui_next/src/screens/Inventory/Inventories.jsx +++ b/awx/ui_next/src/screens/Inventory/Inventories.jsx @@ -77,6 +77,8 @@ class Inventories extends Component { [`${inventorySourcesPath}`]: i18n._(t`Sources`), [`${inventorySourcesPath}/add`]: i18n._(t`Create New Source`), + [`${inventorySourcesPath}/${nested?.id}`]: `${nested?.name}`, + [`${inventorySourcesPath}/${nested?.id}/details`]: i18n._(t`Details`), }; this.setState({ breadcrumbConfig }); }; diff --git a/awx/ui_next/src/screens/Inventory/Inventory.jsx b/awx/ui_next/src/screens/Inventory/Inventory.jsx index ac246aa604..b062817ea9 100644 --- a/awx/ui_next/src/screens/Inventory/Inventory.jsx +++ b/awx/ui_next/src/screens/Inventory/Inventory.jsx @@ -93,7 +93,7 @@ function Inventory({ i18n, setBreadcrumb }) { return ( - {['edit', 'add', 'groups/', 'hosts/'].some(name => + {['edit', 'add', 'groups/', 'hosts/', 'sources/'].some(name => location.pathname.includes(name) ) ? null : ( @@ -138,7 +138,10 @@ function Inventory({ i18n, setBreadcrumb }) { /> , - + , { + return InventoriesAPI.readSourceDetail( + inventory.id, + match.params.sourceId + ); + }, [inventory.id, match.params.sourceId]), + null + ); + + useEffect(() => { + fetchSource(); + }, [fetchSource, match.params.sourceId]); + + useEffect(() => { + if (inventory && source) { + setBreadcrumb(inventory, source); + } + }, [inventory, source, setBreadcrumb]); + + const tabsArray = [ + { + name: ( + <> + + {i18n._(t`Back to Sources`)} + + ), + link: `${sourceListUrl}`, + id: 0, + }, + { + name: i18n._(t`Details`), + link: `${match.url}/details`, + id: 1, + }, + { + name: i18n._(t`Notifications`), + link: `${match.url}/notifications`, + id: 2, + }, + { + name: i18n._(t`Schedules`), + link: `${match.url}/schedules`, + id: 3, + }, + ]; + + if (error) { + return ; + } + + return ( + <> + {['edit'].some(name => location.pathname.includes(name)) ? null : ( + + + + + + + )} + + {isLoading && } + + {!isLoading && source && ( + + + + + + + + + {i18n._(`View inventory source details`)} + + + + + )} + + ); +} + +export default withI18n()(InventorySource); diff --git a/awx/ui_next/src/screens/Inventory/InventorySource/InventorySource.test.jsx b/awx/ui_next/src/screens/Inventory/InventorySource/InventorySource.test.jsx new file mode 100644 index 0000000000..39706bf56b --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventorySource/InventorySource.test.jsx @@ -0,0 +1,83 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; +import { InventoriesAPI } from '@api'; +import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; +import mockInventorySource from '../shared/data.inventory_source.json'; +import InventorySource from './InventorySource'; + +jest.mock('@api/models/Inventories'); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useRouteMatch: () => ({ + url: '/inventories/inventory/2/sources/123', + params: { id: 2, sourceId: 123 }, + }), +})); + +InventoriesAPI.readSourceDetail.mockResolvedValue({ + data: { ...mockInventorySource }, +}); + +const mockInventory = { + id: 2, + name: 'Mock Inventory', +}; + +describe('', () => { + let wrapper; + let history; + + beforeEach(async () => { + await act(async () => { + wrapper = mountWithContexts( + {}} /> + ); + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + wrapper.unmount(); + }); + + test('should render expected tabs', () => { + const expectedTabs = [ + 'Back to Sources', + 'Details', + 'Notifications', + 'Schedules', + ]; + wrapper.find('RoutedTabs li').forEach((tab, index) => { + expect(tab.text()).toEqual(expectedTabs[index]); + }); + }); + + test('should show content error when api throws error on initial render', async () => { + InventoriesAPI.readSourceDetail.mockRejectedValueOnce(new Error()); + await act(async () => { + wrapper = mountWithContexts( + {}} /> + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + await waitForElement(wrapper, 'ContentError', el => el.length === 1); + expect(wrapper.find('ContentError Title').text()).toEqual( + 'Something went wrong...' + ); + }); + + test('should show content error when user attempts to navigate to erroneous route', async () => { + history = createMemoryHistory({ + initialEntries: ['/inventories/inventory/2/sources/1/foobar'], + }); + await act(async () => { + wrapper = mountWithContexts( + {}} />, + { context: { router: { history } } } + ); + }); + await waitForElement(wrapper, 'ContentError', el => el.length === 1); + expect(wrapper.find('ContentError Title').text()).toEqual('Not Found'); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/InventorySource/index.js b/awx/ui_next/src/screens/Inventory/InventorySource/index.js new file mode 100644 index 0000000000..35b12c8201 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventorySource/index.js @@ -0,0 +1 @@ +export { default } from './InventorySource'; diff --git a/awx/ui_next/src/screens/Inventory/InventorySourceDetail/InventorySourceDetail.jsx b/awx/ui_next/src/screens/Inventory/InventorySourceDetail/InventorySourceDetail.jsx new file mode 100644 index 0000000000..7840007fca --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventorySourceDetail/InventorySourceDetail.jsx @@ -0,0 +1,255 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { Link, useHistory } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; + +import { + Button, + Chip, + ChipGroup, + List, + ListItem, +} from '@patternfly/react-core'; +import AlertModal from '@components/AlertModal'; +import { CardBody, CardActionsRow } from '@components/Card'; +import { VariablesDetail } from '@components/CodeMirrorInput'; +import CredentialChip from '@components/CredentialChip'; +import DeleteButton from '@components/DeleteButton'; +import { DetailList, Detail, UserDateDetail } from '@components/DetailList'; +import ErrorDetail from '@components/ErrorDetail'; +import { InventorySourcesAPI } from '@api'; + +function InventorySourceDetail({ inventorySource, i18n }) { + const { + created, + custom_virtualenv, + description, + group_by, + id, + instance_filters, + modified, + name, + overwrite, + overwrite_vars, + source, + source_path, + source_regions, + source_vars, + update_cache_timeout, + update_on_launch, + update_on_project_update, + verbosity, + summary_fields: { + created_by, + credentials, + inventory, + modified_by, + organization, + source_project, + source_script, + user_capabilities, + }, + } = inventorySource; + const [deletionError, setDeletionError] = useState(false); + const history = useHistory(); + const isMounted = useRef(null); + + useEffect(() => { + isMounted.current = true; + return () => { + isMounted.current = false; + }; + }, []); + + const handleDelete = async () => { + try { + await Promise.all([ + InventorySourcesAPI.destroyHosts(id), + InventorySourcesAPI.destroyGroups(id), + InventorySourcesAPI.destroy(id), + ]); + history.push(`/inventories/inventory/${inventory.id}/sources`); + } catch (error) { + if (isMounted.current) { + setDeletionError(error); + } + } + }; + + const VERBOSITY = { + 0: i18n._(t`0 (Warning)`), + 1: i18n._(t`1 (Info)`), + 2: i18n._(t`2 (Debug)`), + }; + + let optionsList = ''; + if ( + overwrite || + overwrite_vars || + update_on_launch || + update_on_project_update + ) { + optionsList = ( + + {overwrite && {i18n._(t`Overwrite`)}} + {overwrite_vars && ( + {i18n._(t`Overwrite variables`)} + )} + {update_on_launch && {i18n._(t`Update on launch`)}} + {update_on_project_update && ( + {i18n._(t`Update on project update`)} + )} + + ); + } + + return ( + + + + + + {organization && ( + + {organization.name} + + } + /> + )} + + {source_project && ( + + {source_project.name} + + } + /> + )} + + + + + {credentials?.length > 0 && ( + ( + + ))} + /> + )} + {source_regions && ( + + {source_regions.split(',').map(region => ( + + {region} + + ))} + + } + /> + )} + {instance_filters && ( + + {instance_filters.split(',').map(filter => ( + + {filter} + + ))} + + } + /> + )} + {group_by && ( + + {group_by.split(',').map(group => ( + + {group} + + ))} + + } + /> + )} + {optionsList && ( + + )} + {source_vars && ( + + )} + + + + + {user_capabilities?.edit && ( + + )} + {user_capabilities?.delete && ( + + {i18n._(t`Delete`)} + + )} + + {deletionError && ( + setDeletionError(false)} + > + {i18n._(t`Failed to delete inventory source ${name}.`)} + + + )} + + ); +} +export default withI18n()(InventorySourceDetail); diff --git a/awx/ui_next/src/screens/Inventory/InventorySourceDetail/InventorySourceDetail.test.jsx b/awx/ui_next/src/screens/Inventory/InventorySourceDetail/InventorySourceDetail.test.jsx new file mode 100644 index 0000000000..3d4d4482a6 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventorySourceDetail/InventorySourceDetail.test.jsx @@ -0,0 +1,160 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; +import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; +import InventorySourceDetail from './InventorySourceDetail'; +import mockInvSource from '../shared/data.inventory_source.json'; +import { InventorySourcesAPI } from '@api'; + +jest.mock('@api/models/InventorySources'); + +function assertDetail(wrapper, label, value) { + expect(wrapper.find(`Detail[label="${label}"] dt`).text()).toBe(label); + expect(wrapper.find(`Detail[label="${label}"] dd`).text()).toBe(value); +} + +describe('InventorySourceDetail', () => { + let wrapper; + + afterEach(() => { + wrapper.unmount(); + jest.clearAllMocks(); + }); + + test('should render expected details', () => { + wrapper = mountWithContexts( + + ); + expect(wrapper.find('InventorySourceDetail')).toHaveLength(1); + assertDetail(wrapper, 'Name', 'mock inv source'); + assertDetail(wrapper, 'Description', 'mock description'); + assertDetail(wrapper, 'Source', 'scm'); + assertDetail(wrapper, 'Organization', 'Mock Org'); + assertDetail(wrapper, 'Ansible environment', '/venv/custom'); + assertDetail(wrapper, 'Project', 'Mock Project'); + assertDetail(wrapper, 'Inventory file', 'foo'); + assertDetail(wrapper, 'Custom inventory script', 'Mock Script'); + assertDetail(wrapper, 'Verbosity', '2 (Debug)'); + assertDetail(wrapper, 'Cache timeout', '2 seconds'); + expect( + wrapper + .find('Detail[label="Regions"]') + .containsAllMatchingElements([ + us-east-1, + us-east-2, + ]) + ).toEqual(true); + expect( + wrapper + .find('Detail[label="Instance filters"]') + .containsAllMatchingElements([ + filter1, + filter2, + filter3, + ]) + ).toEqual(true); + expect( + wrapper + .find('Detail[label="Only group by"]') + .containsAllMatchingElements([ + group1, + group2, + group3, + ]) + ).toEqual(true); + expect(wrapper.find('CredentialChip').text()).toBe('Cloud: mock cred'); + expect(wrapper.find('VariablesDetail').prop('value')).toEqual( + '---\nfoo: bar' + ); + expect( + wrapper + .find('Detail[label="Options"]') + .containsAllMatchingElements([ +
  • Overwrite
  • , +
  • Overwrite variables
  • , +
  • Update on launch
  • , +
  • Update on project update
  • , + ]) + ).toEqual(true); + }); + + test('should show edit and delete button for users with permissions', () => { + wrapper = mountWithContexts( + + ); + const editButton = wrapper.find('Button[aria-label="edit"]'); + expect(editButton.text()).toEqual('Edit'); + expect(editButton.prop('to')).toBe( + '/inventories/inventory/2/source/123/edit' + ); + expect(wrapper.find('DeleteButton')).toHaveLength(1); + }); + + test('should hide edit and delete button for users without permissions', () => { + const userCapabilities = { + edit: false, + delete: false, + }; + const invSource = { + ...mockInvSource, + summary_fields: { ...userCapabilities }, + }; + wrapper = mountWithContexts( + + ); + expect(wrapper.find('Button[aria-label="edit"]')).toHaveLength(0); + expect(wrapper.find('DeleteButton')).toHaveLength(0); + }); + + test('expected api call is made for delete', async () => { + const history = createMemoryHistory({ + initialEntries: ['/inventories/inventory/2/sources/123/details'], + }); + act(() => { + wrapper = mountWithContexts( + , + { + context: { router: { history } }, + } + ); + }); + expect(history.location.pathname).toEqual( + '/inventories/inventory/2/sources/123/details' + ); + await act(async () => { + wrapper.find('DeleteButton').invoke('onConfirm')(); + }); + expect(InventorySourcesAPI.destroy).toHaveBeenCalledTimes(1); + expect(InventorySourcesAPI.destroyHosts).toHaveBeenCalledTimes(1); + expect(InventorySourcesAPI.destroyGroups).toHaveBeenCalledTimes(1); + expect(history.location.pathname).toEqual( + '/inventories/inventory/2/sources' + ); + }); + + test('Error dialog shown for failed deletion', async () => { + InventorySourcesAPI.destroy.mockImplementationOnce(() => + Promise.reject(new Error()) + ); + wrapper = mountWithContexts( + + ); + expect(wrapper.find('Modal[title="Error!"]')).toHaveLength(0); + await act(async () => { + wrapper.find('DeleteButton').invoke('onConfirm')(); + }); + await waitForElement( + wrapper, + 'Modal[title="Error!"]', + el => el.length === 1 + ); + await act(async () => { + wrapper.find('Modal[title="Error!"]').invoke('onClose')(); + }); + await waitForElement( + wrapper, + 'Modal[title="Error!"]', + el => el.length === 0 + ); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/InventorySourceDetail/index.js b/awx/ui_next/src/screens/Inventory/InventorySourceDetail/index.js new file mode 100644 index 0000000000..1be625cff0 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventorySourceDetail/index.js @@ -0,0 +1 @@ +export { default } from './InventorySourceDetail'; diff --git a/awx/ui_next/src/screens/Inventory/InventorySources/InventorySources.jsx b/awx/ui_next/src/screens/Inventory/InventorySources/InventorySources.jsx index 3fe2cdc1bd..982610eb96 100644 --- a/awx/ui_next/src/screens/Inventory/InventorySources/InventorySources.jsx +++ b/awx/ui_next/src/screens/Inventory/InventorySources/InventorySources.jsx @@ -1,14 +1,18 @@ import React from 'react'; import { Switch, Route } from 'react-router-dom'; +import InventorySource from '../InventorySource'; import InventorySourceAdd from '../InventorySourceAdd'; import InventorySourceList from './InventorySourceList'; -function InventorySources() { +function InventorySources({ inventory, setBreadcrumb }) { return ( + + + diff --git a/awx/ui_next/src/screens/Inventory/shared/data.inventory_source.json b/awx/ui_next/src/screens/Inventory/shared/data.inventory_source.json new file mode 100644 index 0000000000..c6fbf26365 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/shared/data.inventory_source.json @@ -0,0 +1,118 @@ +{ + "id":123, + "type":"inventory_source", + "url":"/api/v2/inventory_sources/123/", + "related":{ + "named_url":"/api/v2/inventory_sources/src++Demo Inventory++Default/", + "created_by":"/api/v2/users/1/", + "modified_by":"/api/v2/users/1/", + "update":"/api/v2/inventory_sources/123/update/", + "inventory_updates":"/api/v2/inventory_sources/123/inventory_updates/", + "schedules":"/api/v2/inventory_sources/123/schedules/", + "activity_stream":"/api/v2/inventory_sources/123/activity_stream/", + "hosts":"/api/v2/inventory_sources/123/hosts/", + "groups":"/api/v2/inventory_sources/123/groups/", + "notification_templates_started":"/api/v2/inventory_sources/123/notification_templates_started/", + "notification_templates_success":"/api/v2/inventory_sources/123/notification_templates_success/", + "notification_templates_error":"/api/v2/inventory_sources/123/notification_templates_error/", + "inventory":"/api/v2/inventories/1/", + "source_project":"/api/v2/projects/8/", + "credentials":"/api/v2/inventory_sources/123/credentials/" + }, + "summary_fields":{ + "organization":{ + "id":1, + "name":"Mock Org", + "description":"" + }, + "inventory":{ + "id":2, + "name":"Mock Inventory", + "description":"", + "has_active_failures":false, + "total_hosts":1, + "hosts_with_active_failures":0, + "total_groups":2, + "has_inventory_sources":true, + "total_inventory_sources":5, + "inventory_sources_with_failures":0, + "organization_id":1, + "kind":"" + }, + "source_project":{ + "id":8, + "name":"Mock Project", + "description":"", + "status":"never updated", + "scm_type":"git" + }, + "source_script": { + "name": "Mock Script", + "description": "" + }, + "created_by":{ + "id":1, + "username":"admin", + "first_name":"", + "last_name":"" + }, + "modified_by":{ + "id":1, + "username":"admin", + "first_name":"", + "last_name":"" + }, + "user_capabilities":{ + "edit":true, + "delete":true, + "start":true, + "schedule":true + }, + "credential": { + "id": 8, + "name": "mock cred", + "description": "", + "kind": "vmware", + "cloud": true, + "credential_type_id": 7 + }, + "credentials":[ + { + "id": 8, + "name": "mock cred", + "description": "", + "kind": "vmware", + "cloud": true, + "credential_type_id": 7 + } + ] + }, + "created":"2020-04-02T18:59:08.474167Z", + "modified":"2020-04-02T19:52:23.924252Z", + "name":"mock inv source", + "description":"mock description", + "source":"scm", + "source_path": "foo", + "source_script": "Mock Script", + "source_vars":"---\nfoo: bar", + "credential": 8, + "source_regions": "us-east-1,us-east-2", + "instance_filters": "filter1,filter2,filter3", + "group_by": "group1,group2,group3", + "overwrite":true, + "overwrite_vars":true, + "custom_virtualenv":"/venv/custom", + "timeout":0, + "verbosity":2, + "last_job_run":null, + "last_job_failed":false, + "next_job_run":null, + "status":"never updated", + "inventory":1, + "update_on_launch":true, + "update_cache_timeout":2, + "source_project":8, + "update_on_project_update":true, + "last_update_failed": true, + "last_updated":null +} \ No newline at end of file