diff --git a/awx/ui_next/src/api/models/Inventories.js b/awx/ui_next/src/api/models/Inventories.js index becba96649..d21fedcfce 100644 --- a/awx/ui_next/src/api/models/Inventories.js +++ b/awx/ui_next/src/api/models/Inventories.js @@ -25,7 +25,9 @@ class Inventories extends InstanceGroupsMixin(Base) { } readHosts(id, params) { - return this.http.get(`${this.baseUrl}${id}/hosts/`, { params }); + return this.http.get(`${this.baseUrl}${id}/hosts/`, { + params, + }); } async readHostDetail(inventoryId, hostId) { @@ -45,7 +47,9 @@ class Inventories extends InstanceGroupsMixin(Base) { } readGroups(id, params) { - return this.http.get(`${this.baseUrl}${id}/groups/`, { params }); + return this.http.get(`${this.baseUrl}${id}/groups/`, { + params, + }); } readGroupsOptions(id) { @@ -62,6 +66,12 @@ class Inventories extends InstanceGroupsMixin(Base) { disassociate: true, }); } + + readSources(inventoryId, params) { + return this.http.get(`${this.baseUrl}${inventoryId}/inventory_sources/`, { + params, + }); + } } export default Inventories; diff --git a/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceList.jsx b/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceList.jsx new file mode 100644 index 0000000000..65fe8656f6 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceList.jsx @@ -0,0 +1,157 @@ +import React, { useCallback, useEffect } from 'react'; +import { useParams, useLocation } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import useRequest, { useDeleteItems } from '@util/useRequest'; +import { getQSConfig, parseQueryString } from '@util/qs'; +import { InventoriesAPI, InventorySourcesAPI } from '@api'; +import PaginatedDataList, { + ToolbarAddButton, + ToolbarDeleteButton, +} from '@components/PaginatedDataList'; +import useSelected from '@util/useSelected'; +import DatalistToolbar from '@components/DataListToolbar'; +import AlertModal from '@components/AlertModal/AlertModal'; +import ErrorDetail from '@components/ErrorDetail/ErrorDetail'; +import InventorySourceListItem from './InventorySourceListItem'; + +const QS_CONFIG = getQSConfig('inventory', { + not__source: '', + page: 1, + page_size: 20, + order_by: 'name', +}); + +function InventorySourceList({ i18n }) { + const { inventoryType, id } = useParams(); + const { search } = useLocation(); + + const { + isLoading, + error, + result: { sources, sourceCount, sourceChoices, sourceChoicesOptions }, + request: fetchSources, + } = useRequest( + useCallback(async () => { + const params = parseQueryString(QS_CONFIG, search); + const results = await Promise.all([ + InventoriesAPI.readSources(id, params), + InventorySourcesAPI.readOptions(), + ]); + + return { + sources: results[0].data.results, + sourceCount: results[0].data.count, + sourceChoices: results[1].data.actions.GET.source.choices, + sourceChoicesOptions: results[1].data.actions, + }; + }, [id, search]), + { + sources: [], + sourceCount: 0, + } + ); + + useEffect(() => { + fetchSources(); + }, [fetchSources]); + + const { selected, isAllSelected, handleSelect, setSelected } = useSelected( + sources + ); + + const { + isLoading: isDeleteLoading, + deleteItems: handleDeleteSources, + deletionError, + clearDeletionError, + } = useDeleteItems( + useCallback(async () => { + return Promise.all( + selected.map(({ id: sourceId }) => + InventorySourcesAPI.destroy(sourceId) + ), + [] + ); + }, [selected]), + { + fetchItems: fetchSources, + allItemsSelected: isAllSelected, + qsConfig: QS_CONFIG, + } + ); + + const handleDelete = async () => { + await handleDeleteSources(); + setSelected([]); + }; + const canAdd = + sourceChoicesOptions && + Object.prototype.hasOwnProperty.call(sourceChoicesOptions, 'POST'); + const detailUrl = `/inventories/${inventoryType}/${id}/sources/`; + return ( + <> + ( + + setSelected(isSelected ? [...sources] : []) + } + qsConfig={QS_CONFIG} + additionalControls={[ + ...(canAdd + ? [] + : []), + , + ]} + /> + )} + renderItem={inventorySource => { + let label; + sourceChoices.forEach(([scMatch, scLabel]) => { + if (inventorySource.source === scMatch) { + label = scLabel; + } + }); + return ( + handleSelect(inventorySource)} + label={label} + detailUrl={`${detailUrl}${inventorySource.id}`} + isSelected={selected.some(row => row.id === inventorySource.id)} + /> + ); + }} + /> + {deletionError && ( + + {i18n._(t`Failed to delete one or more Inventory Sources.`)} + + + )} + + ); +} +export default withI18n()(InventorySourceList); diff --git a/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceList.test.jsx b/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceList.test.jsx new file mode 100644 index 0000000000..b0500e6230 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceList.test.jsx @@ -0,0 +1,282 @@ +import React from 'react'; +import { Route } from 'react-router-dom'; +import { createMemoryHistory } from 'history'; +import { InventoriesAPI, InventorySourcesAPI } from '@api'; +import { act } from 'react-dom/test-utils'; +import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; +import InventorySourceList from './InventorySourceList'; + +jest.mock('@api/models/InventorySources'); +jest.mock('@api/models/Inventories'); +jest.mock('@api/models/InventoryUpdates'); + +describe('', () => { + let wrapper; + let history; + + beforeEach(async () => { + InventoriesAPI.readSources.mockResolvedValue({ + data: { + results: [ + { + id: 1, + name: 'Source Foo', + status: '', + source: 'ec2', + url: '/api/v2/inventory_sources/56/', + summary_fields: { + user_capabilities: { + edit: true, + delete: true, + start: true, + schedule: true, + }, + }, + }, + ], + count: 1, + }, + }); + InventorySourcesAPI.readOptions.mockResolvedValue({ + data: { + actions: { + GET: { source: { choices: [['scm', 'SCM'], ['ec2', 'EC2']] } }, + POST: {}, + }, + }, + }); + history = createMemoryHistory({ + initialEntries: ['/inventories/inventory/1/sources'], + }); + await act(async () => { + wrapper = mountWithContexts( + + + , + { + context: { + router: { + history, + route: { + location: { search: '' }, + match: { params: { id: 1 } }, + }, + }, + }, + } + ); + }); + }); + afterEach(() => { + wrapper.unmount(); + jest.clearAllMocks(); + }); + test('should mount properly', async () => { + await waitForElement(wrapper, 'InventorySourceList', el => el.length > 0); + }); + test('api calls should be made on mount', async () => { + await waitForElement(wrapper, 'InventorySourceList', el => el.length > 0); + expect(InventoriesAPI.readSources).toHaveBeenCalledWith('1', { + not__source: '', + order_by: 'name', + page: 1, + page_size: 20, + }); + expect(InventorySourcesAPI.readOptions).toHaveBeenCalled(); + }); + test('source data should render properly', async () => { + await waitForElement(wrapper, 'InventorySourceList', el => el.length > 0); + expect(wrapper.find('PFDataListCell[aria-label="name"]').text()).toBe( + 'Source Foo' + ); + expect(wrapper.find('PFDataListCell[aria-label="type"]').text()).toBe( + 'EC2' + ); + }); + test('add button is not disabled and delete button is disabled', async () => { + await waitForElement(wrapper, 'InventorySourceList', el => el.length > 0); + const addButton = wrapper.find('ToolbarAddButton').find('Link'); + const deleteButton = wrapper.find('ToolbarDeleteButton').find('Button'); + expect(addButton.prop('aria-disabled')).toBe(false); + expect(deleteButton.prop('isDisabled')).toBe(true); + }); + + test('delete button becomes enabled and properly calls api to delete', async () => { + const deleteButton = wrapper.find('ToolbarDeleteButton').find('Button'); + + await waitForElement(wrapper, 'InventorySourceList', el => el.length > 0); + expect(deleteButton.prop('isDisabled')).toBe(true); + + await act(async () => + wrapper.find('DataListCheck').prop('onChange')({ id: 1 }) + ); + wrapper.update(); + expect(wrapper.find('input#select-source-1').prop('checked')).toBe(true); + + await act(async () => + wrapper.find('Button[aria-label="Delete"]').prop('onClick')() + ); + wrapper.update(); + expect(wrapper.find('AlertModal').length).toBe(1); + + await act(async () => + wrapper.find('Button[aria-label="confirm delete"]').prop('onClick')() + ); + expect(InventorySourcesAPI.destroy).toHaveBeenCalledWith(1); + }); + test('should throw error after deletion failure', async () => { + InventorySourcesAPI.destroy.mockRejectedValue( + new Error({ + response: { + config: { + method: 'delete', + url: '/api/v2/inventory_sources/', + }, + data: 'An error occurred', + status: 403, + }, + }) + ); + + await waitForElement(wrapper, 'InventorySourceList', el => el.length > 0); + + await act(async () => + wrapper.find('DataListCheck').prop('onChange')({ id: 1 }) + ); + wrapper.update(); + + await act(async () => + wrapper.find('Button[aria-label="Delete"]').prop('onClick')() + ); + wrapper.update(); + + await act(async () => + wrapper.find('Button[aria-label="confirm delete"]').prop('onClick')() + ); + wrapper.update(); + expect(wrapper.find("AlertModal[aria-label='Delete Error']").length).toBe( + 1 + ); + }); + test('displays error after unseccessful read sources fetch', async () => { + InventorySourcesAPI.readOptions.mockRejectedValue( + new Error({ + response: { + config: { + method: 'get', + url: '/api/v2/inventories/inventory_sources/', + }, + data: 'An error occurred', + status: 403, + }, + }) + ); + InventoriesAPI.readSources.mockRejectedValue( + new Error({ + response: { + config: { + method: 'get', + url: '/api/v2/inventories/inventory_sources/', + }, + data: 'An error occurred', + status: 403, + }, + }) + ); + + await act(async () => { + wrapper = mountWithContexts(); + }); + + await waitForElement(wrapper, 'ContentError', el => el.length > 0); + + expect(wrapper.find('ContentError').length).toBe(1); + }); + + test('displays error after unseccessful read options fetch', async () => { + InventorySourcesAPI.readOptions.mockRejectedValue( + new Error({ + response: { + config: { + method: 'options', + url: '/api/v2/inventory_sources/', + }, + data: 'An error occurred', + status: 403, + }, + }) + ); + + await act(async () => { + wrapper = mountWithContexts(); + }); + + await waitForElement(wrapper, 'InventorySourceList', el => el.length > 0); + + expect(wrapper.find('ContentError').length).toBe(1); + }); +}); + +describe(' RBAC testing', () => { + test('should not render add button', async () => { + InventoriesAPI.readSources.mockResolvedValue({ + data: { + results: [ + { + id: 1, + name: 'Source Foo', + status: '', + source: 'ec2', + url: '/api/v2/inventory_sources/56/', + summary_fields: { + user_capabilities: { + edit: true, + delete: true, + start: true, + schedule: true, + }, + }, + }, + ], + count: 1, + }, + }); + InventorySourcesAPI.readOptions.mockResolvedValue({ + data: { + actions: { + GET: { source: { choices: [['scm', 'SCM'], ['ec2', 'EC2']] } }, + }, + }, + }); + let newWrapper; + const history = createMemoryHistory({ + initialEntries: ['/inventories/inventory/2/sources'], + }); + await act(async () => { + newWrapper = mountWithContexts( + + + , + { + context: { + router: { + history, + route: { + location: { search: '' }, + match: { params: { id: 2 } }, + }, + }, + }, + } + ); + }); + await waitForElement( + newWrapper, + 'InventorySourceList', + el => el.length > 0 + ); + expect(newWrapper.find('ToolbarAddButton').length).toBe(0); + newWrapper.unmount(); + jest.clearAllMocks(); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceListItem.jsx b/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceListItem.jsx new file mode 100644 index 0000000000..153311bd13 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceListItem.jsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { withI18n } from '@lingui/react'; +import { Link } from 'react-router-dom'; +import { t } from '@lingui/macro'; +import { + Button, + DataListItem, + DataListItemRow, + DataListCheck, + DataListItemCells, + DataListCell, + DataListAction, +} from '@patternfly/react-core'; +import { PencilAltIcon } from '@patternfly/react-icons'; + +function InventorySourceListItem({ + source, + isSelected, + onSelect, + i18n, + detailUrl, + label, +}) { + return ( + + + + + + + {source.name} + + + , + + {label} + , + ]} + /> + + {source.summary_fields.user_capabilities.edit && ( + + )} + + + + ); +} +export default withI18n()(InventorySourceListItem); diff --git a/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceListItem.test.jsx b/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceListItem.test.jsx new file mode 100644 index 0000000000..c0555ced67 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceListItem.test.jsx @@ -0,0 +1,85 @@ +import React from 'react'; +import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import InventorySourceListItem from './InventorySourceListItem'; + +const source = { + id: 1, + name: 'Foo', + source: 'Source Bar', + summary_fields: { user_capabilities: { start: true, edit: true } }, +}; +describe('', () => { + let wrapper; + afterEach(() => { + wrapper.unmount(); + jest.clearAllMocks(); + }); + test('should mount properly', () => { + const onSelect = jest.fn(); + wrapper = mountWithContexts( + + ); + expect(wrapper.find('InventorySourceListItem').length).toBe(1); + }); + + test('all buttons and text fields should render properly', () => { + const onSelect = jest.fn(); + wrapper = mountWithContexts( + + ); + expect(wrapper.find('DataListCheck').length).toBe(1); + expect( + wrapper + .find('DataListCell') + .at(0) + .text() + ).toBe('Foo'); + expect( + wrapper + .find('DataListCell') + .at(1) + .text() + ).toBe('Source Bar'); + expect(wrapper.find('PencilAltIcon').length).toBe(1); + }); + + test('item should be checked', () => { + const onSelect = jest.fn(); + wrapper = mountWithContexts( + + ); + expect(wrapper.find('DataListCheck').length).toBe(1); + expect(wrapper.find('DataListCheck').prop('checked')).toBe(true); + }); + + test(' should render edit buttons', () => { + const onSelect = jest.fn(); + wrapper = mountWithContexts( + + ); + expect(wrapper.find('Button[aria-label="Edit Source"]').length).toBe(0); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/InventorySources/InventorySources.jsx b/awx/ui_next/src/screens/Inventory/InventorySources/InventorySources.jsx index afd9b43a81..c2455622ad 100644 --- a/awx/ui_next/src/screens/Inventory/InventorySources/InventorySources.jsx +++ b/awx/ui_next/src/screens/Inventory/InventorySources/InventorySources.jsx @@ -1,10 +1,16 @@ -import React, { Component } from 'react'; -import { CardBody } from '@components/Card'; +import React from 'react'; +import { Switch, Route } from 'react-router-dom'; -class InventorySources extends Component { - render() { - return Coming soon :); - } +import InventorySourceList from './InventorySourceList'; + +function InventorySources() { + return ( + + + + + + ); } export default InventorySources;