Skip to content

Commit

Permalink
feat(ESSNTL-3726): Add the /groups/%id page (group detail) (#1771)
Browse files Browse the repository at this point in the history
* Implement the group detail reducer and add new API

Implements https://issues.redhat.com/browse/ESSNTL-3726.

* Add /groups/%id route and the page component

Implements https://issues.redhat.com/browse/ESSNTL-3726.

* Implement tests for the page and header components

Implements https://issues.redhat.com/browse/ESSNTL-3726.

* Review small fixes
  • Loading branch information
gkarat authored Feb 22, 2023
1 parent 04786b6 commit ca24e55
Show file tree
Hide file tree
Showing 20 changed files with 534 additions and 20 deletions.
5 changes: 3 additions & 2 deletions config/cypress.webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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({
Expand Down
38 changes: 38 additions & 0 deletions cypress/fixtures/groups/620f9ae75A8F6b83d78F3B55Af1c4b2C.json
Original file line number Diff line number Diff line change
@@ -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
}
30 changes: 29 additions & 1 deletion cypress/support/interceptors.js
Original file line number Diff line number Diff line change
@@ -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': () =>
Expand All @@ -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(
Expand All @@ -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');
}
};
25 changes: 11 additions & 14 deletions src/Routes.js
Original file line number Diff line number Diff line change
@@ -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 = () => {
Expand All @@ -38,17 +39,13 @@ export const Routes = () => {
<Route
exact
path={routes.groups}
component={
groupsEnabled
? InventoryGroups
: () => (
<EmptyState>
<EmptyStateBody>
<InvalidObject />
</EmptyStateBody>
</EmptyState>
)
}
component={groupsEnabled ? InventoryGroups : LostPage}
rootClass="inventory"
/>
<Route
exact
path={routes.groupDetail}
component={groupsEnabled ? InventoryGroupDetail : LostPage}
rootClass="inventory"
/>
<Route exact path={routes.detailWithModal} component={InventoryDetail} rootClass='inventory' />
Expand Down
39 changes: 39 additions & 0 deletions src/components/InventoryGroupDetail/GroupDetailHeader.js
Original file line number Diff line number Diff line change
@@ -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 ? (
<Skeleton width="250px" screenreaderText="Loading group details" />
) : (
// in case of error, render just id from URL
data?.results?.[0]?.name || groupId
);

return (
<PageHeader>
<Breadcrumb>
<BreadcrumbItem>
<Link to={routes.groups}>Groups</Link>
</BreadcrumbItem>
<BreadcrumbItem isActive>{nameOrId}</BreadcrumbItem>
</Breadcrumb>
<PageHeaderTitle title={nameOrId} />
</PageHeader>
);
};

GroupDetailHeader.propTypes = {
groupId: PropTypes.string.isRequired
};

export default GroupDetailHeader;
20 changes: 20 additions & 0 deletions src/components/InventoryGroupDetail/GroupDetailInfo.js
Original file line number Diff line number Diff line change
@@ -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 (
<EmptyState>
<EmptyStateBody>
{uninitialized || loading ? <Spinner /> : <InvalidObject />}
</EmptyStateBody>
</EmptyState>
);
};

export default GroupDetailInfo;
22 changes: 22 additions & 0 deletions src/components/InventoryGroupDetail/GroupDetailSystems.js
Original file line number Diff line number Diff line change
@@ -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 ?
<EmptyState>
<EmptyStateBody>
<Spinner />
</EmptyStateBody>
</EmptyState>
: <NoSystemsEmptyState />

);
};

export default GroupDetailSystems;
62 changes: 62 additions & 0 deletions src/components/InventoryGroupDetail/InventoryGroupDetail.cy.js
Original file line number Diff line number Diff line change
@@ -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(
<Provider store={getStore()}>
<MemoryRouter>
<InventoryGroupDetail groupId={TEST_GROUP_ID} />
</MemoryRouter>
</Provider>
);

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');
});
});
89 changes: 89 additions & 0 deletions src/components/InventoryGroupDetail/InventoryGroupDetail.js
Original file line number Diff line number Diff line change
@@ -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 (
<React.Fragment>
<GroupDetailHeader groupId={groupId} />
<PageSection variant="light" type="tabs">
<Tabs
activeKey={activeTabKey}
onSelect={(event, value) => setActiveTabKey(value)}
aria-label="Group tabs"
role="region"
inset={{ default: 'insetMd' }} // add extra space before the first tab (according to mocks)
>
<Tab
eventKey={0}
title={<TabTitleText>Systems</TabTitleText>}
aria-label="Group systems tab"
>
<PageSection>
<GroupDetailSystems />
</PageSection>
</Tab>
<Tab
eventKey={1}
title={<TabTitleText>Group info</TabTitleText>}
aria-label="Group info tab"
>
{activeTabKey === 1 && ( // helps to lazy load the component
<PageSection>
<Suspense
fallback={
<Bullseye>
<Spinner />
</Bullseye>
}
>
<GroupDetailInfo />
</Suspense>
</PageSection>
)}
</Tab>
</Tabs>
</PageSection>
</React.Fragment>
);
};

InventoryGroupDetail.propTypes = {
groupId: PropTypes.string.isRequired
};

export default InventoryGroupDetail;
Loading

0 comments on commit ca24e55

Please sign in to comment.