From 11b2b17d08f999428d333bad68c2002f49b4eb0e Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Wed, 18 Mar 2020 11:09:00 -0400 Subject: [PATCH 1/4] Add associate modal to nested inventory host list --- awx/ui_next/src/api/models/Groups.js | 5 + .../components/Lookup/shared/OptionsList.jsx | 2 + .../InventoryGroupHosts/AssociateModal.jsx | 141 ++++++++++++++++++ .../AssociateModal.test.jsx | 73 +++++++++ .../InventoryGroupHostList.jsx | 90 +++++++---- .../InventoryGroupHostList.test.jsx | 36 ++++- .../screens/Inventory/shared/useSelect.jsx | 27 ++++ .../Inventory/shared/useSelect.test.jsx | 75 ++++++++++ 8 files changed, 414 insertions(+), 35 deletions(-) create mode 100644 awx/ui_next/src/screens/Inventory/InventoryGroupHosts/AssociateModal.jsx create mode 100644 awx/ui_next/src/screens/Inventory/InventoryGroupHosts/AssociateModal.test.jsx create mode 100644 awx/ui_next/src/screens/Inventory/shared/useSelect.jsx create mode 100644 awx/ui_next/src/screens/Inventory/shared/useSelect.test.jsx diff --git a/awx/ui_next/src/api/models/Groups.js b/awx/ui_next/src/api/models/Groups.js index fae9fcdf6f..ea506cc303 100644 --- a/awx/ui_next/src/api/models/Groups.js +++ b/awx/ui_next/src/api/models/Groups.js @@ -5,11 +5,16 @@ class Groups extends Base { super(http); this.baseUrl = '/api/v2/groups/'; + this.associateHost = this.associateHost.bind(this); this.createHost = this.createHost.bind(this); this.readAllHosts = this.readAllHosts.bind(this); this.disassociateHost = this.disassociateHost.bind(this); } + associateHost(id, hostId) { + return this.http.post(`${this.baseUrl}${id}/hosts/`, { id: hostId }); + } + createHost(id, data) { return this.http.post(`${this.baseUrl}${id}/hosts/`, data); } diff --git a/awx/ui_next/src/components/Lookup/shared/OptionsList.jsx b/awx/ui_next/src/components/Lookup/shared/OptionsList.jsx index 6d72c9cc52..da0e1065e6 100644 --- a/awx/ui_next/src/components/Lookup/shared/OptionsList.jsx +++ b/awx/ui_next/src/components/Lookup/shared/OptionsList.jsx @@ -25,6 +25,7 @@ const ModalList = styled.div` function OptionsList({ value, + contentError, options, optionCount, searchColumns, @@ -53,6 +54,7 @@ function OptionsList({ /> )} { + const params = parseQueryString(QS_CONFIG, history.location.search); + const { + data: { count, results }, + } = await fetchRequest(params); + + return { + items: results, + itemCount: count, + }; + }, [fetchRequest, history.location.search]), + { + items: [], + itemCount: 0, + } + ); + + useEffect(() => { + fetchItems(); + }, [fetchItems]); + + const clearQSParams = () => { + const parts = history.location.search.replace(/^\?/, '').split('&'); + const ns = QS_CONFIG.namespace; + const otherParts = parts.filter(param => !param.startsWith(`${ns}.`)); + history.replace(`${history.location.pathname}?${otherParts.join('&')}`); + }; + + const handleSave = async () => { + await onAssociate(selected); + clearQSParams(); + onClose(); + }; + + const handleClose = () => { + clearQSParams(); + onClose(); + }; + + return ( + + + {i18n._(t`Save`)} + , + , + ]} + > + handleSelect(item)} + header={header} + isLoading={isLoading} + multiple + optionCount={itemCount} + options={items} + qsConfig={QS_CONFIG} + readOnly={false} + selectItem={item => handleSelect(item)} + value={selected} + searchColumns={[ + { + name: i18n._(t`Name`), + key: 'name', + isDefault: true, + }, + { + name: i18n._(t`Created By (Username)`), + key: 'created_by__username', + }, + { + name: i18n._(t`Modified By (Username)`), + key: 'modified_by__username', + }, + ]} + sortColumns={[ + { + name: i18n._(t`Name`), + key: 'name', + }, + ]} + /> + + + ); +} + +export default withI18n()(AssociateModal); diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/AssociateModal.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/AssociateModal.test.jsx new file mode 100644 index 0000000000..7f3eca721d --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/AssociateModal.test.jsx @@ -0,0 +1,73 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; + +import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; +import AssociateModal from './AssociateModal'; +import mockHosts from '../shared/data.hosts.json'; + +jest.mock('@api'); + +describe('', () => { + let wrapper; + const onClose = jest.fn(); + const onAssociate = jest.fn().mockResolvedValue(); + const fetchRequest = jest.fn().mockReturnValue({ data: { ...mockHosts } }); + + beforeEach(async () => { + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); + + afterEach(() => { + wrapper.unmount(); + jest.clearAllMocks(); + }); + + test('should render successfully', () => { + expect(wrapper.find('AssociateModal').length).toBe(1); + }); + + test('should fetch and render list items', () => { + expect(fetchRequest).toHaveBeenCalledTimes(1); + expect(wrapper.find('CheckboxListItem').length).toBe(3); + }); + + test('should update selected list chips when items are selected', () => { + expect(wrapper.find('SelectedList Chip')).toHaveLength(0); + act(() => { + wrapper + .find('CheckboxListItem') + .first() + .invoke('onSelect')(); + }); + wrapper.update(); + expect(wrapper.find('SelectedList Chip')).toHaveLength(1); + wrapper.find('SelectedList Chip button').simulate('click'); + expect(wrapper.find('SelectedList Chip')).toHaveLength(0); + }); + + test('save button should call onAssociate', () => { + act(() => { + wrapper + .find('CheckboxListItem') + .first() + .invoke('onSelect')(); + }); + wrapper.find('button[aria-label="Save"]').simulate('click'); + expect(onAssociate).toHaveBeenCalledTimes(1); + }); + + test('cancel button should call onClose', () => { + wrapper.find('button[aria-label="Cancel"]').simulate('click'); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.jsx index 1f4ce9d8b9..3f54e49c90 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.jsx @@ -2,17 +2,22 @@ import React, { useEffect, useCallback, useState } from 'react'; import { useHistory, useLocation, useParams } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; -import { getQSConfig, parseQueryString } from '@util/qs'; +import { getQSConfig, mergeParams, parseQueryString } from '@util/qs'; import { GroupsAPI, InventoriesAPI } from '@api'; +import useRequest, { + useDeleteItems, + useDismissableError, +} from '@util/useRequest'; import AlertModal from '@components/AlertModal'; import DataListToolbar from '@components/DataListToolbar'; import ErrorDetail from '@components/ErrorDetail'; import PaginatedDataList from '@components/PaginatedDataList'; -import useRequest, { useDeleteItems } from '@util/useRequest'; import InventoryGroupHostListItem from './InventoryGroupHostListItem'; +import AssociateModal from './AssociateModal'; import AddHostDropdown from './AddHostDropdown'; import DisassociateButton from './DisassociateButton'; +import useSelect from '../shared/useSelect'; const QS_CONFIG = getQSConfig('host', { page: 1, @@ -21,7 +26,6 @@ const QS_CONFIG = getQSConfig('host', { }); function InventoryGroupHostList({ i18n }) { - const [selected, setSelected] = useState([]); const [isModalOpen, setIsModalOpen] = useState(false); const { id: inventoryId, groupId } = useParams(); const location = useLocation(); @@ -52,29 +56,18 @@ function InventoryGroupHostList({ i18n }) { } ); + const { selected, isAllSelected, handleSelect, setSelected } = useSelect( + hosts + ); + useEffect(() => { fetchHosts(); }, [fetchHosts]); - const handleSelectAll = isSelected => { - setSelected(isSelected ? [...hosts] : []); - }; - - const handleSelect = row => { - if (selected.some(s => s.id === row.id)) { - setSelected(selected.filter(s => s.id !== row.id)); - } else { - setSelected(selected.concat(row)); - } - }; - - const isAllSelected = selected.length > 0 && selected.length === hosts.length; - const { isLoading: isDisassociateLoading, deleteItems: disassociateHosts, deletionError: disassociateError, - clearDeletionError: clearDisassociateError, } = useDeleteItems( useCallback(async () => { return Promise.all( @@ -93,6 +86,34 @@ function InventoryGroupHostList({ i18n }) { setSelected([]); }; + const fetchHostsToAssociate = useCallback( + params => { + return InventoriesAPI.readHosts( + inventoryId, + mergeParams(params, { not__groups: groupId }) + ); + }, + [groupId, inventoryId] + ); + + const { request: handleAssociate, error: associateError } = useRequest( + useCallback( + async hostsToAssociate => { + await Promise.all( + hostsToAssociate.map(host => + GroupsAPI.associateHost(groupId, host.id) + ) + ); + fetchHosts(); + }, + [groupId, fetchHosts] + ) + ); + + const { error, dismissError } = useDismissableError( + associateError || disassociateError + ); + const canAdd = actions && Object.prototype.hasOwnProperty.call(actions, 'POST'); const addFormUrl = `/inventories/inventory/${inventoryId}/groups/${groupId}/nested_hosts/add`; @@ -133,7 +154,9 @@ function InventoryGroupHostList({ i18n }) { {...props} showSelectAll isAllSelected={isAllSelected} - onSelectAll={handleSelectAll} + onSelectAll={isSelected => + setSelected(isSelected ? [...hosts] : []) + } qsConfig={QS_CONFIG} additionalControls={[ ...(canAdd @@ -179,25 +202,26 @@ function InventoryGroupHostList({ i18n }) { } /> {isModalOpen && ( - setIsModalOpen(false)} - > - {/* ADD/ASSOCIATE HOST MODAL PLACEHOLDER */} - {i18n._(t`Host Select Modal`)} - + title={i18n._(t`Select Hosts`)} + /> )} - {disassociateError && ( + {(associateError || disassociateError) && ( - {i18n._(t`Failed to disassociate one or more hosts.`)} - + {associateError + ? i18n._(t`Failed to associate.`) + : i18n._(t`Failed to disassociate one or more hosts.`)} + )} diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.test.jsx index f0a436a9d1..f51c48bb2a 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.test.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.test.jsx @@ -155,9 +155,41 @@ describe('', () => { wrapper .find('DropdownItem[aria-label="add existing host"]') .simulate('click'); - expect(wrapper.find('AlertModal').length).toBe(1); + expect(wrapper.find('AssociateModal').length).toBe(1); wrapper.find('ModalBoxCloseButton').simulate('click'); - expect(wrapper.find('AlertModal').length).toBe(0); + expect(wrapper.find('AssociateModal').length).toBe(0); + }); + + test('should make expected api request when associating hosts', async () => { + GroupsAPI.associateHost.mockResolvedValue(); + InventoriesAPI.readHosts.mockResolvedValue({ + data: { + count: 1, + results: [{ id: 123, name: 'foo', url: '/api/v2/hosts/123/' }], + }, + }); + wrapper + .find('DropdownToggle button[aria-label="add host"]') + .simulate('click'); + await act(async () => { + wrapper + .find('DropdownItem[aria-label="add existing host"]') + .simulate('click'); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + await act(async () => { + wrapper + .find('CheckboxListItem') + .first() + .invoke('onSelect')(); + }); + wrapper.update(); + await act(async () => { + wrapper.find('button[aria-label="Save"]').simulate('click'); + }); + await waitForElement(wrapper, 'AssociateModal', el => el.length === 0); + expect(InventoriesAPI.readHosts).toHaveBeenCalledTimes(1); + expect(GroupsAPI.associateHost).toHaveBeenCalledTimes(1); }); test('should navigate to host add form when adding a new host', async () => { diff --git a/awx/ui_next/src/screens/Inventory/shared/useSelect.jsx b/awx/ui_next/src/screens/Inventory/shared/useSelect.jsx new file mode 100644 index 0000000000..3084c4aace --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/shared/useSelect.jsx @@ -0,0 +1,27 @@ +import { useState } from 'react'; + +/** + * useSelect hook provides a way to read and update a selected list + * Param: array of list items + * Returns: { + * selected: array of selected list items + * isAllSelected: boolean that indicates if all items are selected + * handleSelect: function that adds and removes items from selected list + * setSelected: setter function + * } + */ + +export default function useSelect(list = []) { + const [selected, setSelected] = useState([]); + const isAllSelected = selected.length > 0 && selected.length === list.length; + + const handleSelect = row => { + if (selected.some(s => s.id === row.id)) { + setSelected(prevState => [...prevState.filter(i => i.id !== row.id)]); + } else { + setSelected(prevState => [...prevState, row]); + } + }; + + return { selected, isAllSelected, handleSelect, setSelected }; +} diff --git a/awx/ui_next/src/screens/Inventory/shared/useSelect.test.jsx b/awx/ui_next/src/screens/Inventory/shared/useSelect.test.jsx new file mode 100644 index 0000000000..91113fe913 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/shared/useSelect.test.jsx @@ -0,0 +1,75 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { mount } from 'enzyme'; +import useSelect from './useSelect'; + +const array = [{ id: '1' }, { id: '2' }, { id: '3' }]; + +const TestHook = ({ callback }) => { + callback(); + return null; +}; + +const testHook = callback => { + mount(); +}; + +describe('useSelect hook', () => { + let selected; + let isAllSelected; + let handleSelect; + let setSelected; + + test('should return expected initial values', () => { + testHook(() => { + ({ selected, isAllSelected, handleSelect, setSelected } = useSelect()); + }); + expect(selected).toEqual([]); + expect(isAllSelected).toEqual(false); + expect(handleSelect).toBeInstanceOf(Function); + expect(setSelected).toBeInstanceOf(Function); + }); + + test('handleSelect should update and filter selected items', () => { + testHook(() => { + ({ selected, isAllSelected, handleSelect, setSelected } = useSelect()); + }); + + act(() => { + handleSelect(array[0]); + }); + expect(selected).toEqual([array[0]]); + + act(() => { + handleSelect(array[0]); + }); + expect(selected).toEqual([]); + }); + + test('should return expected isAllSelected value', () => { + testHook(() => { + ({ selected, isAllSelected, handleSelect, setSelected } = useSelect( + array + )); + }); + + act(() => { + handleSelect(array[0]); + }); + expect(selected).toEqual([array[0]]); + expect(isAllSelected).toEqual(false); + + act(() => { + handleSelect(array[1]); + handleSelect(array[2]); + }); + expect(selected).toEqual(array); + expect(isAllSelected).toEqual(true); + + act(() => { + setSelected([]); + }); + expect(selected).toEqual([]); + expect(isAllSelected).toEqual(false); + }); +}); From 51f52f6332ee45ba8bec0eb972fc993d0ec7b6c0 Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Wed, 18 Mar 2020 16:49:22 -0400 Subject: [PATCH 2/4] Translate aria labels --- .../screens/Inventory/InventoryGroupHosts/AssociateModal.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/AssociateModal.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/AssociateModal.jsx index 392833e1e1..1979f4f756 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/AssociateModal.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/AssociateModal.jsx @@ -81,7 +81,7 @@ function AssociateModal({ onClose={handleClose} actions={[