diff --git a/config/cypress.webpack.config.js b/config/cypress.webpack.config.js index 0366a1c1d..0c188b50c 100644 --- a/config/cypress.webpack.config.js +++ b/config/cypress.webpack.config.js @@ -4,7 +4,8 @@ const webpack = require('webpack'); const config = require('@redhat-cloud-services/frontend-components-config'); const { config: webpackConfig, plugins } = config({ - rootFolder: resolve(__dirname, '../'), + rootFolder: resolve(__dirname, '../') + /* Uncomment when working with local mock server: customProxy: [ { context: ['/api/inventory/v1/groups'], // you can adjust the `context` value to redirect only specific endpoints @@ -16,7 +17,7 @@ const { config: webpackConfig, plugins } = config({ proxyReq.setHeader('x-rh-identity', 'foobar'); // avoid 401 errors by providing neccessary security header } } - ] + ] */ }); plugins.push(new webpack.DefinePlugin({ diff --git a/cypress/fixtures/groups/620f9ae75A8F6b83d78F3B55Af1c4b2C.json b/cypress/fixtures/groups/620f9ae75A8F6b83d78F3B55Af1c4b2C.json new file mode 100644 index 000000000..e52cc1577 --- /dev/null +++ b/cypress/fixtures/groups/620f9ae75A8F6b83d78F3B55Af1c4b2C.json @@ -0,0 +1,38 @@ +{ + "count": 1, + "page": 1, + "per_page": 50, + "results": [ + { + "name": "ea velit incididunt", + "updated_at": "1998-04-17T22:00:00.0Z", + "id": "620f9ae75A8F6b83d78F3B55Af1c4b2C", + "account": "irure ea exercitation adipisicing velit", + "org_id": "non", + "created_at": "1994-07-28T22:00:00.0Z", + "host_ids": [ + "eEfb7FAa-1f0b-Bde3-a4FF-fDdCd5faA764", + "bb7417faE7f9eCdacEDDd0Fcae9Cf4BB", + "2095BB72Aa6E1d2D1DC48bdecF1085eb", + "E645A3Fb42B77c4eBf9faEfCad8f6F5a", + "7fb4fa758Cbc0A861C2Cb21695aeA9d6", + "f6BE4AafA6bF543693Fa3F1fadFaA4E9", + "D608C33f-5e6D-BBEF-Cab5-0FFE8eB1bAc4", + "Dd4De9b6-7f3a-ED3d-2a84-D60e4Af2Cd9d", + "ae08E8dd-FFdB-cBC1-BA2A-d0aaC61C1B55", + "dcaD88bD4CeaaDC8bceD5d730ffba4cF", + "5a9F9CDAE74F3116a9c848Eeb1C65EA0", + "f53eDCBe3Fb957BE5dBec9322f030F94", + "c6fbBE38-30D7-D7e9-C8C7-72F4a61Bea9c", + "20E5EedA1e3f2aC2dCaADDCa4BFEED5B", + "adEcDf6ac4A999ccecdbCe7E9e01e23C", + "Bf0E2e8C-7e96-f0C8-9fea-1bD5fa9E18ce", + "6349ADdf-8d6B-bF1e-F5AE-13f0B7ca4acE", + "DBD5E149-C967-12b3-Cc6B-6c9220bA32e1", + "4EC19BbD-b556-ED9E-33E7-Eb5Be40AaeDe", + "CE05B39b3FdDad8dDE2b5DebB7CABe7D" + ] + } + ], + "total": 1 +} diff --git a/cypress/support/interceptors.js b/cypress/support/interceptors.js index 64f40d27b..0c3f8d2a2 100644 --- a/cypress/support/interceptors.js +++ b/cypress/support/interceptors.js @@ -1,6 +1,7 @@ /* eslint-disable camelcase */ import { DEFAULT_ROW_COUNT } from '@redhat-cloud-services/frontend-components-utilities'; import fixtures from '../fixtures/groups.json'; +import groupDetailFixtures from '../fixtures/groups/620f9ae75A8F6b83d78F3B55Af1c4b2C.json'; export const groupsInterceptors = { 'successful with some items': () => @@ -15,7 +16,7 @@ export const groupsInterceptors = { }) .as('getGroups'), 'failed with server error': () => { - Cypress.on('uncaught:exception', (err, runnable) => { + Cypress.on('uncaught:exception', () => { return false; }); cy.intercept('GET', '/api/inventory/v1/groups*', { statusCode: 500 }).as( @@ -31,3 +32,30 @@ export const groupsInterceptors = { }).as('getGroups'); } }; + +export const groupDetailInterceptors = { + successful: () => + cy + .intercept('GET', '/api/inventory/v1/groups/620f9ae75A8F6b83d78F3B55Af1c4b2C', groupDetailFixtures) + .as('getGroupDetail'), + empty: () => + cy + .intercept('GET', '/api/inventory/v1/groups/620f9ae75A8F6b83d78F3B55Af1c4b2C', { statusCode: 404 }) + .as('getGroupDetail'), + 'failed with server error': () => { + Cypress.on('uncaught:exception', () => { + return false; + }); + cy.intercept('GET', '/api/inventory/v1/groups/620f9ae75A8F6b83d78F3B55Af1c4b2C', { statusCode: 500 }).as( + 'getGroupDetail' + ); + }, + 'long responding': () => { + cy.intercept('GET', '/api/inventory/v1/groups/620f9ae75A8F6b83d78F3B55Af1c4b2C', (req) => { + req.reply({ + body: groupDetailFixtures, + delay: 42000000 // milliseconds + }); + }).as('getGroupDetail'); + } +}; diff --git a/src/Routes.js b/src/Routes.js index 39cd166a5..f2fa101cb 100644 --- a/src/Routes.js +++ b/src/Routes.js @@ -1,20 +1,21 @@ import { Route, Redirect, Switch } from 'react-router-dom'; import React, { lazy, Suspense, useMemo } from 'react'; -import { EmptyState, EmptyStateBody } from '@patternfly/react-core'; -import { InvalidObject } from '@redhat-cloud-services/frontend-components'; import { getSearchParams } from './constants'; import RenderWrapper from './Utilities/Wrapper'; import useFeatureFlag from './Utilities/useFeatureFlag'; +import LostPage from './components/LostPage'; const InventoryTable = lazy(() => import('./routes/InventoryTable')); const InventoryDetail = lazy(() => import('./routes/InventoryDetail')); const InventoryGroups = lazy(() => import('./routes/InventoryGroups')); +const InventoryGroupDetail = lazy(() => import('./routes/InventoryGroupDetail')); export const routes = { table: '/', detail: '/:inventoryId', detailWithModal: '/:inventoryId/:modalId', - groups: '/groups' + groups: '/groups', + groupDetail: '/groups/:groupId' }; export const Routes = () => { @@ -38,17 +39,13 @@ export const Routes = () => { ( - - - - - - ) - } + component={groupsEnabled ? InventoryGroups : LostPage} + rootClass="inventory" + /> + diff --git a/src/components/InventoryGroupDetail/GroupDetailHeader.js b/src/components/InventoryGroupDetail/GroupDetailHeader.js new file mode 100644 index 000000000..9ef4e4830 --- /dev/null +++ b/src/components/InventoryGroupDetail/GroupDetailHeader.js @@ -0,0 +1,39 @@ +import { Breadcrumb, BreadcrumbItem, Skeleton } from '@patternfly/react-core'; +import { + PageHeader, + PageHeaderTitle +} from '@redhat-cloud-services/frontend-components'; +import React from 'react'; +import { useSelector } from 'react-redux'; +import { Link } from 'react-router-dom'; +import { routes } from '../../Routes'; +import PropTypes from 'prop-types'; + +const GroupDetailHeader = ({ groupId }) => { + const { uninitialized, loading, data } = useSelector((state) => state.groupDetail); + + const nameOrId = uninitialized || loading ? ( + + ) : ( + // in case of error, render just id from URL + data?.results?.[0]?.name || groupId + ); + + return ( + + + + Groups + + {nameOrId} + + + + ); +}; + +GroupDetailHeader.propTypes = { + groupId: PropTypes.string.isRequired +}; + +export default GroupDetailHeader; diff --git a/src/components/InventoryGroupDetail/GroupDetailInfo.js b/src/components/InventoryGroupDetail/GroupDetailInfo.js new file mode 100644 index 000000000..01a935077 --- /dev/null +++ b/src/components/InventoryGroupDetail/GroupDetailInfo.js @@ -0,0 +1,20 @@ +import { EmptyState, EmptyStateBody, Spinner } from '@patternfly/react-core'; +import { InvalidObject } from '@redhat-cloud-services/frontend-components'; +import React from 'react'; +import { useSelector } from 'react-redux'; + +const GroupDetailInfo = () => { + const { uninitialized, loading } = useSelector((state) => state.groupDetail); + + // TODO: implement according to mocks + + return ( + + + {uninitialized || loading ? : } + + + ); +}; + +export default GroupDetailInfo; diff --git a/src/components/InventoryGroupDetail/GroupDetailSystems.js b/src/components/InventoryGroupDetail/GroupDetailSystems.js new file mode 100644 index 000000000..e9470ce71 --- /dev/null +++ b/src/components/InventoryGroupDetail/GroupDetailSystems.js @@ -0,0 +1,22 @@ +import { EmptyState, EmptyStateBody, Spinner } from '@patternfly/react-core'; +import React from 'react'; +import { useSelector } from 'react-redux'; +import NoSystemsEmptyState from './NoSystemsEmptyState'; + +const GroupDetailSystems = () => { + const { uninitialized, loading } = useSelector((state) => state.groupDetail); + + // TODO: integrate the inventory table + + return (uninitialized || loading ? + + + + + + : + + ); +}; + +export default GroupDetailSystems; diff --git a/src/components/InventoryGroupDetail/InventoryGroupDetail.cy.js b/src/components/InventoryGroupDetail/InventoryGroupDetail.cy.js new file mode 100644 index 000000000..b487e971b --- /dev/null +++ b/src/components/InventoryGroupDetail/InventoryGroupDetail.cy.js @@ -0,0 +1,62 @@ +import { mount } from '@cypress/react'; +import React from 'react'; +import { Provider } from 'react-redux'; +import { MemoryRouter } from 'react-router-dom'; +import groupDetailFixtures from '../../../cypress/fixtures/groups/620f9ae75A8F6b83d78F3B55Af1c4b2C.json'; +import { groupDetailInterceptors as interceptors } from '../../../cypress/support/interceptors'; +import { getStore } from '../../store'; +import InventoryGroupDetail from './InventoryGroupDetail'; + +const TEST_GROUP_ID = '620f9ae75A8F6b83d78F3B55Af1c4b2C'; + +const mountPage = () => + mount( + + + + + + ); + +before(() => { + cy.window().then( + (window) => + (window.insights = { + chrome: { + isProd: false, + auth: { + getUser: () => { + return Promise.resolve({}); + } + } + } + }) + ); +}); + +describe('group detail page', () => { + it('name from server is rendered in header and breadcrumb', () => { + interceptors.successful(); + mountPage(); + + cy.wait('@getGroupDetail'); + cy.get('h1').contains(groupDetailFixtures.results[0].name); + cy.get('[data-ouia-component-type="PF4/Breadcrumb"] li') + .last() + .should('have.text', groupDetailFixtures.results[0].name); + }); + + it('skeletons rendered while fetching data', () => { + // TODO: after each hook fails for some reason for this particular test + Cypress.on('uncaught:exception', () => { + return false; + }); + + interceptors['long responding'](); + mountPage(); + + cy.get('[data-ouia-component-type="PF4/Breadcrumb"] li').last().find('.pf-c-skeleton'); + cy.get('h1').find('.pf-c-skeleton'); + cy.get('.pf-c-empty-state').find('.pf-c-spinner'); + }); +}); diff --git a/src/components/InventoryGroupDetail/InventoryGroupDetail.js b/src/components/InventoryGroupDetail/InventoryGroupDetail.js new file mode 100644 index 000000000..816d81043 --- /dev/null +++ b/src/components/InventoryGroupDetail/InventoryGroupDetail.js @@ -0,0 +1,89 @@ +import React, { lazy, Suspense, useEffect } from 'react'; +import useChrome from '@redhat-cloud-services/frontend-components/useChrome'; +import { useDispatch, useSelector } from 'react-redux'; +import { fetchGroupDetail } from '../../store/inventory-actions'; +import { + Bullseye, + PageSection, + Spinner, + Tab, + Tabs, + TabTitleText +} from '@patternfly/react-core'; +import { useState } from 'react'; +import GroupDetailHeader from './GroupDetailHeader'; +import GroupDetailSystems from './GroupDetailSystems'; +import PropTypes from 'prop-types'; + +const GroupDetailInfo = lazy(() => import('./GroupDetailInfo')); + +const InventoryGroupDetail = ({ groupId }) => { + const dispatch = useDispatch(); + const { data } = useSelector((state) => state.groupDetail); + const chrome = useChrome(); + + useEffect(() => { + dispatch(fetchGroupDetail(groupId)); + }, []); + + useEffect(() => { + // if available, change ID to the group's name in the window title + chrome?.updateDocumentTitle?.( + `${data?.name || groupId} - Inventory Groups | Red Hat Insights` + ); + }, [data]); + + const [activeTabKey, setActiveTabKey] = useState(0); + + // TODO: append search parameter to identify the active tab + + return ( + + + + setActiveTabKey(value)} + aria-label="Group tabs" + role="region" + inset={{ default: 'insetMd' }} // add extra space before the first tab (according to mocks) + > + Systems} + aria-label="Group systems tab" + > + + + + + Group info} + aria-label="Group info tab" + > + {activeTabKey === 1 && ( // helps to lazy load the component + + + + + } + > + + + + )} + + + + + ); +}; + +InventoryGroupDetail.propTypes = { + groupId: PropTypes.string.isRequired +}; + +export default InventoryGroupDetail; diff --git a/src/components/InventoryGroupDetail/NoSystemsEmptyState.js b/src/components/InventoryGroupDetail/NoSystemsEmptyState.js new file mode 100644 index 000000000..decc11181 --- /dev/null +++ b/src/components/InventoryGroupDetail/NoSystemsEmptyState.js @@ -0,0 +1,42 @@ +import React from 'react'; +import { + Button, + EmptyState, + EmptyStateBody, + EmptyStateIcon, + EmptyStateSecondaryActions, + Title +} from '@patternfly/react-core'; +import { ExternalLinkAltIcon, PlusCircleIcon } from '@patternfly/react-icons'; + +import { global_palette_black_600 as globalPaletteBlack600 } from '@patternfly/react-tokens/dist/js/global_palette_black_600'; + +const NoSystemsEmptyState = () => { + return ( + + + + No systems added + + + To manage systems more effectively, add systems to the group. + + + + + + + );}; + +export default NoSystemsEmptyState; diff --git a/src/components/InventoryGroupDetail/__tests__/GroupDetailHeader.test.js b/src/components/InventoryGroupDetail/__tests__/GroupDetailHeader.test.js new file mode 100644 index 000000000..10a77cc0e --- /dev/null +++ b/src/components/InventoryGroupDetail/__tests__/GroupDetailHeader.test.js @@ -0,0 +1,44 @@ +import '@testing-library/jest-dom'; +import { render } from '@testing-library/react'; +import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import GroupDetailHeader from '../GroupDetailHeader'; + +jest.mock('react-redux', () => { + return { + ...jest.requireActual('react-redux'), + useSelector: () => ({ + uninitialized: false, + loading: false, + data: { + results: [ + { + name: 'group-name-1' + } + ] + } + }) + }; +}); + +describe('group detail header', () => { + let getByRole; + + beforeEach(() => { + const rendered = render( + + + + ); + getByRole = rendered.getByRole; + }); + + it('renders title and breadcrumbs', () => { + expect(getByRole('heading')).toBeInTheDocument(); + }); + + it('has breadcrumbs', () => { + expect(getByRole('navigation')).toHaveClass('pf-c-breadcrumb'); + expect(getByRole('navigation')).toHaveTextContent('group-name-1'); + }); +}); diff --git a/src/components/InventoryGroupDetail/__tests__/InventoryGroupDetail.test.js b/src/components/InventoryGroupDetail/__tests__/InventoryGroupDetail.test.js new file mode 100644 index 000000000..44f42fa57 --- /dev/null +++ b/src/components/InventoryGroupDetail/__tests__/InventoryGroupDetail.test.js @@ -0,0 +1,44 @@ +import '@testing-library/jest-dom'; +import { render } from '@testing-library/react'; +import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import InventoryGroupDetail from '../InventoryGroupDetail'; + +jest.mock('react-redux', () => { + return { + ...jest.requireActual('react-redux'), + useSelector: () => ({ + uninitialized: false, + loading: false, + data: { + results: [ + { + name: 'group-name-1' + } + ] + } + }), + useDispatch: () => () => {} + }; +}); + +describe('group detail page component', () => { + let getByRole; + let container; + + beforeEach(() => { + const rendered = render( + + + + ); + getByRole = rendered.getByRole; + container = rendered.container; + }); + + it('renders two tabs', () => { + expect(getByRole('tablist')).toBeInTheDocument(); + expect(container.querySelectorAll('.pf-c-tabs__item')[0]).toHaveTextContent('Systems'); + expect(container.querySelectorAll('.pf-c-tabs__item')[1]).toHaveTextContent('Group info'); + }); +}); diff --git a/src/components/InventoryGroupDetail/index.js b/src/components/InventoryGroupDetail/index.js new file mode 100644 index 000000000..91b334ef3 --- /dev/null +++ b/src/components/InventoryGroupDetail/index.js @@ -0,0 +1,11 @@ +import React from 'react'; +import { useParams } from 'react-router-dom'; +import InventoryGroupDetail from './InventoryGroupDetail'; + +const InventoryGroupDetailWrapper = () => { + const { groupId } = useParams(); + + return ; +}; + +export default InventoryGroupDetailWrapper; diff --git a/src/components/InventoryGroups/utils/api.js b/src/components/InventoryGroups/utils/api.js index 5d506a734..d427f7673 100644 --- a/src/components/InventoryGroups/utils/api.js +++ b/src/components/InventoryGroups/utils/api.js @@ -25,6 +25,10 @@ export const validateGroupName = (name) => { .then((resp) => resp?.results.some((group) => group.name === name)); }; +export const getGroupDetail = (groupId) => { + return instance.get(`${INVENTORY_API_BASE}/groups/${groupId}`); +}; + export const updateGroupById = (id, payload) => { return instance.patch(`${INVENTORY_API_BASE}/groups/${id}`, { name: payload.name @@ -46,3 +50,7 @@ getGroups.propTypes = { page: PropTypes.number }) }; + +getGroupDetail.propTypes = { + groupId: PropTypes.string.isRequired +}; diff --git a/src/components/LostPage.js b/src/components/LostPage.js new file mode 100644 index 000000000..f2cdc6da3 --- /dev/null +++ b/src/components/LostPage.js @@ -0,0 +1,13 @@ +import { EmptyState, EmptyStateBody } from '@patternfly/react-core'; +import { InvalidObject } from '@redhat-cloud-services/frontend-components'; +import React from 'react'; + +const LostPage = () => ( + + + + + +); + +export default LostPage; diff --git a/src/routes/InventoryGroupDetail.js b/src/routes/InventoryGroupDetail.js new file mode 100644 index 000000000..235b9c98f --- /dev/null +++ b/src/routes/InventoryGroupDetail.js @@ -0,0 +1,3 @@ +import InventoryGroupDetail from '../components/InventoryGroupDetail'; + +export default InventoryGroupDetail; diff --git a/src/store/action-types.js b/src/store/action-types.js index 15b7e98e7..a3911afcf 100644 --- a/src/store/action-types.js +++ b/src/store/action-types.js @@ -35,7 +35,8 @@ export const asyncInventory = [ 'LOAD_TAGS', 'ALL_TAGS', 'OPERATING_SYSTEMS', - 'GROUPS' + 'GROUPS', + 'GROUP_DETAIL' ]; export const systemIssues = [ diff --git a/src/store/groupDetail.js b/src/store/groupDetail.js new file mode 100644 index 000000000..ef02d36e8 --- /dev/null +++ b/src/store/groupDetail.js @@ -0,0 +1,45 @@ + +import { applyReducerHash } from '@redhat-cloud-services/frontend-components-utilities/ReducerRegistry'; +import { ACTION_TYPES } from './action-types'; + +export const initialState = { + loading: false, + rejected: false, + fulfilled: false, + uninitialized: true, + error: null, + data: null +}; + +export default applyReducerHash( + { + [ACTION_TYPES.GROUP_DETAIL_PENDING]: (state) => { + return { + ...state, + loading: true, + uninitialized: false + }; + }, + [ACTION_TYPES.GROUP_DETAIL_FULFILLED]: (state, { payload }) => { + return { + ...state, + loading: false, + rejected: false, + uninitialized: false, + fulfilled: true, + data: payload + }; + }, + [ACTION_TYPES.GROUP_DETAIL_REJECTED]: (state, { payload }) => { + return { + ...state, + loading: false, + rejected: true, + uninitialized: false, + fulfilled: false, + error: payload + }; + } + }, + initialState +); diff --git a/src/store/inventory-actions.js b/src/store/inventory-actions.js index af415c14e..57052c366 100644 --- a/src/store/inventory-actions.js +++ b/src/store/inventory-actions.js @@ -20,7 +20,7 @@ import { filtersReducer, getOperatingSystems } from '../api'; -import { getGroups } from '../components/InventoryGroups/utils/api'; +import { getGroupDetail, getGroups } from '../components/InventoryGroups/utils/api'; export const loadEntities = (items = [], { filters, ...config }, { showTags } = {}, getEntities = defaultGetEntities) => { const itemIds = items.reduce((acc, curr) => ( @@ -178,6 +178,11 @@ export const fetchGroups = (search, pagination) => ({ payload: getGroups(search, pagination) }); +export const fetchGroupDetail = (groupId) => ({ + type: ACTION_TYPES.GROUP_DETAIL, + payload: getGroupDetail(groupId) +}); + export const fetchOperatingSystems = (params = []) => ({ type: ACTION_TYPES.OPERATING_SYSTEMS, payload: getOperatingSystems(params) diff --git a/src/store/reducers.js b/src/store/reducers.js index cdfcdb0bb..8b3c08794 100644 --- a/src/store/reducers.js +++ b/src/store/reducers.js @@ -12,6 +12,7 @@ import { notificationsReducer } from '@redhat-cloud-services/frontend-components import entitiesReducer, { defaultState as entitiesDefault } from './entities'; import entityDetailsReducer, { entityDefaultState as entityDefault, updateEntity } from './entityDetails'; import groups from './groups'; +import groupDetail from './groupDetail'; export { entitiesReducer, entityDetailsReducer }; @@ -114,7 +115,8 @@ function onSetPagination(state, { payload }) { let reducers = { notifications: notificationsReducer, systemProfileStore, - groups + groups, + groupDetail }; export const tableReducer = applyReducerHash(