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();
+ });
+});