diff --git a/awx/ui_next/src/components/PaginatedDataList/ToolbarDeleteButton.jsx b/awx/ui_next/src/components/PaginatedDataList/ToolbarDeleteButton.jsx index b50277c550..db55590c36 100644 --- a/awx/ui_next/src/components/PaginatedDataList/ToolbarDeleteButton.jsx +++ b/awx/ui_next/src/components/PaginatedDataList/ToolbarDeleteButton.jsx @@ -63,10 +63,12 @@ class ToolbarDeleteButton extends React.Component { onDelete: func.isRequired, itemsToDelete: arrayOf(ItemToDelete).isRequired, pluralizedItemName: string, + errorMessage: string, }; static defaultProps = { pluralizedItemName: 'Items', + errorMessage: '', }; constructor(props) { @@ -96,7 +98,12 @@ class ToolbarDeleteButton extends React.Component { } renderTooltip() { - const { itemsToDelete, pluralizedItemName, i18n } = this.props; + const { + itemsToDelete, + pluralizedItemName, + errorMessage, + i18n, + } = this.props; const itemsUnableToDelete = itemsToDelete .filter(cannotDelete) @@ -105,9 +112,11 @@ class ToolbarDeleteButton extends React.Component { if (itemsToDelete.some(cannotDelete)) { return (
- {i18n._( - t`You do not have permission to delete the following ${pluralizedItemName}: ${itemsUnableToDelete}` - )} + {errorMessage.length > 0 + ? errorMessage + : i18n._( + t`You do not have permission to delete ${pluralizedItemName}: ${itemsUnableToDelete}` + )}
); } diff --git a/awx/ui_next/src/components/PaginatedDataList/__snapshots__/ToolbarDeleteButton.test.jsx.snap b/awx/ui_next/src/components/PaginatedDataList/__snapshots__/ToolbarDeleteButton.test.jsx.snap index 1b146c628b..94b0592368 100644 --- a/awx/ui_next/src/components/PaginatedDataList/__snapshots__/ToolbarDeleteButton.test.jsx.snap +++ b/awx/ui_next/src/components/PaginatedDataList/__snapshots__/ToolbarDeleteButton.test.jsx.snap @@ -2,6 +2,7 @@ exports[` should render button 1`] = ` { + const clonedItem = { + ...item, + summary_fields: { + ...item.summary_fields, + user_capabilities: { + ...item.summary_fields.user_capabilities, + }, + }, + }; + if (clonedItem.name === 'tower') { + clonedItem.summary_fields.user_capabilities.delete = false; + } + return clonedItem; + }); +} + +function InstanceGroupList({ i18n }) { + const location = useLocation(); + const match = useRouteMatch(); + + const { + error: contentError, + isLoading, + request: fetchInstanceGroups, + result: { instanceGroups, instanceGroupsCount, actions }, + } = useRequest( + useCallback(async () => { + const params = parseQueryString(QS_CONFIG, location.search); + + const [response, responseActions] = await Promise.all([ + InstanceGroupsAPI.read(params), + InstanceGroupsAPI.readOptions(), + ]); + + return { + instanceGroups: response.data.results, + instanceGroupsCount: response.data.count, + actions: responseActions.data.actions, + }; + }, [location]), + { + instanceGroups: [], + instanceGroupsCount: 0, + actions: {}, + } + ); + + useEffect(() => { + fetchInstanceGroups(); + }, [fetchInstanceGroups]); + + const { selected, isAllSelected, handleSelect, setSelected } = useSelected( + instanceGroups + ); + + const modifiedSelected = modifyInstanceGroups(selected); + + const { + isLoading: deleteLoading, + deletionError, + deleteItems: deleteInstanceGroups, + clearDeletionError, + } = useDeleteItems( + useCallback(async () => { + await Promise.all( + selected.map(({ id }) => InstanceGroupsAPI.destroy(id)) + ); + }, [selected]), + { + qsConfig: QS_CONFIG, + allItemsSelected: isAllSelected, + fetchItems: fetchInstanceGroups, + } + ); + + const handleDelete = async () => { + await deleteInstanceGroups(); + setSelected([]); + }; + + const canAdd = actions && actions.POST; + + function cannotDelete(item) { + return !item.summary_fields.user_capabilities.delete; + } + + const pluralizedItemName = i18n._(t`Instance Groups`); + + let errorMessageDelete = ''; + + if (modifiedSelected.some(item => item.name === 'tower')) { + const itemsUnableToDelete = modifiedSelected + .filter(cannotDelete) + .filter(item => item.name !== 'tower') + .map(item => item.name) + .join(', '); + + if (itemsUnableToDelete) { + if (modifiedSelected.some(cannotDelete)) { + errorMessageDelete = i18n._( + t`You do not have permission to delete ${pluralizedItemName}: ${itemsUnableToDelete}. ` + ); + } + } + + if (errorMessageDelete.length > 0) { + errorMessageDelete = errorMessageDelete.concat('\n'); + } + errorMessageDelete = errorMessageDelete.concat( + i18n._(t`The tower instance group cannot be deleted.`) + ); + } + return ( - - -
Instance Group List
-
-
+ <> + + + ( + + setSelected(isSelected ? [...instanceGroups] : []) + } + qsConfig={QS_CONFIG} + additionalControls={[ + ...(canAdd + ? [ + , + ] + : []), + , + ]} + /> + )} + renderItem={instanceGroup => ( + handleSelect(instanceGroup)} + isSelected={selected.some(row => row.id === instanceGroup.id)} + /> + )} + emptyStateControls={ + canAdd && ( + + ) + } + /> + + + + {i18n._(t`Failed to delete one or more instance groups.`)} + + + ); } -export default InstanceGroupList; +export default withI18n()(InstanceGroupList); diff --git a/awx/ui_next/src/screens/InstanceGroup/InstanceGroupList/InstanceGroupList.test.jsx b/awx/ui_next/src/screens/InstanceGroup/InstanceGroupList/InstanceGroupList.test.jsx new file mode 100644 index 0000000000..338dea2cbb --- /dev/null +++ b/awx/ui_next/src/screens/InstanceGroup/InstanceGroupList/InstanceGroupList.test.jsx @@ -0,0 +1,193 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; + +import { + mountWithContexts, + waitForElement, +} from '../../../../testUtils/enzymeHelpers'; + +import { InstanceGroupsAPI } from '../../../api'; +import InstanceGroupList from './InstanceGroupList'; + +jest.mock('../../../api/models/InstanceGroups'); + +const instanceGroups = { + data: { + results: [ + { + id: 1, + name: 'Foo', + type: 'instance_group', + url: '/api/v2/instance_groups/1', + consumed_capacity: 10, + summary_fields: { user_capabilities: { edit: true, delete: true } }, + }, + { + id: 2, + name: 'tower', + type: 'instance_group', + url: '/api/v2/instance_groups/2', + consumed_capacity: 42, + summary_fields: { user_capabilities: { edit: true, delete: true } }, + }, + { + id: 3, + name: 'Bar', + type: 'instance_group', + url: '/api/v2/instance_groups/3', + consumed_capacity: 42, + summary_fields: { user_capabilities: { edit: true, delete: false } }, + }, + ], + count: 3, + }, +}; + +const options = { data: { actions: { POST: true } } }; + +describe(' { + let wrapper; + + test('should have data fetched and render 3 rows', async () => { + InstanceGroupsAPI.read.mockResolvedValue(instanceGroups); + InstanceGroupsAPI.readOptions.mockResolvedValue(options); + + await act(async () => { + wrapper = mountWithContexts(); + }); + await waitForElement(wrapper, 'InstanceGroupList', el => el.length > 0); + expect(wrapper.find('InstanceGroupListItem').length).toBe(3); + expect(InstanceGroupsAPI.read).toBeCalled(); + expect(InstanceGroupsAPI.readOptions).toBeCalled(); + }); + + test('should delete item successfully', async () => { + InstanceGroupsAPI.read.mockResolvedValue(instanceGroups); + InstanceGroupsAPI.readOptions.mockResolvedValue(options); + + await act(async () => { + wrapper = mountWithContexts(); + }); + await waitForElement(wrapper, 'InstanceGroupList', el => el.length > 0); + + wrapper + .find('input#select-instance-groups-1') + .simulate('change', instanceGroups); + wrapper.update(); + + expect(wrapper.find('input#select-instance-groups-1').prop('checked')).toBe( + true + ); + + 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')() + ); + + expect(InstanceGroupsAPI.destroy).toBeCalledWith( + instanceGroups.data.results[0].id + ); + }); + + test('should not be able to delete tower instance group', async () => { + InstanceGroupsAPI.read.mockResolvedValue(instanceGroups); + InstanceGroupsAPI.readOptions.mockResolvedValue(options); + + await act(async () => { + wrapper = mountWithContexts(); + }); + await waitForElement(wrapper, 'InstanceGroupList', el => el.length > 0); + + const instanceGroupIndex = [1, 2, 3]; + + instanceGroupIndex.forEach(element => { + wrapper + .find(`input#select-instance-groups-${element}`) + .simulate('change', instanceGroups); + wrapper.update(); + + expect( + wrapper.find(`input#select-instance-groups-${element}`).prop('checked') + ).toBe(true); + }); + + expect(wrapper.find('Button[aria-label="Delete"]').prop('isDisabled')).toBe( + true + ); + }); + + test('should thrown content error', async () => { + InstanceGroupsAPI.read.mockRejectedValue( + new Error({ + response: { + config: { + method: 'GET', + url: '/api/v2/instance_groups', + }, + data: 'An error occurred', + }, + }) + ); + InstanceGroupsAPI.readOptions.mockResolvedValue(options); + await act(async () => { + wrapper = mountWithContexts(); + }); + await waitForElement(wrapper, 'InstanceGroupList', el => el.length > 0); + expect(wrapper.find('ContentError').length).toBe(1); + }); + + test('should render deletion error modal', async () => { + InstanceGroupsAPI.destroy.mockRejectedValue( + new Error({ + response: { + config: { + method: 'DELETE', + url: '/api/v2/instance_groups', + }, + data: 'An error occurred', + }, + }) + ); + InstanceGroupsAPI.read.mockResolvedValue(instanceGroups); + InstanceGroupsAPI.readOptions.mockResolvedValue(options); + await act(async () => { + wrapper = mountWithContexts(); + }); + waitForElement(wrapper, 'InstanceGroupList', el => el.length > 0); + + wrapper.find('input#select-instance-groups-1').simulate('change', 'a'); + wrapper.update(); + expect(wrapper.find('input#select-instance-groups-1').prop('checked')).toBe( + true + ); + + 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('ErrorDetail').length).toBe(1); + }); + + test('should not render add button', async () => { + InstanceGroupsAPI.read.mockResolvedValue(instanceGroups); + InstanceGroupsAPI.readOptions.mockResolvedValue({ + data: { actions: { POST: false } }, + }); + await act(async () => { + wrapper = mountWithContexts(); + }); + waitForElement(wrapper, 'InstanceGroupList', el => el.length > 0); + expect(wrapper.find('ToolbarAddButton').length).toBe(0); + }); +}); + +describe('modifyInstanceGroups', () => {}); diff --git a/awx/ui_next/src/screens/InstanceGroup/InstanceGroupList/InstanceGroupListItem.jsx b/awx/ui_next/src/screens/InstanceGroup/InstanceGroupList/InstanceGroupListItem.jsx new file mode 100644 index 0000000000..2b494d99f7 --- /dev/null +++ b/awx/ui_next/src/screens/InstanceGroup/InstanceGroupList/InstanceGroupListItem.jsx @@ -0,0 +1,183 @@ +import React from 'react'; +import { string, bool, func } from 'prop-types'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Link } from 'react-router-dom'; +import 'styled-components/macro'; +import { + Badge as PFBadge, + Progress, + ProgressMeasureLocation, + ProgressSize, + Button, + DataListAction as _DataListAction, + DataListCheck, + DataListItem, + DataListItemRow, + DataListItemCells, + Tooltip, +} from '@patternfly/react-core'; +import { PencilAltIcon } from '@patternfly/react-icons'; +import styled from 'styled-components'; + +import _DataListCell from '../../../components/DataListCell'; +import { InstanceGroup } from '../../../types'; + +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; + } +`; + +const DataListAction = styled(_DataListAction)` + align-items: center; + display: grid; + grid-gap: 16px; + grid-template-columns: 40px; +`; + +function InstanceGroupListItem({ + instanceGroup, + detailUrl, + isSelected, + onSelect, + i18n, +}) { + 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)) { + return ( + + ); + } + return {i18n._(t`Unavailable`)}; + } + return null; + } + + return ( + + + + + + + + {instanceGroup.name} + + + , + + + {i18n._(t`Type`)} + + {isContainerGroup(instanceGroup) + ? i18n._(t`Container group`) + : i18n._(t`Instance group`)} + + , + + + {i18n._(t`Running jobs`)} + {instanceGroup.jobs_running} + + + {i18n._(t`Total jobs`)} + {instanceGroup.jobs_total} + + + {!instanceGroup.is_containerized ? ( + + {i18n._(t`Instances`)} + {instanceGroup.instances} + + ) : null} + , + + + {usedCapacity(instanceGroup)} + , + ]} + /> + + {instanceGroup.summary_fields.user_capabilities.edit && ( + + + + )} + + + + ); +} +InstanceGroupListItem.prototype = { + instanceGroup: InstanceGroup.isRequired, + detailUrl: string.isRequired, + isSelected: bool.isRequired, + onSelect: func.isRequired, +}; + +export default withI18n()(InstanceGroupListItem); diff --git a/awx/ui_next/src/screens/InstanceGroup/InstanceGroupList/InstanceGroupListItem.test.jsx b/awx/ui_next/src/screens/InstanceGroup/InstanceGroupList/InstanceGroupListItem.test.jsx new file mode 100644 index 0000000000..9c819dd964 --- /dev/null +++ b/awx/ui_next/src/screens/InstanceGroup/InstanceGroupList/InstanceGroupListItem.test.jsx @@ -0,0 +1,151 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; + +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; + +import InstanceGroupListItem from './InstanceGroupListItem'; + +describe('', () => { + let wrapper; + const instanceGroups = [ + { + id: 1, + name: 'Foo', + type: 'instance_group', + url: '/api/v2/instance_groups/1', + capacity: 10, + policy_instance_minimum: 10, + policy_instance_percentage: 50, + percent_capacity_remaining: 60, + is_containerized: false, + summary_fields: { + user_capabilities: { + edit: true, + delete: true, + }, + }, + }, + { + id: 2, + name: 'Bar', + type: 'instance_group', + url: '/api/v2/instance_groups/2', + capacity: 0, + policy_instance_minimum: 0, + policy_instance_percentage: 0, + percent_capacity_remaining: 0, + is_containerized: true, + summary_fields: { + user_capabilities: { + edit: false, + delete: false, + }, + }, + }, + ]; + + test('should mount successfully', async () => { + await act(async () => { + wrapper = mountWithContexts( + {}} + /> + ); + }); + expect(wrapper.find('InstanceGroupListItem').length).toBe(1); + }); + + test('should render the proper data instance group', async () => { + await act(async () => { + wrapper = mountWithContexts( + {}} + /> + ); + }); + expect( + wrapper.find('PFDataListCell[aria-label="instance group name"]').text() + ).toBe('Foo'); + expect(wrapper.find('Progress').prop('value')).toBe(40); + expect( + wrapper.find('PFDataListCell[aria-label="instance group type"]').text() + ).toBe('TypeInstance group'); + expect(wrapper.find('PencilAltIcon').length).toBe(1); + expect(wrapper.find('input#select-instance-groups-1').prop('checked')).toBe( + false + ); + }); + + test('should render the proper data container group', async () => { + await act(async () => { + wrapper = mountWithContexts( + {}} + /> + ); + }); + expect( + wrapper.find('PFDataListCell[aria-label="instance group name"]').text() + ).toBe('Bar'); + + expect( + wrapper.find('PFDataListCell[aria-label="instance group type"]').text() + ).toBe('TypeContainer group'); + expect(wrapper.find('PencilAltIcon').length).toBe(0); + }); + + test('should be checked', async () => { + await act(async () => { + wrapper = mountWithContexts( + {}} + /> + ); + }); + expect(wrapper.find('input#select-instance-groups-1').prop('checked')).toBe( + true + ); + }); + + test('edit button shown to users with edit capabilities', async () => { + await act(async () => { + wrapper = mountWithContexts( + {}} + /> + ); + }); + + expect(wrapper.find('PencilAltIcon').exists()).toBeTruthy(); + }); + + test('edit button hidden from users without edit capabilities', async () => { + await act(async () => { + wrapper = mountWithContexts( + {}} + /> + ); + }); + + expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy(); + }); +});