diff --git a/cypress/fixtures/hosts.json b/cypress/fixtures/hosts.json index 3b156ab11..cd4c5c50b 100644 --- a/cypress/fixtures/hosts.json +++ b/cypress/fixtures/hosts.json @@ -292,6 +292,7 @@ "reporter": "adipisicing veniam velit", "created": "1962-06-25T23:00:00.0Z", "account": null, + "group_name": "abc", "mac_addresses": null, "provider_id": "aute ut sit", "facts": [ diff --git a/cypress/support/interceptors.js b/cypress/support/interceptors.js index 4c42f9e3b..c4308f442 100644 --- a/cypress/support/interceptors.js +++ b/cypress/support/interceptors.js @@ -7,6 +7,7 @@ import groupsSecondPage from '../fixtures/groupsSecondPage.json'; import groupDetailFixtures from '../fixtures/groups/620f9ae75A8F6b83d78F3B55Af1c4b2C.json'; import hostsFixtures from '../fixtures/hosts.json'; +export { hostsFixtures, groupDetailFixtures }; export const groupsInterceptors = { 'successful with some items': () => cy @@ -63,6 +64,23 @@ export const groupDetailInterceptors = { } ) .as('getGroupDetail'), + 'successful with hosts': () => + cy + .intercept( + 'GET', + '/api/inventory/v1/groups/620f9ae75A8F6b83d78F3B55Af1c4b2C', + { + statusCode: 200, + body: { + ...groupDetailFixtures, + results: [{ + ...groupDetailFixtures.results[0], + host_ids: ['host-1', 'host-2'] + }] + } + } + ) + .as('getGroupDetail'), empty: () => cy .intercept( @@ -162,7 +180,16 @@ export const featureFlagsInterceptors = { cy.intercept('GET', '/feature_flags*', { statusCode: 200, body: { - toggles: [] + toggles: [ + { + name: 'hbi.ui.inventory-groups', + enabled: true, + variant: { + name: 'disabled', + enabled: true + } + } + ] } }).as('getFeatureFlag'); } diff --git a/src/components/GroupSystems/GroupSystems.cy.js b/src/components/GroupSystems/GroupSystems.cy.js index 07c072f57..703d79f41 100644 --- a/src/components/GroupSystems/GroupSystems.cy.js +++ b/src/components/GroupSystems/GroupSystems.cy.js @@ -9,6 +9,7 @@ import { CHIP_GROUP, DROPDOWN_TOGGLE, hasChip, + MODAL, PAGINATION_VALUES, SORTING_ORDERS, TEXT_INPUT, @@ -38,7 +39,7 @@ import _ from 'lodash'; const GROUP_NAME = 'foobar'; const ROOT = 'div[id="group-systems-table"]'; -const TABLE_HEADERS = ['Name', 'Tags', 'OS', 'Update methods', 'Last seen']; +const TABLE_HEADERS = ['Name', 'Tags', 'OS', 'Update method', 'Last seen']; const SORTABLE_HEADERS = ['Name', 'OS', 'Last seen']; const DEFAULT_ROW_COUNT = 50; @@ -300,7 +301,21 @@ describe('selection and bulk selection', () => { }); describe('actions', () => { - // TBA + beforeEach(() => { + cy.intercept('*', { statusCode: 200 }); + hostsInterceptors.successful(); + + mountTable(); + + cy.wait('@getHosts'); + }); + + it('can open systems add modal', () => { + cy.get('button').contains('Add systems').click(); + cy.get(MODAL).find('h1').contains('Add systems'); + + cy.wait('@getHosts'); + }); }); describe('edge cases', () => { diff --git a/src/components/GroupSystems/GroupSystems.js b/src/components/GroupSystems/GroupSystems.js index d02d42118..942aece8c 100644 --- a/src/components/GroupSystems/GroupSystems.js +++ b/src/components/GroupSystems/GroupSystems.js @@ -1,20 +1,45 @@ +import { Button } from '@patternfly/react-core'; import { fitContent, TableVariant } from '@patternfly/react-table'; import difference from 'lodash/difference'; import map from 'lodash/map'; import PropTypes from 'prop-types'; -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import { selectEntity } from '../../store/inventory-actions'; +import { clearFilters, selectEntity } from '../../store/inventory-actions'; +import AddSystemsToGroupModal from '../InventoryGroups/Modals/AddSystemsToGroupModal'; import InventoryTable from '../InventoryTable/InventoryTable'; +export const bulkSelectConfig = (dispatch, selectedNumber, noneSelected, pageSelected, rowsNumber) => ({ + count: selectedNumber, + id: 'bulk-select-groups', + items: [ + { + title: 'Select none (0)', + onClick: () => dispatch(selectEntity(-1, false)), + props: { isDisabled: noneSelected } + }, + { + title: `${pageSelected ? 'Deselect' : 'Select'} page (${ + rowsNumber + } items)`, + onClick: () => dispatch(selectEntity(0, !pageSelected)) + } + // TODO: Implement "select all" + ], + onSelect: (value) => { + dispatch(selectEntity(0, value)); + }, + checked: selectedNumber > 0 // TODO: support partial selection (dash sign) in FEC BulkSelect +}); + const prepareColumns = (initialColumns) => { // hides the "groups" column const columns = initialColumns.filter(({ key }) => key !== 'groups'); - // additionally insert the "update methods" column + // additionally insert the "update method" column columns.splice(columns.length - 1 /* must be penultimate */, 0, { key: 'update_method', - title: 'Update methods', + title: 'Update method', sortKey: 'update_method', transforms: [fitContent], renderFunc: (value, hostId, systemData) => @@ -29,7 +54,7 @@ const prepareColumns = (initialColumns) => { return columns; }; -const GroupSystems = ({ groupName }) => { +const GroupSystems = ({ groupName, groupId }) => { const dispatch = useDispatch(); const selected = useSelector( @@ -42,58 +67,77 @@ const GroupSystems = ({ groupName }) => { const pageSelected = difference(displayedIds, [...selected.keys()]).length === 0; + const [isModalOpen, setIsModalOpen] = useState(false); + + const resetTable = () => { + dispatch(clearFilters()); + dispatch(selectEntity(-1, false)); + }; + + useEffect(() => { + return () => { + resetTable(); + }; + }, []); + return (
- - await defaultGetEntities( - items, - // filter systems by the group name - { - ...config, - filters: { - ...config.filters, - groupName: [groupName] // TODO: the param is not yet supported by `apiHostGetHostList` - } - }, - showTags - ) - } - tableProps={{ - isStickyHeader: true, - variant: TableVariant.compact, - canSelectAll: false - }} - bulkSelect={{ - count: selected.size, - id: 'bulk-select-groups', - items: [ - { - title: 'Select none (0)', - onClick: () => dispatch(selectEntity(-1, false)), - props: { isDisabled: noneSelected } - }, - { - title: `${pageSelected ? 'Deselect' : 'Select'} page (${ - rows.length - } items)`, - onClick: () => dispatch(selectEntity(0, !pageSelected)) - } - // TODO: Implement "select all" - ], - onSelect: (value) => { - dispatch(selectEntity(0, value)); - }, - checked: selected.size > 0 // TODO: support partial selection (dash sign) in FEC BulkSelect - }} - /> + { + isModalOpen && { + resetTable(); + setIsModalOpen(value); + } + } + groupId={groupId} + groupName={groupName} + /> + } + { + !isModalOpen && + + await defaultGetEntities( + items, + // filter systems by the group name + { + ...config, + filters: { + ...config.filters, + groupName: [groupName] // TODO: the param is not yet supported by `apiHostGetHostList` + } + }, + showTags + ) + } + tableProps={{ + isStickyHeader: true, + variant: TableVariant.compact, + canSelectAll: false + }} + bulkSelect={bulkSelectConfig(dispatch, selected.size, noneSelected, pageSelected, rows.length)} + > + + + }
); }; GroupSystems.propTypes = { - groupName: PropTypes.string.isRequired + groupName: PropTypes.string.isRequired, + groupId: PropTypes.string.isRequired }; export default GroupSystems; diff --git a/src/components/GroupSystems/index.js b/src/components/GroupSystems/index.js index e2c71c39b..9c7177334 100644 --- a/src/components/GroupSystems/index.js +++ b/src/components/GroupSystems/index.js @@ -2,10 +2,10 @@ import { EmptyState, EmptyStateBody, Spinner } from '@patternfly/react-core'; import PropTypes from 'prop-types'; import React from 'react'; import { useSelector } from 'react-redux'; -import NoGroupsEmptyState from '../InventoryGroups/NoGroupsEmptyState'; +import NoSystemsEmptyState from '../InventoryGroupDetail/NoSystemsEmptyState'; import GroupSystems from './GroupSystems'; -const GroupSystemsWrapper = ({ groupName }) => { +const GroupSystemsWrapper = ({ groupName, groupId }) => { const { uninitialized, loading, data } = useSelector((state) => state.groupDetail); const hosts = data?.results?.[0]?.host_ids /* can be null */ || []; @@ -16,13 +16,14 @@ const GroupSystemsWrapper = ({ groupName }) => { ) : hosts.length > 0 ? ( - + ) : - ; + ; }; GroupSystemsWrapper.propTypes = { - groupName: PropTypes.string.isRequired + groupName: PropTypes.string.isRequired, + groupId: PropTypes.string.isRequired }; export default GroupSystemsWrapper; diff --git a/src/components/InventoryGroupDetail/InventoryGroupDetail.js b/src/components/InventoryGroupDetail/InventoryGroupDetail.js index ae8126d92..bc414da7b 100644 --- a/src/components/InventoryGroupDetail/InventoryGroupDetail.js +++ b/src/components/InventoryGroupDetail/InventoryGroupDetail.js @@ -53,7 +53,7 @@ const InventoryGroupDetail = ({ groupId }) => { aria-label="Group systems tab" > - + { + const [isModalOpen, setIsModalOpen] = useState(false); -const NoSystemsEmptyState = () => { return ( + No systems added @@ -25,13 +35,14 @@ const NoSystemsEmptyState = () => { <EmptyStateBody> To manage systems more effectively, add systems to the group. </EmptyStateBody> - <Button variant="primary" onClick={() => {}}>Add systems</Button> + <Button variant="primary" onClick={() => setIsModalOpen(true)}> + Add systems + </Button> <EmptyStateSecondaryActions> <Button variant="link" icon={<ExternalLinkAltIcon />} iconPosition="right" - // TODO: component={(props) => <a href='' {...props} />} > Learn more about system groups </Button> @@ -39,4 +50,8 @@ const NoSystemsEmptyState = () => { </EmptyState> );}; +NoSystemsEmptyState.propTypes = { + groupId: PropTypes.string, + groupName: PropTypes.string +}; export default NoSystemsEmptyState; diff --git a/src/components/InventoryGroupDetail/__tests__/InventoryGroupDetail.test.js b/src/components/InventoryGroupDetail/__tests__/InventoryGroupDetail.test.js index 44f42fa57..2b59832e3 100644 --- a/src/components/InventoryGroupDetail/__tests__/InventoryGroupDetail.test.js +++ b/src/components/InventoryGroupDetail/__tests__/InventoryGroupDetail.test.js @@ -2,25 +2,11 @@ import '@testing-library/jest-dom'; import { render } from '@testing-library/react'; import React from 'react'; import { MemoryRouter } from 'react-router-dom'; +import { getStore } from '../../../store'; import InventoryGroupDetail from '../InventoryGroupDetail'; +import { Provider } from 'react-redux'; -jest.mock('react-redux', () => { - return { - ...jest.requireActual('react-redux'), - useSelector: () => ({ - uninitialized: false, - loading: false, - data: { - results: [ - { - name: 'group-name-1' - } - ] - } - }), - useDispatch: () => () => {} - }; -}); +jest.mock('../../../Utilities/useFeatureFlag'); describe('group detail page component', () => { let getByRole; @@ -29,7 +15,9 @@ describe('group detail page component', () => { beforeEach(() => { const rendered = render( <MemoryRouter> - <InventoryGroupDetail groupId="group-id-2" /> + <Provider store={getStore()}> + <InventoryGroupDetail groupId="group-id-2" /> + </Provider> </MemoryRouter> ); getByRole = rendered.getByRole; diff --git a/src/components/InventoryGroups/Modals/AddSystemsToGroupModal.cy.js b/src/components/InventoryGroups/Modals/AddSystemsToGroupModal.cy.js new file mode 100644 index 000000000..bc7220c5f --- /dev/null +++ b/src/components/InventoryGroups/Modals/AddSystemsToGroupModal.cy.js @@ -0,0 +1,159 @@ +import { mount } from '@cypress/react'; +import { + checkTableHeaders, + MODAL, + ouiaId, + TABLE +} from '@redhat-cloud-services/frontend-components-utilities'; +import FlagProvider from '@unleash/proxy-client-react'; +import _ from 'lodash'; +import React from 'react'; +import { Provider } from 'react-redux'; +import { MemoryRouter } from 'react-router-dom'; +import { + featureFlagsInterceptors, + groupDetailInterceptors, + hostsFixtures, + hostsInterceptors +} from '../../../../cypress/support/interceptors'; +import { + selectRowN, + unleashDummyConfig +} from '../../../../cypress/support/utils'; +import { getStore } from '../../../store'; +import AddSystemsToGroupModal from './AddSystemsToGroupModal'; + +const TABLE_HEADERS = [ + 'Name', + 'OS', + 'Tags', + 'Update method', + 'Group', + 'Last seen' +]; + +const ALERT = '[data-ouia-component-type="PF4/Alert"]'; + +before(() => { + cy.window().then( + (window) => + (window.insights = { + chrome: { + isProd: false, + auth: { + getUser: () => { + return Promise.resolve({}); + } + } + } + }) + ); +}); + +const mountModal = () => + mount( + <FlagProvider config={unleashDummyConfig}> + <Provider store={getStore()}> + <MemoryRouter> + <AddSystemsToGroupModal + isModalOpen={true} + groupId="620f9ae75A8F6b83d78F3B55Af1c4b2C" + setIsModalOpen={() => {}} // TODO: test that the func is called on close + /> + </MemoryRouter> + </Provider> + </FlagProvider> + ); + +describe('test data', () => { + it('at least one system is already in a group', () => { + const alreadyInGroup = hostsFixtures.results.filter( + // eslint-disable-next-line camelcase + ({ group_name }) => !_.isEmpty(group_name) + ); + expect(alreadyInGroup.length).to.be.gte(1); + }); + + it('the first system in group has specific id', () => { + const alreadyInGroup = hostsFixtures.results.filter( + // eslint-disable-next-line camelcase + ({ group_name }) => !_.isEmpty(group_name) + ); + expect(alreadyInGroup[0].id).to.eq('anim commodo'); + }); +}); + +describe('AddSystemsToGroupModal', () => { + beforeEach(() => { + cy.viewport(1920, 1080); // to accomadate the inventory table + cy.intercept('*', { statusCode: 200 }); + hostsInterceptors.successful(); // default hosts list + featureFlagsInterceptors.successful(); // to enable the Group column + }); + + it('renders correct header and buttons', () => { + mountModal(); + + cy.wait('@getHosts'); + cy.get('h1').contains('Add systems'); + cy.get('button').contains('Add systems'); + cy.get('button').contains('Cancel'); + }); + + it('renders the inventory table', () => { + mountModal(); + + cy.wait('@getHosts'); + cy.get(ouiaId('PrimaryToolbar')); + cy.get(TABLE); + cy.get('#options-menu-bottom-pagination'); + checkTableHeaders(TABLE_HEADERS); + }); + + it('can add systems that are not yet in group', () => { + groupDetailInterceptors['patch successful'](); + groupDetailInterceptors['successful with hosts'](); + mountModal(); + + cy.wait('@getHosts'); + cy.get('button').contains('Add systems').should('be.disabled'); + selectRowN(1); + cy.get('button').contains('Add systems').click(); + cy.wait('@getGroupDetail'); // requests the current hosts list + cy.wait('@patchGroup') + .its('request.body') + .should('deep.equal', { + // eslint-disable-next-line camelcase + host_ids: ['host-1', 'host-2', 'dolor'] // sends the merged list of hosts + }); + }); + + it('can add systems that are already in group', () => { + groupDetailInterceptors['patch successful'](); + groupDetailInterceptors['successful with hosts'](); + mountModal(); + + cy.wait('@getHosts'); + const i = + hostsFixtures.results.findIndex( + // eslint-disable-next-line camelcase + ({ group_name }) => !_.isEmpty(group_name) + ) + 1; + selectRowN(i); + cy.get(ALERT); // check the alert is shown + cy.get('button').contains('Add systems').click(); + cy.get(MODAL).find('h1').contains('Add all selected systems to group?'); + cy.get('button') + .contains('Yes, add all systems to group') + .should('be.disabled'); + cy.get('input[name="confirmation"]').check(); + cy.get('button').contains('Yes, add all systems to group').click(); + cy.wait('@getGroupDetail'); + cy.wait('@patchGroup') + .its('request.body') + .should('deep.equal', { + // eslint-disable-next-line camelcase + host_ids: ['host-1', 'host-2', 'anim commodo'] // sends the merged list of hosts + }); + }); +}); diff --git a/src/components/InventoryGroups/Modals/AddSystemsToGroupModal.js b/src/components/InventoryGroups/Modals/AddSystemsToGroupModal.js new file mode 100644 index 000000000..828f8328e --- /dev/null +++ b/src/components/InventoryGroups/Modals/AddSystemsToGroupModal.js @@ -0,0 +1,197 @@ +import { + Alert, + Button, + Flex, + FlexItem, + Modal +} from '@patternfly/react-core'; +import { fitContent, TableVariant } from '@patternfly/react-table'; +import difference from 'lodash/difference'; +import map from 'lodash/map'; +import PropTypes from 'prop-types'; +import React, { useCallback, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { fetchGroupDetail } from '../../../store/inventory-actions'; +import { bulkSelectConfig } from '../../GroupSystems/GroupSystems'; +import InventoryTable from '../../InventoryTable/InventoryTable'; +import { addHostsToGroupById } from '../utils/api'; +import apiWithToast from '../utils/apiWithToast'; +import ConfirmSystemsAddModal from './ConfirmSystemsAddModal'; + +export const prepareColumns = (initialColumns) => { + const columns = initialColumns; + + // additionally insert the "update method" column + columns.splice(columns.length - 2 /* must be the 3rd col from the end */, 0, { + key: 'update_method', + title: 'Update method', + sortKey: 'update_method', + transforms: [fitContent], + renderFunc: (value, hostId, systemData) => + systemData?.system_profile?.system_update_method || 'N/A', + props: { + // TODO: remove isStatic when the sorting is supported by API + isStatic: true, + width: 10 + } + }); + + // map columns to the speicifc order + return [ + 'display_name', + 'system_profile', + 'tags', + 'update_method', + 'groups', + 'updated' + ].map((colKey) => columns.find(({ key }) => key === colKey)); +}; + +const AddSystemsToGroupModal = ({ + isModalOpen, + setIsModalOpen, + groupId, + groupName +}) => { + const dispatch = useDispatch(); + + const [confirmationModalOpen, setConfirmationModalOpen] = useState(false); + const [systemsSelectModalOpen, setSystemSelectModalOpen] = useState(true); + const selected = useSelector( + (state) => state?.entities?.selected || new Map() + ); + const rows = useSelector(({ entities }) => entities?.rows || []); + + const noneSelected = selected.size === 0; + const displayedIds = map(rows, 'id'); + const pageSelected = difference(displayedIds, [...selected.keys()]).length === 0; + + const alreadyHasGroup = [...selected].filter( + // eslint-disable-next-line camelcase + (entry) => { + return entry[1].group_name !== undefined && entry[1].group_name !== ''; + } + ); + const showWarning = alreadyHasGroup.length > 0; + + const handleSystemAddition = useCallback( + (hostIds) => { + const statusMessages = { + onSuccess: { + title: 'Success', + description: `${hostIds.length > 1 ? 'Systems' : 'System'} added to ${groupName || groupId}` + }, + onError: { + title: 'Error', + description: `Failed to add ${hostIds.length > 1 ? 'systems' : 'system'} to ${groupName || groupId}` + } + }; + return apiWithToast( + dispatch, + () => addHostsToGroupById(groupId, hostIds), + statusMessages + ); + }, + [isModalOpen] + ); + + return ( + isModalOpen && ( + <> + {/** confirmation modal */} + <ConfirmSystemsAddModal + isModalOpen={confirmationModalOpen} + onSubmit={async () => { + await handleSystemAddition([...selected.keys()]); + setTimeout(() => dispatch(fetchGroupDetail(groupId)), 500); // refetch data for this group + setIsModalOpen(false); + + }} + onBack={() => { + setConfirmationModalOpen(false); + setSystemSelectModalOpen(true); // switch back to the systems table modal + }} + onCancel={() => setIsModalOpen(false)} + hostsNumber={alreadyHasGroup.length} + /> + {/** hosts selection modal */} + <Modal + title="Add systems" + isOpen={systemsSelectModalOpen} + onClose={() => setIsModalOpen(false)} + footer={ + <Flex direction={{ default: 'column' }} style={{ width: '100%' }}> + {showWarning && ( + <FlexItem fullWidth={{ default: 'fullWidth' }}> + <Alert + variant="warning" + isInline + title="One or more of the selected systems + already belong to a group. Adding these systems + to a different group may impact system configuration." + /> + </FlexItem> + )} + <FlexItem> + <Button + key="confirm" + variant="primary" + onClick={async () => { + if (showWarning) { + setSystemSelectModalOpen(false); + setConfirmationModalOpen(true); // switch to the confirmation modal + } else { + await handleSystemAddition([ + ...selected.keys() + ]); + setTimeout( + () => + dispatch( + fetchGroupDetail(groupId) + ), + 500 + ); // refetch data for this group + setIsModalOpen(false); + } + }} + isDisabled={noneSelected} + > + Add systems + </Button> + <Button + key="cancel" + variant="link" + onClick={() => setIsModalOpen(false)} + > + Cancel + </Button> + </FlexItem> + </Flex> + } + variant="large" // required to accomodate the systems table + > + <InventoryTable + columns={prepareColumns} + variant={TableVariant.compact} // TODO: this doesn't affect the table variant + tableProps={{ + isStickyHeader: false, + canSelectAll: false + }} + bulkSelect={bulkSelectConfig(dispatch, selected.size, noneSelected, pageSelected, rows.length)} + initialLoading={true} + /> + </Modal> + </> + ) + ); +}; + +AddSystemsToGroupModal.propTypes = { + isModalOpen: PropTypes.bool, + setIsModalOpen: PropTypes.func, + reloadData: PropTypes.func, + groupId: PropTypes.string, + groupName: PropTypes.string +}; + +export default AddSystemsToGroupModal; diff --git a/src/components/InventoryGroups/Modals/ConfirmSystemsAddModal.js b/src/components/InventoryGroups/Modals/ConfirmSystemsAddModal.js new file mode 100644 index 000000000..48682b155 --- /dev/null +++ b/src/components/InventoryGroups/Modals/ConfirmSystemsAddModal.js @@ -0,0 +1,74 @@ +import { FormSpy, useFormApi } from '@data-driven-forms/react-form-renderer'; +import { Button, Flex } from '@patternfly/react-core'; +import ExclamationTriangleIcon from '@patternfly/react-icons/dist/js/icons/exclamation-triangle-icon'; +import warningColor from '@patternfly/react-tokens/dist/esm/global_warning_color_100'; +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from './Modal'; +import { confirmSystemsAddSchema } from './ModalSchemas/schemes'; + +const ConfirmSystemsAddModal = ({ + isModalOpen, + onSubmit, + onBack, + onCancel, + hostsNumber +}) => ( + <Modal + isModalOpen={isModalOpen} + title={'Add all selected systems to group?'} + titleIconVariant={() => ( + <ExclamationTriangleIcon color={warningColor.value} /> + )} + closeModal={onCancel} + schema={confirmSystemsAddSchema(hostsNumber)} + reloadData={() => {}} + onSubmit={onSubmit} + customFormTemplate={({ formFields, schema }) => { + const { handleSubmit, getState } = useFormApi(); + const { submitting, valid } = getState(); + + return ( + <form onSubmit={handleSubmit}> + <Flex + direction={{ default: 'column' }} + spaceItems={{ default: 'spaceItemsLg' }} + > + {schema.title} + {formFields} + <FormSpy> + {() => ( + <Flex> + <Button + isDisabled={submitting || !valid} + type="submit" + color="primary" + variant="primary" + > + Yes, add all systems to group + </Button> + <Button variant="secondary" onClick={onBack}> + Back + </Button> + <Button variant="link" onClick={onCancel}> + Cancel + </Button> + </Flex> + )} + </FormSpy> + </Flex> + </form> + ); + }} + /> +); + +ConfirmSystemsAddModal.propTypes = { + isModalOpen: PropTypes.bool, + onSubmit: PropTypes.func, + onBack: PropTypes.func, + onCancel: PropTypes.func, + hostsNumber: PropTypes.number +}; + +export default ConfirmSystemsAddModal; diff --git a/src/components/InventoryGroups/Modals/CreateGroupModal.js b/src/components/InventoryGroups/Modals/CreateGroupModal.js index 0a9d2ea88..48dd78803 100644 --- a/src/components/InventoryGroups/Modals/CreateGroupModal.js +++ b/src/components/InventoryGroups/Modals/CreateGroupModal.js @@ -48,7 +48,6 @@ const CreateGroupModal = ({ return ( <Modal - data-testid="create-group-modal" isModalOpen={isModalOpen} closeModal={() => setIsModalOpen(false)} title="Create group" diff --git a/src/components/InventoryGroups/Modals/Modal.js b/src/components/InventoryGroups/Modals/Modal.js index cf3b7ada9..a562a8860 100644 --- a/src/components/InventoryGroups/Modals/Modal.js +++ b/src/components/InventoryGroups/Modals/Modal.js @@ -17,6 +17,7 @@ const RepoModal = ({ reloadData, size, onSubmit, + customFormTemplate, additionalMappers }) => { return ( @@ -30,7 +31,7 @@ const RepoModal = ({ > <FormRenderer schema={schema} - FormTemplate={(props) => ( + FormTemplate={customFormTemplate ? customFormTemplate : (props) => ( <FormTemplate {...props} submitLabel={submitLabel} @@ -69,7 +70,8 @@ RepoModal.propTypes = { size: PropTypes.string, additionalMappers: PropTypes.object, titleIconVariant: PropTypes.any, - validatorMapper: PropTypes.object + validatorMapper: PropTypes.object, + customFormTemplate: PropTypes.node }; export default RepoModal; diff --git a/src/components/InventoryGroups/Modals/ModalSchemas/schemes.js b/src/components/InventoryGroups/Modals/ModalSchemas/schemes.js index f5de7a45a..02f6c5512 100644 --- a/src/components/InventoryGroups/Modals/ModalSchemas/schemes.js +++ b/src/components/InventoryGroups/Modals/ModalSchemas/schemes.js @@ -25,6 +25,23 @@ export const createGroupSchema = (namePresenceValidator) => ({ ] }); +export const confirmSystemsAddSchema = (hostsNumber) => ({ + fields: [ + { + component: componentTypes.PLAIN_TEXT, + name: 'warning-message', + label: `${hostsNumber} of the systems you selected already belong to a group. + Moving them to a different group will impact their configuration.` + }, + { + component: componentTypes.CHECKBOX, + name: 'confirmation', + label: 'I acknowledge that this action cannot be undone.', + validate: [{ type: validatorTypes.REQUIRED }] + } + ] +}); + const createDescription = (systemName) => { return ( <Text> diff --git a/src/components/InventoryGroups/Modals/RenameGroupModal.js b/src/components/InventoryGroups/Modals/RenameGroupModal.js index 2fbcf9ba4..8c1767a70 100644 --- a/src/components/InventoryGroups/Modals/RenameGroupModal.js +++ b/src/components/InventoryGroups/Modals/RenameGroupModal.js @@ -45,7 +45,7 @@ const RenameGroupModal = ({ }, onError: { title: 'Error', description: 'Failed to rename group' } }; - apiWithToast(dispatch, () => updateGroupById(id, values), statusMessages); + apiWithToast(dispatch, () => updateGroupById(id, { name: values.name }), statusMessages); }; const schema = useMemo(() => { diff --git a/src/components/InventoryGroups/utils/api.js b/src/components/InventoryGroups/utils/api.js index 7352c007b..2ae0ac650 100644 --- a/src/components/InventoryGroups/utils/api.js +++ b/src/components/InventoryGroups/utils/api.js @@ -3,6 +3,7 @@ import { instance } from '@redhat-cloud-services/frontend-components-utilities/i import { INVENTORY_API_BASE } from '../../../api'; import { TABLE_DEFAULT_PAGINATION } from '../../../constants'; import PropTypes from 'prop-types'; +import union from 'lodash/union'; import fixtureGroups from '../../../../cypress/fixtures/groups.json'; import fixtureGroupsDetails from '../../../../cypress/fixtures/groups/Ba8B79ab5adC8E41e255D5f8aDb8f1F3.json'; @@ -49,14 +50,21 @@ export const getGroupDetail = (groupId) => { }; export const updateGroupById = (id, payload) => { - return instance.patch(`${INVENTORY_API_BASE}/groups/${id}`, { - name: payload.name - }); + return instance.patch(`${INVENTORY_API_BASE}/groups/${id}`, payload); }; export const deleteGroupsById = (ids = []) => { return instance.delete(`${INVENTORY_API_BASE}/groups/${ids.join(',')}`); +}; +export const addHostsToGroupById = (id, hostIds) => { + // the current hosts must be fetched before merging with the new ones + return getGroupDetail(id).then((response) => + updateGroupById(id, { + // eslint-disable-next-line camelcase + host_ids: union(response.results[0].host_ids, hostIds) + }) + ); }; export const addHostToGroup = (groupId, newHostId) => { diff --git a/src/store/entities.js b/src/store/entities.js index d99bea7ce..e319e9e8c 100644 --- a/src/store/entities.js +++ b/src/store/entities.js @@ -22,6 +22,7 @@ import OperatingSystemFormatter from '../Utilities/OperatingSystemFormatter'; import { Tooltip } from '@patternfly/react-core'; import { verifyCulledInsightsClient } from '../Utilities/sharedFunctions'; import { fitContent } from '@patternfly/react-table'; +import isEmpty from 'lodash/isEmpty'; export const defaultState = { loaded: false, @@ -47,9 +48,11 @@ export const defaultColumns = (groupsEnabled = false) => ([ ...(groupsEnabled ? [{ key: 'groups', sortKey: 'groups', - title: 'Groups', + title: 'Group', props: { width: 10 }, - renderFunc: () => <React.Fragment>N/A</React.Fragment> + // eslint-disable-next-line camelcase + renderFunc: (value, systemId, { group_name }) => isEmpty(group_name) ? 'N/A' : group_name, + transforms: [fitContent] }] : []), { key: 'tags', @@ -172,7 +175,7 @@ function selectEntity(state, { payload }) { function versionsLoaded(state, { payload: { results } }) { return { ...state, - operatingSystems: results.map(entry => { + operatingSystems: (results || []).map(entry => { const { name, major, minor } = entry.value; const versionStringified = `${major}.${minor}`; return { label: `${name} ${versionStringified}`, value: versionStringified };