From 8a4d45ddb6efda72efe461a74c90c1527415d8c7 Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Mon, 3 Aug 2020 17:46:28 -0400 Subject: [PATCH] Add smart inventory host list view --- .../src/components/HostToggle/HostToggle.jsx | 8 +- .../components/HostToggle/HostToggle.test.jsx | 14 +- .../screens/Host/HostList/HostListItem.jsx | 6 +- .../src/screens/Inventory/Inventories.jsx | 9 +- .../InventoryHostDetail.test.jsx | 13 +- .../src/screens/Inventory/SmartInventory.jsx | 2 +- .../SmartInventoryHostList.jsx | 120 ++++++++++++++++ .../SmartInventoryHostList.test.jsx | 136 ++++++++++++++++++ .../SmartInventoryHostListItem.jsx | 84 +++++++++++ .../SmartInventoryHostListItem.test.jsx | 52 +++++++ .../SmartInventoryHosts.jsx | 20 ++- .../SmartInventoryHosts.test.jsx | 28 ++++ 12 files changed, 466 insertions(+), 26 deletions(-) create mode 100644 awx/ui_next/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHostList.jsx create mode 100644 awx/ui_next/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHostList.test.jsx create mode 100644 awx/ui_next/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHostListItem.jsx create mode 100644 awx/ui_next/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHostListItem.test.jsx create mode 100644 awx/ui_next/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHosts.test.jsx diff --git a/awx/ui_next/src/components/HostToggle/HostToggle.jsx b/awx/ui_next/src/components/HostToggle/HostToggle.jsx index d08a15a70c..43452ee3f0 100644 --- a/awx/ui_next/src/components/HostToggle/HostToggle.jsx +++ b/awx/ui_next/src/components/HostToggle/HostToggle.jsx @@ -8,7 +8,7 @@ import ErrorDetail from '../ErrorDetail'; import useRequest from '../../util/useRequest'; import { HostsAPI } from '../../api'; -function HostToggle({ host, onToggle, className, i18n }) { +function HostToggle({ host, isDisabled = false, onToggle, className, i18n }) { const [isEnabled, setIsEnabled] = useState(host.enabled); const [showError, setShowError] = useState(false); @@ -54,7 +54,11 @@ function HostToggle({ host, onToggle, className, i18n }) { label={i18n._(t`On`)} labelOff={i18n._(t`Off`)} isChecked={isEnabled} - isDisabled={isLoading || !host.summary_fields.user_capabilities.edit} + isDisabled={ + isLoading || + isDisabled || + !host.summary_fields.user_capabilities.edit + } onChange={toggleHost} aria-label={i18n._(t`Toggle host`)} /> diff --git a/awx/ui_next/src/components/HostToggle/HostToggle.test.jsx b/awx/ui_next/src/components/HostToggle/HostToggle.test.jsx index 63dd971285..7391036bf2 100644 --- a/awx/ui_next/src/components/HostToggle/HostToggle.test.jsx +++ b/awx/ui_next/src/components/HostToggle/HostToggle.test.jsx @@ -19,7 +19,7 @@ const mockHost = { }, user_capabilities: { delete: true, - update: true, + edit: true, }, recent_jobs: [], }, @@ -68,6 +68,18 @@ describe('', () => { expect(onToggle).toHaveBeenCalledWith(true); }); + test('should be enabled', async () => { + const wrapper = mountWithContexts(); + expect(wrapper.find('Switch').prop('isDisabled')).toEqual(false); + }); + + test('should be disabled', async () => { + const wrapper = mountWithContexts( + + ); + expect(wrapper.find('Switch').prop('isDisabled')).toEqual(true); + }); + test('should show error modal', async () => { HostsAPI.update.mockImplementation(() => { throw new Error('nope'); diff --git a/awx/ui_next/src/screens/Host/HostList/HostListItem.jsx b/awx/ui_next/src/screens/Host/HostList/HostListItem.jsx index e2751f6f06..377fb453ab 100644 --- a/awx/ui_next/src/screens/Host/HostList/HostListItem.jsx +++ b/awx/ui_next/src/screens/Host/HostList/HostListItem.jsx @@ -54,11 +54,7 @@ function HostListItem({ i18n, host, isSelected, onSelect, detailUrl }) { {i18n._(t`Inventory`)} {host.summary_fields.inventory.name} diff --git a/awx/ui_next/src/screens/Inventory/Inventories.jsx b/awx/ui_next/src/screens/Inventory/Inventories.jsx index 0ad93adbe9..30a46b64c3 100644 --- a/awx/ui_next/src/screens/Inventory/Inventories.jsx +++ b/awx/ui_next/src/screens/Inventory/Inventories.jsx @@ -105,14 +105,7 @@ function Inventories({ i18n }) { - - {({ me }) => ( - - )} - + diff --git a/awx/ui_next/src/screens/Inventory/InventoryHostDetail/InventoryHostDetail.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryHostDetail/InventoryHostDetail.test.jsx index ae7d60b1f7..a6a7c7ef73 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryHostDetail/InventoryHostDetail.test.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryHostDetail/InventoryHostDetail.test.jsx @@ -77,11 +77,18 @@ describe('', () => { describe('User has read-only permissions', () => { beforeAll(() => { - const readOnlyHost = { ...mockHost }; + const readOnlyHost = { + ...mockHost, + summary_fields: { + ...mockHost.summary_fields, + user_capabilities: { + ...mockHost.summary_fields.user_capabilities, + }, + }, + }; readOnlyHost.summary_fields.user_capabilities.edit = false; readOnlyHost.summary_fields.recent_jobs = []; - - wrapper = mountWithContexts(); + wrapper = mountWithContexts(); }); afterAll(() => { diff --git a/awx/ui_next/src/screens/Inventory/SmartInventory.jsx b/awx/ui_next/src/screens/Inventory/SmartInventory.jsx index acb08661b6..18291a2959 100644 --- a/awx/ui_next/src/screens/Inventory/SmartInventory.jsx +++ b/awx/ui_next/src/screens/Inventory/SmartInventory.jsx @@ -47,7 +47,7 @@ function SmartInventory({ i18n, setBreadcrumb }) { useEffect(() => { fetchInventory(); - }, [fetchInventory, location.pathname]); + }, [fetchInventory]); useEffect(() => { if (inventory) { diff --git a/awx/ui_next/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHostList.jsx b/awx/ui_next/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHostList.jsx new file mode 100644 index 0000000000..aa6290669c --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHostList.jsx @@ -0,0 +1,120 @@ +import React, { useEffect, useCallback } from 'react'; +import { useLocation } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Button } from '@patternfly/react-core'; +import DataListToolbar from '../../../components/DataListToolbar'; +import PaginatedDataList from '../../../components/PaginatedDataList'; +import SmartInventoryHostListItem from './SmartInventoryHostListItem'; +import useRequest from '../../../util/useRequest'; +import useSelected from '../../../util/useSelected'; +import { getQSConfig, parseQueryString } from '../../../util/qs'; +import { InventoriesAPI } from '../../../api'; +import { Inventory } from '../../../types'; + +const QS_CONFIG = getQSConfig('host', { + page: 1, + page_size: 20, + order_by: 'name', +}); + +function SmartInventoryHostList({ i18n, inventory }) { + const location = useLocation(); + + const { + result: { hosts, count }, + error: contentError, + isLoading, + request: fetchHosts, + } = useRequest( + useCallback(async () => { + const params = parseQueryString(QS_CONFIG, location.search); + const { data } = await InventoriesAPI.readHosts(inventory.id, params); + return { + hosts: data.results, + count: data.count, + }; + }, [location.search, inventory.id]), + { + hosts: [], + count: 0, + } + ); + + const { selected, isAllSelected, handleSelect, setSelected } = useSelected( + hosts + ); + + useEffect(() => { + fetchHosts(); + }, [fetchHosts]); + + return ( + ( + setSelected(isSelected ? [...hosts] : [])} + qsConfig={QS_CONFIG} + additionalControls={ + inventory?.summary_fields?.user_capabilities?.adhoc + ? [ + , + ] + : [] + } + /> + )} + renderItem={host => ( + row.id === host.id)} + onSelect={() => handleSelect(host)} + /> + )} + /> + ); +} + +SmartInventoryHostList.propTypes = { + inventory: Inventory.isRequired, +}; + +export default withI18n()(SmartInventoryHostList); diff --git a/awx/ui_next/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHostList.test.jsx b/awx/ui_next/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHostList.test.jsx new file mode 100644 index 0000000000..ae3f00d66f --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHostList.test.jsx @@ -0,0 +1,136 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { InventoriesAPI } from '../../../api'; +import { + mountWithContexts, + waitForElement, +} from '../../../../testUtils/enzymeHelpers'; +import SmartInventoryHostList from './SmartInventoryHostList'; +import mockInventory from '../shared/data.inventory.json'; +import mockHosts from '../shared/data.hosts.json'; + +jest.mock('../../../api'); + +describe('', () => { + describe('User has adhoc permissions', () => { + let wrapper; + const clonedInventory = { + ...mockInventory, + summary_fields: { + ...mockInventory.summary_fields, + user_capabilities: { + ...mockInventory.summary_fields.user_capabilities, + }, + }, + }; + + beforeAll(async () => { + InventoriesAPI.readHosts.mockResolvedValue({ + data: mockHosts, + }); + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); + + afterAll(() => { + jest.clearAllMocks(); + wrapper.unmount(); + }); + + test('initially renders successfully', () => { + expect(wrapper.find('SmartInventoryHostList').length).toBe(1); + }); + + test('should fetch hosts from api and render them in the list', () => { + expect(InventoriesAPI.readHosts).toHaveBeenCalled(); + expect(wrapper.find('SmartInventoryHostListItem').length).toBe(3); + }); + + test('should disable run commands button when no hosts are selected', () => { + wrapper.find('DataListCheck').forEach(el => { + expect(el.props().checked).toBe(false); + }); + const runCommandsButton = wrapper.find( + 'button[aria-label="Run commands"]' + ); + expect(runCommandsButton.length).toBe(1); + expect(runCommandsButton.prop('disabled')).toEqual(true); + }); + + test('should enable run commands button when at least one host is selected', () => { + act(() => { + wrapper.find('DataListCheck[id="select-host-2"]').invoke('onChange')( + true + ); + }); + wrapper.update(); + const runCommandsButton = wrapper.find( + 'button[aria-label="Run commands"]' + ); + expect(runCommandsButton.prop('disabled')).toEqual(false); + }); + + test('should select and deselect all items', async () => { + act(() => { + wrapper.find('DataListToolbar').invoke('onSelectAll')(true); + }); + wrapper.update(); + wrapper.find('DataListCheck').forEach(el => { + expect(el.props().checked).toEqual(true); + }); + act(() => { + wrapper.find('DataListToolbar').invoke('onSelectAll')(false); + }); + wrapper.update(); + wrapper.find('DataListCheck').forEach(el => { + expect(el.props().checked).toEqual(false); + }); + }); + + test('should show content error when api throws an error', async () => { + InventoriesAPI.readHosts.mockImplementation(() => + Promise.reject(new Error()) + ); + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + await waitForElement(wrapper, 'ContentError', el => el.length === 1); + }); + }); + + describe('User does not have adhoc permissions', () => { + let wrapper; + const clonedInventory = { + ...mockInventory, + summary_fields: { + user_capabilities: { + adhoc: false, + }, + }, + }; + + test('should hide run commands button', async () => { + InventoriesAPI.readHosts.mockResolvedValue({ + data: { results: [], count: 0 }, + }); + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + const runCommandsButton = wrapper.find( + 'button[aria-label="Run commands"]' + ); + expect(runCommandsButton.length).toBe(0); + jest.clearAllMocks(); + wrapper.unmount(); + }); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHostListItem.jsx b/awx/ui_next/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHostListItem.jsx new file mode 100644 index 0000000000..960218206b --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHostListItem.jsx @@ -0,0 +1,84 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { string, bool, func } from 'prop-types'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import 'styled-components/macro'; + +import { + DataListAction, + DataListCheck, + DataListItem, + DataListItemCells, + DataListItemRow, +} from '@patternfly/react-core'; +import DataListCell from '../../../components/DataListCell'; +import HostToggle from '../../../components/HostToggle'; +import Sparkline from '../../../components/Sparkline'; +import { Host } from '../../../types'; + +function SmartInventoryHostListItem({ + i18n, + detailUrl, + host, + isSelected, + onSelect, +}) { + const recentPlaybookJobs = host.summary_fields.recent_jobs.map(job => ({ + ...job, + type: 'job', + })); + + const labelId = `check-action-${host.id}`; + + return ( + + + + + + {host.name} + + , + + + , + + <> + {i18n._(t`Inventory`)} + + {host.summary_fields.inventory.name} + + + , + ]} + /> + + + + + + ); +} + +SmartInventoryHostListItem.propTypes = { + detailUrl: string.isRequired, + host: Host.isRequired, + isSelected: bool.isRequired, + onSelect: func.isRequired, +}; + +export default withI18n()(SmartInventoryHostListItem); diff --git a/awx/ui_next/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHostListItem.test.jsx b/awx/ui_next/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHostListItem.test.jsx new file mode 100644 index 0000000000..a2462a831a --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHostListItem.test.jsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import SmartInventoryHostListItem from './SmartInventoryHostListItem'; + +const mockHost = { + id: 2, + name: 'Host Two', + url: '/api/v2/hosts/2', + inventory: 1, + summary_fields: { + inventory: { + id: 1, + name: 'Inv 1', + }, + user_capabilities: { + edit: true, + }, + recent_jobs: [], + }, +}; + +describe('', () => { + let wrapper; + + beforeEach(() => { + wrapper = mountWithContexts( + {}} + /> + ); + }); + + afterEach(() => { + wrapper.unmount(); + }); + + test('should render expected row cells', () => { + const cells = wrapper.find('DataListCell'); + expect(cells).toHaveLength(3); + expect(cells.at(0).text()).toEqual('Host Two'); + expect(cells.at(1).find('Sparkline').length).toEqual(1); + expect(cells.at(2).text()).toContain('Inv 1'); + }); + + test('should display disabled host toggle', () => { + expect(wrapper.find('HostToggle').length).toBe(1); + expect(wrapper.find('HostToggle Switch').prop('isDisabled')).toEqual(true); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHosts.jsx b/awx/ui_next/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHosts.jsx index 608e664e95..0aa24cdc59 100644 --- a/awx/ui_next/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHosts.jsx +++ b/awx/ui_next/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHosts.jsx @@ -1,10 +1,18 @@ -import React, { Component } from 'react'; -import { CardBody } from '../../../components/Card'; +import React from 'react'; +import { Route } from 'react-router-dom'; +import SmartInventoryHostList from './SmartInventoryHostList'; +import { Inventory } from '../../../types'; -class SmartInventoryHosts extends Component { - render() { - return Coming soon :); - } +function SmartInventoryHosts({ inventory }) { + return ( + + + + ); } +SmartInventoryHosts.propTypes = { + inventory: Inventory.isRequired, +}; + export default SmartInventoryHosts; diff --git a/awx/ui_next/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHosts.test.jsx b/awx/ui_next/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHosts.test.jsx new file mode 100644 index 0000000000..8fed1ef7f7 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHosts.test.jsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { createMemoryHistory } from 'history'; +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import SmartInventoryHosts from './SmartInventoryHosts'; + +jest.mock('../../../api'); + +describe('', () => { + test('should render smart inventory host list', () => { + const history = createMemoryHistory({ + initialEntries: ['/inventories/smart_inventory/1/hosts'], + }); + const match = { + path: '/inventories/smart_inventory/:id/hosts', + url: '/inventories/smart_inventory/1/hosts', + isExact: true, + }; + const wrapper = mountWithContexts( + , + { + context: { router: { history, route: { match } } }, + } + ); + expect(wrapper.find('SmartInventoryHostList').length).toBe(1); + jest.clearAllMocks(); + wrapper.unmount(); + }); +});