diff --git a/src/components/InventoryGroups/Modals/AddHostToGroupModal.js b/src/components/InventoryGroups/Modals/AddHostToGroupModal.js new file mode 100644 index 000000000..0d8dc8eb5 --- /dev/null +++ b/src/components/InventoryGroups/Modals/AddHostToGroupModal.js @@ -0,0 +1,92 @@ +import React, { useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import Modal from './Modal'; +import { addHostToGroup } from '../utils/api'; +import apiWithToast from '../utils/apiWithToast'; +import { useDispatch } from 'react-redux'; +import { CreateGroupButton } from '../SmallComponents/CreateGroupButton'; +import SearchInput from './SearchInput'; +import { fetchGroups } from '../../../store/inventory-actions'; +import { addHostSchema } from './ModalSchemas/schemes'; +import CreateGroupModal from './CreateGroupModal'; + +const AddHostToGroupModal = ({ + isModalOpen, + setIsModalOpen, + modalState, + reloadData +}) => { + const dispatch = useDispatch(); + //we have to fetch groups to make them available in state + useEffect(() => { + dispatch(fetchGroups()); + + }, []); + const [isCreateGroupModalOpen, setIsCreateGroupModalOpen] = useState(false); + + const handleAddDevices = (values) => { + const { group } = values; + const statusMessages = { + onSuccess: { + title: 'Success', + description: `System(s) have been added to ${group.toString()} successfully` + }, + onError: { title: 'Error', description: `Failed to add ${modalState.name} to ${modalState.groupName}` } + }; + + apiWithToast( + dispatch, + () => addHostToGroup(group.groupId, modalState.id), + statusMessages + ); + }; + + return ( + <> + setIsModalOpen(false)} + title="Add to group" + submitLabel="Add" + schema={addHostSchema(modalState.name)} + additionalMappers={{ + 'search-input': { + component: SearchInput + }, + 'create-group-btn': { + component: CreateGroupButton, + closeModal: () => { + setIsCreateGroupModalOpen(true); + setIsModalOpen(false); + } + } + }} + initialValues={modalState} + onSubmit={handleAddDevices} + reloadData={reloadData} + /> + {isCreateGroupModalOpen && ( + console.log('data reloaded')} + /> + )} + + ); +}; + +AddHostToGroupModal.propTypes = { + modalState: PropTypes.shape({ + id: PropTypes.string, + name: PropTypes.string, + groupName: PropTypes.string + }), + isModalOpen: PropTypes.bool, + setIsModalOpen: PropTypes.func, + reloadData: PropTypes.func, + setIsCreateGroupModalOpen: PropTypes.func, + deviceIds: PropTypes.array +}; + +export default AddHostToGroupModal; diff --git a/src/components/InventoryGroups/Modals/Modal.js b/src/components/InventoryGroups/Modals/Modal.js index 4f7380b2f..cf3b7ada9 100644 --- a/src/components/InventoryGroups/Modals/Modal.js +++ b/src/components/InventoryGroups/Modals/Modal.js @@ -16,7 +16,8 @@ const RepoModal = ({ variant, reloadData, size, - onSubmit + onSubmit, + additionalMappers }) => { return ( )} initialValues={initialValues} - componentMapper={componentMapper} + componentMapper={additionalMappers + ? { ...additionalMappers, ...componentMapper } + : componentMapper} //reload comes from the table and fetches fresh data onSubmit={async (values) => { await onSubmit(values); diff --git a/src/components/InventoryGroups/Modals/ModalSchemas/schemes.js b/src/components/InventoryGroups/Modals/ModalSchemas/schemes.js index 1a5e306c1..f5de7a45a 100644 --- a/src/components/InventoryGroups/Modals/ModalSchemas/schemes.js +++ b/src/components/InventoryGroups/Modals/ModalSchemas/schemes.js @@ -1,6 +1,8 @@ +import React from 'react'; import validatorTypes from '@data-driven-forms/react-form-renderer/validator-types'; import componentTypes from '@data-driven-forms/react-form-renderer/component-types'; import { nameValidator } from '../../helpers/validate'; +import { Text } from '@patternfly/react-core'; export const createGroupSchema = (namePresenceValidator) => ({ fields: [ @@ -22,3 +24,32 @@ export const createGroupSchema = (namePresenceValidator) => ({ } ] }); + +const createDescription = (systemName) => { + return ( + + Select a group to add {systemName} to, or create a new one. + + ); +}; + +//this is a custom schema that is passed via additional mappers to the Modal component +//it allows to create custom item types in the modal + +export const addHostSchema = (systemName) => ({ + fields: [ + { + component: componentTypes.PLAIN_TEXT, + name: 'description', + label: createDescription(systemName) + }, + { + component: 'search-input', + name: 'group', + label: 'Select a group', + isRequired: true, + validate: [{ type: validatorTypes.REQUIRED }] + }, + { component: 'create-group-btn', name: 'create-group-btn' } + ] +}); diff --git a/src/components/InventoryGroups/Modals/RenameGroupModal.cy.js b/src/components/InventoryGroups/Modals/RenameGroupModal.cy.js index b0e270f81..21559fa45 100644 --- a/src/components/InventoryGroups/Modals/RenameGroupModal.cy.js +++ b/src/components/InventoryGroups/Modals/RenameGroupModal.cy.js @@ -8,45 +8,9 @@ import { import { Provider } from 'react-redux'; import { MemoryRouter } from 'react-router-dom'; import { getStore } from '../../../store'; +import groups from '../../../../cypress/fixtures/groups.json'; -const mockResponse = [ - { - count: 50, - page: 20, - per_page: 20, - total: 50, - results: [ - { - created_at: '2020-02-09T10:16:07.996Z', - host_ids: ['bA6deCFc19564430AB814bf8F70E8cEf'], - id: '3f01b55457674041b75e41829bcee1dca', - name: 'sre-group0', - updated_at: '2020-02-09T10:16:07.996Z' - }, - { - created_at: '2020-02-09T10:16:07.996Z', - host_ids: ['bA6deCFc19564430AB814bf8F70E8cEf'], - id: '3f01b55457674041b75e41829bcee1dca', - name: 'sre-group1', - updated_at: '2020-02-09T10:16:07.996Z' - }, - { - created_at: '2020-02-09T10:16:07.996Z', - host_ids: ['bA6deCFc19564430AB814bf8F70E8cEf'], - id: '3f01b55457674041b75e41829bcee1dca', - name: 'sre-group2', - updated_at: '2020-02-09T10:16:07.996Z' - }, - { - created_at: '2020-02-09T10:16:07.996Z', - host_ids: ['bA6deCFc19564430AB814bf8F70E8cEf'], - id: '3f01b55457674041b75e41829bcee1dca', - name: 'sre-group3', - updated_at: '2020-02-09T10:16:07.996Z' - } - ] - } -]; +const mockResponse = [groups]; describe('render Rename Group Modal', () => { before(() => { @@ -74,21 +38,21 @@ describe('render Rename Group Modal', () => { } }).as('rename'); + }); + + it('Input is fillable and firing a validation request that succeeds', () => { mount( console.log('data reloaded')} - modalState={{ id: '1', name: 'sre-group' }} + modalState={{ id: '1', name: 'Ut occaeca' }} /> ); - }); - - it('Input is fillable and firing a validation request that succeeds', () => { - cy.get(TEXT_INPUT).type('0'); + cy.get(TEXT_INPUT).type('t'); cy.wait('@validate').then((xhr) => { expect(xhr.request.url).to.contain('groups');} ); @@ -96,6 +60,17 @@ describe('render Rename Group Modal', () => { }); it('User can rename the group', () => { + mount( + + + console.log('data reloaded')} + modalState={{ id: '1', name: 'sre-group' }} + /> + + + ); cy.get(TEXT_INPUT).type('newname'); cy.get(`button[type="submit"]`).should('have.attr', 'aria-disabled', 'false'); cy.get(`button[type="submit"]`).click(); diff --git a/src/components/InventoryGroups/Modals/SearchInput.js b/src/components/InventoryGroups/Modals/SearchInput.js new file mode 100644 index 000000000..ee08987c4 --- /dev/null +++ b/src/components/InventoryGroups/Modals/SearchInput.js @@ -0,0 +1,113 @@ +import React, { useState, useEffect } from 'react'; +import { useSelector } from 'react-redux'; +import { + HelperText, + HelperTextItem, + Select, + SelectOption +} from '@patternfly/react-core'; +import useFormApi from '@data-driven-forms/react-form-renderer/use-form-api'; + +const SearchInput = () => { + const { change } = useFormApi(); + const [isLoading, setIsLoading] = useState(true); + //fetch data from the store + const storeGroups = useSelector(({ groups }) => groups?.data?.results); + + //select options is a constructed array of objects with values for dropdown + const [selectOptions, setSelectOptions] = useState([]); + //when storeGroups is changed - we create selectOptions + useEffect(() => { + setSelectOptions( + (storeGroups || []).reduce((acc, group) => { + acc.push({ + DeviceGroup: { + ID: group.id, + Name: group.name, + UpdatedAt: group.updated_at, + CreatedAt: group.created_at + } + }); + return acc; + }, []) + ); + setIsLoading(false); + }, [storeGroups]); + + const [isOpen, setIsOpen] = useState(false); + const [selected, setSelected] = useState(null); + const [searchTerm, setSearchTerm] = useState(''); + + const onToggle = (isOpen) => { + setIsOpen(isOpen); + }; + + const updateSelection = (value) => { + // Update state when an option has been selected. + setSelected(value); + setIsOpen(false); + //this is requried to make select component pass the saved data up to the modal + change('group', value); + }; + + const clearSelection = () => { + setSearchTerm(''); + updateSelection(null); + setIsOpen(false); + }; + + const onSelect = (_event, selection, isPlaceholder) => { + if (isPlaceholder) { + clearSelection(); + } + else { + updateSelection(selection); + } + }; + + return ( + <> + + {!isLoading && !selected && isOpen && selectOptions.length ? ( + + Over {selectOptions.length} results found. Refine your search. + + ) : ( + + Select a group + + )} + + + + ); +}; + +export default SearchInput; diff --git a/src/components/InventoryGroups/Modals/__tests__/SearchInput.test.js b/src/components/InventoryGroups/Modals/__tests__/SearchInput.test.js new file mode 100644 index 000000000..b5d927ab4 --- /dev/null +++ b/src/components/InventoryGroups/Modals/__tests__/SearchInput.test.js @@ -0,0 +1,26 @@ +/* eslint-disable camelcase */ + +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import SearchInput from '../SearchInput'; +import { Provider } from 'react-redux'; +import configureStore from 'redux-mock-store'; +import groups from '../../../../../cypress/fixtures/groups.json'; + +describe('SearchInput', () => { + let mockStore; + const initialStore = { + groups + }; + beforeEach(() => { + mockStore = configureStore(); + }); + + test('displays select options when the user clicks on the component', async () => { + const store = mockStore(initialStore); + render(); + fireEvent.click(screen.getByRole('textbox', { placeholder: 'Type or click to select a group' })); + const options = await screen.findAllByRole('option'); + expect(options).toHaveLength(1); + }); +}); diff --git a/src/components/InventoryGroups/SmallComponents/CreateGroupButton.js b/src/components/InventoryGroups/SmallComponents/CreateGroupButton.js new file mode 100644 index 000000000..93097edce --- /dev/null +++ b/src/components/InventoryGroups/SmallComponents/CreateGroupButton.js @@ -0,0 +1,16 @@ +import React from 'react'; +import { Button, Text } from '@patternfly/react-core'; +import PropTypes from 'prop-types'; + +export const CreateGroupButton = ({ closeModal }) => ( + <> + Or + + +); + +CreateGroupButton.propTypes = { + closeModal: PropTypes.func +}; diff --git a/src/components/InventoryGroups/utils/api.js b/src/components/InventoryGroups/utils/api.js index eb0eb7b6c..7352c007b 100644 --- a/src/components/InventoryGroups/utils/api.js +++ b/src/components/InventoryGroups/utils/api.js @@ -1,3 +1,4 @@ +/* eslint-disable camelcase */ import { instance } from '@redhat-cloud-services/frontend-components-utilities/interceptors/interceptors'; import { INVENTORY_API_BASE } from '../../../api'; import { TABLE_DEFAULT_PAGINATION } from '../../../constants'; @@ -58,6 +59,12 @@ export const deleteGroupsById = (ids = []) => { }; +export const addHostToGroup = (groupId, newHostId) => { + return instance.post(`${INVENTORY_API_BASE}/groups/${groupId}/hosts/${newHostId}`, { + host_ids: newHostId + }); +}; + getGroups.propTypes = { search: PropTypes.shape({ // eslint-disable-next-line camelcase diff --git a/src/components/InventoryTable/helpers.js b/src/components/InventoryTable/helpers.js index 862c4d1d7..1b181d88a 100644 --- a/src/components/InventoryTable/helpers.js +++ b/src/components/InventoryTable/helpers.js @@ -19,6 +19,13 @@ export const buildCells = (item, columns, extra) => { }); }; +//returns an array of objects representing rows for a table. +//The function takes three parameters: "rows", "columns", and an object with several optional properties. +//The "rows" parameter is an array of objects, where each object represents a single row. +//The "columns" parameter is also an array of objects, where each object represents a single column in the table. +//The third parameter is an object with several optional properties, including "actions", +//"expandable", "noSystemsTable", and "extra". These properties are destructured from +//the object using object destructuring syntax. export const createRows = (rows = [], columns = [], { actions, expandable, noSystemsTable, ...extra } = {}) => { if (rows.length === 0) { return [{ @@ -32,6 +39,12 @@ export const createRows = (rows = [], columns = [], { actions, expandable, noSys }]; } + //If the "rows" parameter is not empty, the function maps over each row object in the "rows" + //array and creates an array of two objects for each row. The first object represents the + //row itself and contains the "cells" property, which is an array of objects representing + //each cell in the row. The "actionProps" property is also set to an object containing the + //"data-ouia-component-id" property, which is set to a string combining the row's "id" property + //and the string "-actions-kebab". return flatten(rows.map((oneItem, key) => ([{ ...oneItem, ...oneItem.children && expandable && { isOpen: !!oneItem.isOpen }, @@ -39,7 +52,13 @@ export const createRows = (rows = [], columns = [], { actions, expandable, noSys actionProps: { 'data-ouia-component-id': `${oneItem.id}-actions-kebab` } - }, oneItem.children && expandable && { + }, + //The second object represents the child row, which is only created if the "expandable" + //property is set to true and the row has a "children" property. This object has the + //"cells" property set to an array containing a single object representing the cell + //in the row. The "parent" property is set to the index of the parent row multiplied by 2, + //and the "fullWidth" property is set to true. + oneItem.children && expandable && { cells: [ { title: typeof oneItem.children === 'function' ? oneItem.children() : oneItem.children diff --git a/src/routes/InventoryTable.js b/src/routes/InventoryTable.js index aa1bc7f1b..82630215f 100644 --- a/src/routes/InventoryTable.js +++ b/src/routes/InventoryTable.js @@ -14,6 +14,8 @@ import flatMap from 'lodash/flatMap'; import { useWritePermissions, RHCD_FILTER_KEY, UPDATE_METHOD_KEY, generateFilter } from '../Utilities/constants'; import { InventoryTable as InventoryTableCmp } from '../components/InventoryTable'; import useChrome from '@redhat-cloud-services/frontend-components/useChrome'; +import AddHostToGroupModal from '../components/InventoryGroups/Modals/AddHostToGroupModal'; +import useFeatureFlag from '../Utilities/useFeatureFlag'; const reloadWrapper = (event, callback) => { event.payload.then(callback); @@ -107,15 +109,16 @@ const Inventory = ({ lastSeenFilter) ); const [ediOpen, onEditOpen] = useState(false); + const [addHostGroupModalOpen, setAddHostGroupModalOpen] = useState(false); const [globalFilter, setGlobalFilter] = useState(); const writePermissions = useWritePermissions(); const rows = useSelector(({ entities }) => entities?.rows, shallowEqual); const loaded = useSelector(({ entities }) => entities?.loaded); const selected = useSelector(({ entities }) => entities?.selected); const dispatch = useDispatch(); + const groupsEnabled = useFeatureFlag('hbi.ui.inventory-groups'); const onSelectRows = (id, isSelected) => dispatch(actions.selectEntity(id, isSelected)); - const onRefresh = (options, callback) => { onSetfilters(options?.filters); const searchParams = new URLSearchParams(); @@ -168,6 +171,53 @@ const Inventory = ({ const calculateSelected = () => selected ? selected.size : 0; + //This wrapping of table actions allows to pass feature flag status and receive a prepared array of actions + const tableActions = (groupsUiStatus, row) => { + const isGroupPresentForThisRow = (row) => { + return row && row?.groups?.title !== ''; + }; + + const standardActions = [ + { + title: 'Edit', + onClick: (_event, _index, data) => { + activateSystem(() => data); + onEditOpen(() => true); + } + }, + { + title: 'Delete', + onClick: (_event, _index, { id: systemId, display_name: displayName }) => { + activateSystem(() => ({ + id: systemId, + displayName + })); + handleModalToggle(() => true); + } + } + ]; + + const actionsBehindFeatureFlag = [ + { + title: 'Add to group', + onClick: (_event, _index, { id: systemId, display_name: displayName, group_name: groupName }) => { + activateSystem(() => ({ + id: systemId, + name: displayName, + groupName + })); + setAddHostGroupModalOpen(true); + } + }, + { + title: 'Remove from group', + isDisabled: isGroupPresentForThisRow(row) + } + ]; + + return [...(groupsUiStatus ? actionsBehindFeatureFlag : []), ...standardActions]; + }; + return ( @@ -187,25 +237,10 @@ const Inventory = ({ hasCheckbox={writePermissions} autoRefresh initialLoading={initialLoading} + tableProps={ + (writePermissions && { + actionResolver: (row) => tableActions(groupsEnabled, row), canSelectAll: false })} {...(writePermissions && { - actions: [ - { - title: 'Delete', - onClick: (_event, _index, { id: systemId, display_name: displayName }) => { - activateSystem(() => ({ - id: systemId, - displayName - })); - handleModalToggle(() => true); - } - }, { - title: 'Edit', - onClick: (_event, _index, data) => { - activateSystem(() => data); - onEditOpen(() => true); - } - } - ], actionsConfig: { actions: [{ label: 'Delete', @@ -242,9 +277,6 @@ const Inventory = ({ } } })} - tableProps={{ - canSelectAll: false - }} onRowClick={(_e, id, app) => history.push(`/${id}${app ? `/${app}` : ''}`)} /> @@ -279,7 +311,6 @@ const Inventory = ({ handleModalToggle(false); }} /> - + console.log('data reloaded')} + /> ); }; diff --git a/src/routes/InventoryTable.test.js b/src/routes/InventoryTable.test.js index 5bc8f0ac8..ea7f6ca7f 100644 --- a/src/routes/InventoryTable.test.js +++ b/src/routes/InventoryTable.test.js @@ -249,8 +249,12 @@ describe('InventoryTable', () => { expect(wrapper.find('DropdownMenu')).toHaveLength(1); await act(async () => { - wrapper.find('DropdownItem').first().find('button').simulate('click'); + const dropdownItems = wrapper.find('DropdownItem'); + + const deleteDropdown = dropdownItems.at(1); + deleteDropdown.find('button').simulate('click'); }); + wrapper.update(); expect(wrapper.find(DeleteModal).props().isModalOpen).toEqual(true);