From aa8ed51285637b8a00917d92f715a99689278233 Mon Sep 17 00:00:00 2001 From: Georgy Karataev Date: Fri, 3 Mar 2023 12:43:23 +0100 Subject: [PATCH] feat(ESSNTL-4365): Enable actions in the groups table (#1779) * Connect modals to the groups table actions Implements https://issues.redhat.com/browse/ESSNTL-4365. This enables the creation, deletion and renaming of one group in the groups table. * Fix propTypes for groups modals * Allow multiple groups deletion --- cypress/support/interceptors.js | 13 +++ src/components/GroupsTable/GroupsTable.cy.js | 86 +++++++++++++++++-- src/components/GroupsTable/GroupsTable.js | 75 ++++++++++++++++ .../Modals/CreateGroupModal.js | 4 +- .../Modals/DeleteGroupModal.cy.js | 51 ++++++++--- .../Modals/DeleteGroupModal.js | 50 +++++++---- .../Modals/RenameGroupModal.js | 6 +- src/components/InventoryGroups/utils/api.js | 4 +- 8 files changed, 243 insertions(+), 46 deletions(-) diff --git a/cypress/support/interceptors.js b/cypress/support/interceptors.js index e2f2bca20..97bdbbed2 100644 --- a/cypress/support/interceptors.js +++ b/cypress/support/interceptors.js @@ -62,3 +62,16 @@ export const groupDetailInterceptors = { }).as('getGroupDetail'); } }; + +export const deleteGroupsInterceptors = { + 'successful deletion': () => { + cy.intercept('DELETE', '/api/inventory/v1/groups/*', { + statusCode: 204 + }).as('deleteGroups'); + }, + 'failed deletion (invalid request)': () => { + cy.intercept('DELETE', '/api/inventory/v1/groups/*', { + statusCode: 400 + }).as('deleteGroups'); + } +}; diff --git a/src/components/GroupsTable/GroupsTable.cy.js b/src/components/GroupsTable/GroupsTable.cy.js index c4f9fed1e..da549e698 100644 --- a/src/components/GroupsTable/GroupsTable.cy.js +++ b/src/components/GroupsTable/GroupsTable.cy.js @@ -15,7 +15,10 @@ import { TEXT_INPUT, TOOLBAR, TOOLBAR_FILTER, - DROPDOWN_TOGGLE + DROPDOWN_TOGGLE, + DROPDOWN, + DROPDOWN_ITEM, + MODAL } from '@redhat-cloud-services/frontend-components-utilities'; import _ from 'lodash'; import React from 'react'; @@ -125,14 +128,12 @@ describe('pagination', () => { }); it('can change page limit', () => { - cy.wait('@getGroups').then(() => { - // first initial call - cy.wrap(PAGINATION_VALUES).each((el) => { - changePagination(el).then(() => { - cy.wait('@getGroups') - .its('request.url') - .should('include', `perPage=${el}`); - }); + cy.wait('@getGroups'); // first initial call + PAGINATION_VALUES.forEach((el) => { + changePagination(el).then(() => { + cy.wait('@getGroups') + .its('request.url') + .should('include', `perPage=${el}`); }); }); }); @@ -255,6 +256,73 @@ describe('selection and bulk selection', () => { }); }); +describe('actions', () => { + beforeEach(() => { + interceptors['successful with some items'](); + mountTable(); + + cy.wait('@getGroups'); // first initial request + }); + + const TEST_ID = 0; + + it('bulk rename and delete actions are disabled when no items selected', () => { + cy.get(`${TOOLBAR} ${DROPDOWN}`).eq(1).click(); // open bulk action toolbar + cy.get(DROPDOWN_ITEM).should('have.class', 'pf-m-disabled'); + }); + + it('can rename a group, 1', () => { + cy.get(ROW).eq(TEST_ID + 1).find(`${DROPDOWN} button`).click(); + cy.get(DROPDOWN_ITEM).contains('Rename group').click(); + cy.get(MODAL).find('h1').should('contain.text', 'Rename group'); + cy.get(MODAL).find('input').should('have.value', fixtures.results[TEST_ID].name); + + cy.wait('@getGroups'); // validate request + }); + + it('can rename a group, 2', () => { + selectRowN(TEST_ID + 1); + cy.get(`${TOOLBAR} ${DROPDOWN}`).eq(1).click(); // open bulk action toolbar + cy.get(DROPDOWN_ITEM).contains('Rename group').click(); + cy.get(MODAL).find('h1').should('contain.text', 'Rename group'); + cy.get(MODAL).find('input').should('have.value', fixtures.results[TEST_ID].name); + + cy.wait('@getGroups'); // validate request + }); + + it('can delete a group, 1', () => { + cy.get(ROW).eq(TEST_ID + 1).find(`${DROPDOWN} button`).click(); + cy.get(DROPDOWN_ITEM).contains('Delete group').click(); + cy.get(MODAL).find('h1').should('contain.text', 'Delete group?'); + cy.get(MODAL).find('p').should('contain.text', fixtures.results[TEST_ID].name); + }); + + it('can delete a group, 2', () => { + selectRowN(TEST_ID + 1); + cy.get(`${TOOLBAR} ${DROPDOWN}`).eq(1).click(); // open bulk action toolbar + cy.get(DROPDOWN_ITEM).contains('Delete group').click(); + cy.get(MODAL).find('h1').should('contain.text', 'Delete group?'); + cy.get(MODAL).find('p').should('contain.text', fixtures.results[TEST_ID].name); + }); + + it('can delete more groups', () => { + const TEST_ROWS = [2, 3]; + TEST_ROWS.forEach((row) => selectRowN(row)); + + cy.get(`${TOOLBAR} ${DROPDOWN}`).eq(1).click(); // open bulk action toolbar + cy.get(DROPDOWN_ITEM).contains('Delete groups').click(); + cy.get(MODAL).find('h1').should('contain.text', 'Delete groups?'); + cy.get(MODAL).find('p').should('contain.text', `${TEST_ROWS.length} groups and all their data`); + }); + + it('can create a group', () => { + cy.get(TOOLBAR).find('button').contains('Create group').click(); + cy.get(MODAL).find('h1').should('contain.text', 'Create group'); + + cy.wait('@getGroups'); // validate request + }); +}); + describe('edge cases', () => { it('no groups match', () => { interceptors['successful empty'](); diff --git a/src/components/GroupsTable/GroupsTable.js b/src/components/GroupsTable/GroupsTable.js index 7739b953a..63ba30d2f 100644 --- a/src/components/GroupsTable/GroupsTable.js +++ b/src/components/GroupsTable/GroupsTable.js @@ -29,6 +29,9 @@ import { Link } from 'react-router-dom'; import { TABLE_DEFAULT_PAGINATION } from '../../constants'; import { fetchGroups } from '../../store/inventory-actions'; import useFetchBatched from '../../Utilities/hooks/useFetchBatched'; +import CreateGroupModal from '../InventoryGroups/Modals/CreateGroupModal'; +import DeleteGroupModal from '../InventoryGroups/Modals/DeleteGroupModal'; +import RenameGroupModal from '../InventoryGroups/Modals/RenameGroupModal'; import { getGroups } from '../InventoryGroups/utils/api'; import { generateLoadingRows } from '../InventoryTable/helpers'; import NoEntitiesFound from '../InventoryTable/NoEntitiesFound'; @@ -70,6 +73,10 @@ const GroupsTable = () => { const [filters, setFilters] = useState(GROUPS_TABLE_INITIAL_STATE); const [rows, setRows] = useState([]); const [selectedIds, setSelectedIds] = useState([]); + const [selectedGroup, setSelectedGroup] = useState({}); + const [createModalOpen, setCreateModalOpen] = useState(false); + const [renameModalOpen, setRenameModalOpen] = useState(false); + const [deleteModalOpen, setDeleteModalOpen] = useState(false); const groups = useMemo(() => data?.results || [], [data]); const { fetchBatched } = useFetchBatched(); @@ -105,9 +112,15 @@ const GroupsTable = () => { {} ], groupId: group.id, + groupName: group.name, selected: selectedIds.includes(group.id) })); setRows(newRows); + + setSelectedGroup({ + id: selectedIds[0], + name: groups.find(({ id }) => id === selectedIds[0])?.name + }); }, [groups, selectedIds]); // TODO: convert initial URL params to filters @@ -233,6 +246,25 @@ const GroupsTable = () => { return (
+ {fetchData(filters);}} + /> + fetchData(filters)} + modalState={selectedGroup} + /> + fetchData(filters)} + modalState={selectedIds.length > 1 ? { + ids: selectedIds + } : selectedGroup} + /> { ouiaId: 'groups-selector', count: selectedIds.length }} + actionsConfig={{ + actions: [ + { + label: 'Create group', + onClick: () => setCreateModalOpen(true) + }, + { + label: 'Rename group', + onClick: () => setRenameModalOpen(true), + props: { + isDisabled: selectedIds.length !== 1 + } + }, + { + label: selectedIds.length > 1 ? 'Delete groups' : 'Delete group', + onClick: () => setDeleteModalOpen(true), + props: { + isDisabled: selectedIds.length === 0 + } + } + ] }} /> { isStickyHeader onSelect={onSelect} canSelectAll={false} + actions={[ + { + title: 'Rename group', + onClick: (event, rowIndex, { groupId, groupName }) => { + setSelectedGroup({ + id: groupId, + name: groupName + }); + setRenameModalOpen(true); + } + }, + { + title: 'Delete group', + onClick: (event, rowIndex, { groupId, groupName }) => { + setSelectedGroup({ + id: groupId, + name: groupName + }); + setDeleteModalOpen(true); + } + } + ]} > diff --git a/src/components/InventoryGroups/Modals/CreateGroupModal.js b/src/components/InventoryGroups/Modals/CreateGroupModal.js index 36b8d2860..0a9d2ea88 100644 --- a/src/components/InventoryGroups/Modals/CreateGroupModal.js +++ b/src/components/InventoryGroups/Modals/CreateGroupModal.js @@ -65,7 +65,5 @@ export default CreateGroupModal; CreateGroupModal.propTypes = { isModalOpen: PropTypes.bool, setIsModalOpen: PropTypes.func, - reloadData: PropTypes.func, - deviceIds: PropTypes.array, - isOpen: PropTypes.bool + reloadData: PropTypes.func }; diff --git a/src/components/InventoryGroups/Modals/DeleteGroupModal.cy.js b/src/components/InventoryGroups/Modals/DeleteGroupModal.cy.js index 1739af5fa..5354a54cb 100644 --- a/src/components/InventoryGroups/Modals/DeleteGroupModal.cy.js +++ b/src/components/InventoryGroups/Modals/DeleteGroupModal.cy.js @@ -5,6 +5,16 @@ import { Provider } from 'react-redux'; import { MemoryRouter } from 'react-router-dom'; import { getStore } from '../../../store'; import DeleteGroupModal from './DeleteGroupModal'; +import { deleteGroupsInterceptors } from '../../../../cypress/support/interceptors'; + +const mountModal = (props) => + mount( + + + + + + ); describe('Delete Group Modal', () => { before(() => { @@ -21,23 +31,38 @@ describe('Delete Group Modal', () => { }); beforeEach(() => { - cy.intercept('DELETE', '**/api/inventory/v1/groups/1', { - statusCode: 200, body: { + deleteGroupsInterceptors['successful deletion'](); + }); + + it('fires a network request, single group', () => { + const id = 'foo-bar-1'; + const name = 'foobar group'; + + mountModal({ + isModalOpen: true, + modalState: { + id, + name } - }).as('delete'); - - mount( - - - - - - ); + }); + + cy.get(`div[class="pf-c-check"]`).click(); + cy.get(`button[type="submit"]`).click(); + cy.wait('@deleteGroups').its('request.url').should('include', 'foo-bar-1'); }); - it('Input is fillable and firing a delete request', () => { + it('fires a network request, more groups', () => { + const ids = ['foo-bar-1', 'foo-bar-2']; + + mountModal({ + isModalOpen: true, + modalState: { + ids + } + }); + cy.get(`div[class="pf-c-check"]`).click(); cy.get(`button[type="submit"]`).click(); - cy.wait('@delete'); + cy.wait('@deleteGroups').its('request.url').should('include', 'foo-bar-1').and('include', 'foo-bar-2'); }); }); diff --git a/src/components/InventoryGroups/Modals/DeleteGroupModal.js b/src/components/InventoryGroups/Modals/DeleteGroupModal.js index 3b3be9ce4..28fcb9c79 100644 --- a/src/components/InventoryGroups/Modals/DeleteGroupModal.js +++ b/src/components/InventoryGroups/Modals/DeleteGroupModal.js @@ -3,26 +3,37 @@ import PropTypes from 'prop-types'; import validatorTypes from '@data-driven-forms/react-form-renderer/validator-types'; import componentTypes from '@data-driven-forms/react-form-renderer/component-types'; import Modal from './Modal'; -import { deleteGroupById } from '../utils/api'; +import { deleteGroupsById } from '../utils/api'; import { ExclamationTriangleIcon } from '@patternfly/react-icons/dist/esm/icons/exclamation-triangle-icon'; import warningColor from '@patternfly/react-tokens/dist/esm/global_warning_color_100'; import { Text } from '@patternfly/react-core'; import apiWithToast from '../utils/apiWithToast'; import { useDispatch } from 'react-redux'; -const description = (name) => ( - - {name} and all its data will be permanently deleted. - Associated systems will be removed from the group but will not be deleted. - -); +const description = (name = '', groupsCount) => { + const isMultiple = name === '' && groupsCount; -const schema = (name) => ({ + return isMultiple ? ( + + {groupsCount} groups and all their data will be + permanently deleted. Associated systems will be removed from the + groups but will not be deleted. + + ) : ( + + {name} and all its data will be + permanently deleted. Associated systems will be removed from the + group but will not be deleted. + + ); +}; + +const schema = (name, groupsCount) => ({ fields: [ { component: componentTypes.PLAIN_TEXT, name: 'warning-message', - label: description(name) + label: description(name, groupsCount) }, { component: componentTypes.CHECKBOX, @@ -41,7 +52,8 @@ const DeleteGroupModal = ({ reloadData = defaultValueToBeRemoved, modalState }) => { - const { id, name } = modalState; + const { id, name, ids } = modalState; + const isMultiple = (ids || []).length > 0; const dispatch = useDispatch(); const handleDeleteGroup = () => { @@ -52,18 +64,20 @@ const DeleteGroupModal = ({ }, onError: { title: 'Error', description: 'Failed to delete group' } }; - apiWithToast(dispatch, () => deleteGroupById(id), statusMessages); + apiWithToast(dispatch, () => deleteGroupsById(isMultiple ? ids : [id]), statusMessages); }; return ( setIsModalOpen(false)} - title="Delete group?" - titleIconVariant={() => ()} + title={isMultiple ? 'Delete groups?' : 'Delete group?'} + titleIconVariant={() => ( + + )} variant="danger" submitLabel="Delete" - schema={schema(name)} + schema={schema(name, (ids || []).length)} onSubmit={handleDeleteGroup} reloadData={reloadData} /> @@ -71,9 +85,11 @@ const DeleteGroupModal = ({ }; DeleteGroupModal.propTypes = { - id: PropTypes.number, - name: PropTypes.string, - modalState: PropTypes.object, + modalState: PropTypes.shape({ + id: PropTypes.string, + name: PropTypes.string, + ids: PropTypes.array + }), isModalOpen: PropTypes.bool, setIsModalOpen: PropTypes.func, reloadData: PropTypes.func diff --git a/src/components/InventoryGroups/Modals/RenameGroupModal.js b/src/components/InventoryGroups/Modals/RenameGroupModal.js index d6c353736..2fbcf9ba4 100644 --- a/src/components/InventoryGroups/Modals/RenameGroupModal.js +++ b/src/components/InventoryGroups/Modals/RenameGroupModal.js @@ -78,8 +78,10 @@ const RenameGroupModal = ({ }; RenameGroupModal.propTypes = { - id: PropTypes.number, - modalState: PropTypes.object, + modalState: PropTypes.shape({ + id: PropTypes.string, + name: PropTypes.string + }), isModalOpen: PropTypes.bool, setIsModalOpen: PropTypes.func, reloadData: PropTypes.func diff --git a/src/components/InventoryGroups/utils/api.js b/src/components/InventoryGroups/utils/api.js index d427f7673..4e28e0031 100644 --- a/src/components/InventoryGroups/utils/api.js +++ b/src/components/InventoryGroups/utils/api.js @@ -35,8 +35,8 @@ export const updateGroupById = (id, payload) => { }); }; -export const deleteGroupById = (id) => { - return instance.delete(`${INVENTORY_API_BASE}/groups/${id}`); +export const deleteGroupsById = (ids = []) => { + return instance.delete(`${INVENTORY_API_BASE}/groups/${ids.join(',')}`); };