Refactor breadcrumbs params detection (#1394)

Co-authored-by: Oleg Shur <workshur@gmail.com>
This commit is contained in:
Azat Belgibayev 2022-01-25 15:01:37 +06:00 committed by GitHub
parent 38c4cf7dd9
commit e569e46a8a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 264 additions and 154 deletions

View file

@ -3,7 +3,6 @@ import { Switch, Route, useLocation } from 'react-router-dom';
import { GIT_TAG, GIT_COMMIT } from 'lib/constants'; import { GIT_TAG, GIT_COMMIT } from 'lib/constants';
import Nav from 'components/Nav/Nav'; import Nav from 'components/Nav/Nav';
import PageLoader from 'components/common/PageLoader/PageLoader'; import PageLoader from 'components/common/PageLoader/PageLoader';
import Breadcrumb from 'components/common/Breadcrumb/Breadcrumb';
import Dashboard from 'components/Dashboard/Dashboard'; import Dashboard from 'components/Dashboard/Dashboard';
import ClusterPage from 'components/Cluster/Cluster'; import ClusterPage from 'components/Cluster/Cluster';
import Version from 'components/Version/Version'; import Version from 'components/Version/Version';
@ -82,20 +81,14 @@ const App: React.FC = () => {
aria-label="Overlay" aria-label="Overlay"
/> />
{areClustersFulfilled ? ( {areClustersFulfilled ? (
<> <Switch>
<Breadcrumb /> <Route
<Switch> exact
<Route path={['/', '/ui', '/ui/clusters']}
exact component={Dashboard}
path={['/', '/ui', '/ui/clusters']} />
component={Dashboard} <Route path="/ui/clusters/:clusterName" component={ClusterPage} />
/> </Switch>
<Route
path="/ui/clusters/:clusterName"
component={ClusterPage}
/>
</Switch>
</>
) : ( ) : (
<PageLoader /> <PageLoader />
)} )}

View file

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { Switch, Route, Redirect, useParams } from 'react-router-dom'; import { Switch, Redirect, useParams } from 'react-router-dom';
import { ClusterFeaturesEnum } from 'generated-sources'; import { ClusterFeaturesEnum } from 'generated-sources';
import { import {
getClustersFeatures, getClustersFeatures,
@ -22,6 +22,9 @@ import ClusterContext from 'components/contexts/ClusterContext';
import Brokers from 'components/Brokers/Brokers'; import Brokers from 'components/Brokers/Brokers';
import ConsumersGroups from 'components/ConsumerGroups/ConsumerGroups'; import ConsumersGroups from 'components/ConsumerGroups/ConsumerGroups';
import KsqlDb from 'components/KsqlDb/KsqlDb'; import KsqlDb from 'components/KsqlDb/KsqlDb';
import Breadcrumb from 'components/common/Breadcrumb/Breadcrumb';
import { BreadcrumbRoute } from 'components/common/Breadcrumb/Breadcrumb.route';
import { BreadcrumbProvider } from 'components/common/Breadcrumb/Breadcrumb.provider';
const Cluster: React.FC = () => { const Cluster: React.FC = () => {
const { clusterName } = useParams<{ clusterName: string }>(); const { clusterName } = useParams<{ clusterName: string }>();
@ -50,41 +53,53 @@ const Cluster: React.FC = () => {
); );
return ( return (
<ClusterContext.Provider value={contextValue}> <BreadcrumbProvider>
<Switch> <Breadcrumb />
<Route path={clusterBrokersPath(':clusterName')} component={Brokers} /> <ClusterContext.Provider value={contextValue}>
<Route path={clusterTopicsPath(':clusterName')} component={Topics} /> <Switch>
<Route <BreadcrumbRoute
path={clusterConsumerGroupsPath(':clusterName')} path={clusterBrokersPath(':clusterName')}
component={ConsumersGroups} component={Brokers}
/>
{hasSchemaRegistryConfigured && (
<Route
path={clusterSchemasPath(':clusterName')}
component={Schemas}
/> />
)} <BreadcrumbRoute
{hasKafkaConnectConfigured && ( path={clusterTopicsPath(':clusterName')}
<Route component={Topics}
path={clusterConnectsPath(':clusterName')}
component={Connect}
/> />
)} <BreadcrumbRoute
{hasKafkaConnectConfigured && ( path={clusterConsumerGroupsPath(':clusterName')}
<Route component={ConsumersGroups}
path={clusterConnectorsPath(':clusterName')}
component={Connect}
/> />
)} {hasSchemaRegistryConfigured && (
{hasKsqlDbConfigured && ( <BreadcrumbRoute
<Route path={clusterKsqlDbPath(':clusterName')} component={KsqlDb} /> path={clusterSchemasPath(':clusterName')}
)} component={Schemas}
<Redirect />
from="/ui/clusters/:clusterName" )}
to="/ui/clusters/:clusterName/brokers" {hasKafkaConnectConfigured && (
/> <BreadcrumbRoute
</Switch> path={clusterConnectsPath(':clusterName')}
</ClusterContext.Provider> component={Connect}
/>
)}
{hasKafkaConnectConfigured && (
<BreadcrumbRoute
path={clusterConnectorsPath(':clusterName')}
component={Connect}
/>
)}
{hasKsqlDbConfigured && (
<BreadcrumbRoute
path={clusterKsqlDbPath(':clusterName')}
component={KsqlDb}
/>
)}
<Redirect
from="/ui/clusters/:clusterName"
to="/ui/clusters/:clusterName/brokers"
/>
</Switch>
</ClusterContext.Provider>
</BreadcrumbProvider>
); );
}; };

View file

@ -1,11 +1,12 @@
import React from 'react'; import React from 'react';
import { Switch, Route } from 'react-router-dom'; import { Switch } from 'react-router-dom';
import { import {
clusterConnectorsPath, clusterConnectorsPath,
clusterConnectorNewPath, clusterConnectorNewPath,
clusterConnectConnectorPath, clusterConnectConnectorPath,
clusterConnectConnectorEditPath, clusterConnectConnectorEditPath,
} from 'lib/paths'; } from 'lib/paths';
import { BreadcrumbRoute } from 'components/common/Breadcrumb/Breadcrumb.route';
import ListContainer from './List/ListContainer'; import ListContainer from './List/ListContainer';
import NewContainer from './New/NewContainer'; import NewContainer from './New/NewContainer';
@ -15,17 +16,17 @@ import EditContainer from './Edit/EditContainer';
const Connect: React.FC = () => ( const Connect: React.FC = () => (
<div> <div>
<Switch> <Switch>
<Route <BreadcrumbRoute
exact exact
path={clusterConnectorsPath(':clusterName')} path={clusterConnectorsPath(':clusterName')}
component={ListContainer} component={ListContainer}
/> />
<Route <BreadcrumbRoute
exact exact
path={clusterConnectorNewPath(':clusterName')} path={clusterConnectorNewPath(':clusterName')}
component={NewContainer} component={NewContainer}
/> />
<Route <BreadcrumbRoute
exact exact
path={clusterConnectConnectorEditPath( path={clusterConnectConnectorEditPath(
':clusterName', ':clusterName',
@ -34,7 +35,7 @@ const Connect: React.FC = () => (
)} )}
component={EditContainer} component={EditContainer}
/> />
<Route <BreadcrumbRoute
path={clusterConnectConnectorPath( path={clusterConnectConnectorPath(
':clusterName', ':clusterName',
':connectName', ':connectName',

View file

@ -3,7 +3,7 @@
exports[`Connect matches snapshot 1`] = ` exports[`Connect matches snapshot 1`] = `
<div> <div>
<Switch> <Switch>
<Route <BreadcrumbRoute
component={ component={
Object { Object {
"$$typeof": Symbol(react.memo), "$$typeof": Symbol(react.memo),
@ -15,17 +15,17 @@ exports[`Connect matches snapshot 1`] = `
exact={true} exact={true}
path="/ui/clusters/:clusterName/connectors" path="/ui/clusters/:clusterName/connectors"
/> />
<Route <BreadcrumbRoute
component={[Function]} component={[Function]}
exact={true} exact={true}
path="/ui/clusters/:clusterName/connectors/create-new" path="/ui/clusters/:clusterName/connectors/create-new"
/> />
<Route <BreadcrumbRoute
component={[Function]} component={[Function]}
exact={true} exact={true}
path="/ui/clusters/:clusterName/connects/:connectName/connectors/:connectorName/edit" path="/ui/clusters/:clusterName/connects/:connectName/connectors/:connectorName/edit"
/> />
<Route <BreadcrumbRoute
component={[Function]} component={[Function]}
path="/ui/clusters/:clusterName/connects/:connectName/connectors/:connectorName" path="/ui/clusters/:clusterName/connects/:connectName/connectors/:connectorName"
/> />

View file

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { ClusterName } from 'redux/interfaces'; import { ClusterName } from 'redux/interfaces';
import { Switch, Route, useParams } from 'react-router-dom'; import { Switch, useParams } from 'react-router-dom';
import PageLoader from 'components/common/PageLoader/PageLoader'; import PageLoader from 'components/common/PageLoader/PageLoader';
import Details from 'components/ConsumerGroups/Details/Details'; import Details from 'components/ConsumerGroups/Details/Details';
import List from 'components/ConsumerGroups/List/List'; import List from 'components/ConsumerGroups/List/List';
@ -10,6 +10,7 @@ import {
fetchConsumerGroups, fetchConsumerGroups,
getAreConsumerGroupsFulfilled, getAreConsumerGroupsFulfilled,
} from 'redux/reducers/consumerGroups/consumerGroupsSlice'; } from 'redux/reducers/consumerGroups/consumerGroupsSlice';
import { BreadcrumbRoute } from 'components/common/Breadcrumb/Breadcrumb.route';
const ConsumerGroups: React.FC = () => { const ConsumerGroups: React.FC = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@ -22,17 +23,17 @@ const ConsumerGroups: React.FC = () => {
if (isFetched) { if (isFetched) {
return ( return (
<Switch> <Switch>
<Route <BreadcrumbRoute
exact exact
path="/ui/clusters/:clusterName/consumer-groups" path="/ui/clusters/:clusterName/consumer-groups"
component={List} component={List}
/> />
<Route <BreadcrumbRoute
exact exact
path="/ui/clusters/:clusterName/consumer-groups/:consumerGroupID" path="/ui/clusters/:clusterName/consumer-groups/:consumerGroupID"
component={Details} component={Details}
/> />
<Route <BreadcrumbRoute
path="/ui/clusters/:clusterName/consumer-groups/:consumerGroupID/reset-offsets" path="/ui/clusters/:clusterName/consumer-groups/:consumerGroupID/reset-offsets"
component={ResetOffsets} component={ResetOffsets}
/> />

View file

@ -1,11 +1,13 @@
import React from 'react'; import React from 'react';
import PageHeading from 'components/common/PageHeading/PageHeading';
import ClustersWidgetContainer from './ClustersWidget/ClustersWidgetContainer'; import ClustersWidgetContainer from './ClustersWidget/ClustersWidgetContainer';
const Dashboard: React.FC = () => ( const Dashboard: React.FC = () => (
<div> <>
<PageHeading text="Dashboard" />
<ClustersWidgetContainer /> <ClustersWidgetContainer />
</div> </>
); );
export default Dashboard; export default Dashboard;

View file

@ -1,14 +1,19 @@
import React from 'react'; import React from 'react';
import { Switch, Route } from 'react-router-dom'; import { Switch } from 'react-router-dom';
import { clusterKsqlDbPath, clusterKsqlDbQueryPath } from 'lib/paths'; import { clusterKsqlDbPath, clusterKsqlDbQueryPath } from 'lib/paths';
import List from 'components/KsqlDb/List/List'; import List from 'components/KsqlDb/List/List';
import Query from 'components/KsqlDb/Query/Query'; import Query from 'components/KsqlDb/Query/Query';
import { BreadcrumbRoute } from 'components/common/Breadcrumb/Breadcrumb.route';
const KsqlDb: React.FC = () => { const KsqlDb: React.FC = () => {
return ( return (
<Switch> <Switch>
<Route exact path={clusterKsqlDbPath()} component={List} /> <BreadcrumbRoute exact path={clusterKsqlDbPath()} component={List} />
<Route exact path={clusterKsqlDbQueryPath()} component={Query} /> <BreadcrumbRoute
exact
path={clusterKsqlDbQueryPath()}
component={Query}
/>
</Switch> </Switch>
); );
}; };

View file

@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { Switch, Route, useParams } from 'react-router-dom'; import { Switch, useParams } from 'react-router-dom';
import { import {
clusterSchemaNewPath, clusterSchemaNewPath,
clusterSchemaPath, clusterSchemaPath,
@ -16,6 +16,7 @@ import List from 'components/Schemas/List/List';
import Details from 'components/Schemas/Details/Details'; import Details from 'components/Schemas/Details/Details';
import New from 'components/Schemas/New/New'; import New from 'components/Schemas/New/New';
import Edit from 'components/Schemas/Edit/Edit'; import Edit from 'components/Schemas/Edit/Edit';
import { BreadcrumbRoute } from 'components/common/Breadcrumb/Breadcrumb.route';
const Schemas: React.FC = () => { const Schemas: React.FC = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@ -32,18 +33,22 @@ const Schemas: React.FC = () => {
return ( return (
<Switch> <Switch>
<Route exact path={clusterSchemasPath(':clusterName')} component={List} /> <BreadcrumbRoute
<Route exact
path={clusterSchemasPath(':clusterName')}
component={List}
/>
<BreadcrumbRoute
exact exact
path={clusterSchemaNewPath(':clusterName')} path={clusterSchemaNewPath(':clusterName')}
component={New} component={New}
/> />
<Route <BreadcrumbRoute
exact exact
path={clusterSchemaPath(':clusterName', ':subject')} path={clusterSchemaPath(':clusterName', ':subject')}
component={Details} component={Details}
/> />
<Route <BreadcrumbRoute
exact exact
path={clusterSchemaEditPath(':clusterName', ':subject')} path={clusterSchemaEditPath(':clusterName', ':subject')}
component={Edit} component={Edit}

View file

@ -1,10 +1,11 @@
import React from 'react'; import React from 'react';
import { Switch, Route } from 'react-router-dom'; import { Switch } from 'react-router-dom';
import { import {
clusterTopicNewPath, clusterTopicNewPath,
clusterTopicPath, clusterTopicPath,
clusterTopicsPath, clusterTopicsPath,
} from 'lib/paths'; } from 'lib/paths';
import { BreadcrumbRoute } from 'components/common/Breadcrumb/Breadcrumb.route';
import ListContainer from './List/ListContainer'; import ListContainer from './List/ListContainer';
import TopicContainer from './Topic/TopicContainer'; import TopicContainer from './Topic/TopicContainer';
@ -12,13 +13,17 @@ import New from './New/New';
const Topics: React.FC = () => ( const Topics: React.FC = () => (
<Switch> <Switch>
<Route <BreadcrumbRoute
exact exact
path={clusterTopicsPath(':clusterName')} path={clusterTopicsPath(':clusterName')}
component={ListContainer} component={ListContainer}
/> />
<Route exact path={clusterTopicNewPath(':clusterName')} component={New} /> <BreadcrumbRoute
<Route exact
path={clusterTopicNewPath(':clusterName')}
component={New}
/>
<BreadcrumbRoute
path={clusterTopicPath(':clusterName', ':topicName')} path={clusterTopicPath(':clusterName', ':topicName')}
component={TopicContainer} component={TopicContainer}
/> />

View file

@ -1,35 +0,0 @@
import React from 'react';
import Breadcrumb from 'components/common/Breadcrumb/Breadcrumb';
import { clusterTopicsPath, clusterTopicPath } from 'lib/paths';
import { ClusterName, TopicName } from 'redux/interfaces';
interface Props {
clusterName: ClusterName;
topicName?: TopicName;
current: string;
}
const FormBreadcrumbs: React.FC<Props> = ({
clusterName,
topicName,
current,
}) => {
const allTopicsLink = {
href: clusterTopicsPath(clusterName),
label: 'All Topics',
};
const links = topicName
? [
allTopicsLink,
{ href: clusterTopicPath(clusterName, topicName), label: topicName },
]
: [allTopicsLink];
return (
<div className="level-item level-left">
<Breadcrumb links={links}>{current}</Breadcrumb>
</div>
);
};
export default FormBreadcrumbs;

View file

@ -0,0 +1,16 @@
import { createContext } from 'react';
export interface BreadcrumbEntry {
link: string;
path: string[];
}
interface BreadcrumbContextInterface extends BreadcrumbEntry {
handleRouteChange: (match: { url: string; path: string }) => void;
}
export const BreadcrumbContext = createContext<BreadcrumbContextInterface>({
link: '',
path: [],
handleRouteChange: () => {},
});

View file

@ -0,0 +1,51 @@
import React, { useState } from 'react';
import capitalize from 'lodash/capitalize';
import { BreadcrumbContext, BreadcrumbEntry } from './Breadcrumb.context';
const mapLocationToPath = (
splittedLocation: string[],
splittedRoutePath: string[]
) =>
splittedLocation.map((item, index) =>
splittedRoutePath[index]?.charAt(0) !== ':'
? item.split('-').map(capitalize).join(' ')
: item
);
export const BreadcrumbProvider: React.FC = ({ children }) => {
const [state, setState] = useState<BreadcrumbEntry>({
link: '',
path: [],
});
const handleRouteChange = (params: { url: string; path: string }) => {
setState((prevState) => {
const newState = { ...prevState };
const splittedRoutePath = params.path.split('/');
const splittedLocation = params.url.split('/');
if (prevState.link !== params.url) {
newState.link = params.url;
newState.path = mapLocationToPath(splittedLocation, splittedRoutePath);
}
if (prevState.path.length < params.path.split('/').length) {
newState.path = mapLocationToPath(splittedLocation, splittedRoutePath);
}
return newState;
});
};
return (
<BreadcrumbContext.Provider
value={{
...state,
handleRouteChange,
}}
>
{children}
</BreadcrumbContext.Provider>
);
};

View file

@ -0,0 +1,59 @@
import React, { useContext, useEffect } from 'react';
import {
Route,
RouteProps,
useLocation,
useRouteMatch,
} from 'react-router-dom';
import { BreadcrumbContext } from './Breadcrumb.context';
const BreadcrumbRouteInternal: React.FC = () => {
const match = useRouteMatch();
const location = useLocation();
const context = useContext(BreadcrumbContext);
useEffect(() => {
context.handleRouteChange({ ...match, url: location.pathname });
}, [location.pathname]);
return null;
};
export const BreadcrumbRoute: React.FC<RouteProps> = ({
children,
render,
component,
...props
}) => {
return (
<Route
{...props}
render={(routeParams) => {
if (component) {
return (
<>
{React.createElement(component)}
<BreadcrumbRouteInternal />
</>
);
}
if (render) {
return (
<>
{render(routeParams)}
<BreadcrumbRouteInternal />
</>
);
}
return (
<>
{children}
<BreadcrumbRouteInternal />
</>
);
}}
/>
);
};

View file

@ -1,53 +1,34 @@
import React from 'react'; import React, { useContext } from 'react';
import { Link, useLocation, useParams } from 'react-router-dom'; import { Link } from 'react-router-dom';
import cn from 'classnames'; import cn from 'classnames';
import { clusterPath } from 'lib/paths'; import { clusterPath } from 'lib/paths';
import { capitalize } from 'lodash';
import { BreadcrumbWrapper } from './Breadcrumb.styled'; import { BreadcrumbWrapper } from './Breadcrumb.styled';
import { BreadcrumbContext } from './Breadcrumb.context';
export interface BreadcrumbItem {
label: string;
href: string;
}
interface Props {
links?: BreadcrumbItem[];
}
const basePathEntriesLength = clusterPath(':clusterName').split('/').length; const basePathEntriesLength = clusterPath(':clusterName').split('/').length;
const Breadcrumb: React.FC<Props> = () => { const Breadcrumb: React.FC = () => {
const location = useLocation(); const breadcrumbContext = useContext(BreadcrumbContext);
const params = useParams();
const pathParams = React.useMemo(() => Object.values(params), [params]);
const paths = location.pathname.split('/');
const links = React.useMemo( const links = React.useMemo(
() => () => breadcrumbContext.path.slice(basePathEntriesLength),
paths.slice(basePathEntriesLength).map((path, index) => { [breadcrumbContext.link]
return !pathParams.includes(paths[basePathEntriesLength + index])
? path.split('-').map(capitalize).join(' ')
: path;
}),
[paths]
); );
const currentLink = React.useMemo(() => {
if (paths.length < basePathEntriesLength) {
return 'Dashboard';
}
return links[links.length - 1];
}, [links]);
const getPathPredicate = React.useCallback( const getPathPredicate = React.useCallback(
(index: number) => (index: number) =>
`${paths.slice(0, basePathEntriesLength + index + 1).join('/')}`, `${breadcrumbContext.link
[paths] .split('/')
.slice(0, basePathEntriesLength + index + 1)
.join('/')}`,
[breadcrumbContext.link]
); );
if (links.length < 2) { if (links.length < 2) {
return null; return null;
} }
return ( return (
<BreadcrumbWrapper role="list"> <BreadcrumbWrapper role="list">
{links.slice(0, links.length - 1).map((link, index) => ( {links.slice(0, links.length - 1).map((link, index) => (
@ -60,7 +41,7 @@ const Breadcrumb: React.FC<Props> = () => {
'is-size-4 has-text-weight-medium is-capitalized': links.length < 2, 'is-size-4 has-text-weight-medium is-capitalized': links.length < 2,
})} })}
> >
<span>{currentLink}</span> <span>{links[links.length - 1]}</span>
</li> </li>
</BreadcrumbWrapper> </BreadcrumbWrapper>
); );

View file

@ -1,22 +1,33 @@
import React from 'react'; import React from 'react';
import Breadcrumb from 'components/common/Breadcrumb/Breadcrumb'; import Breadcrumb from 'components/common/Breadcrumb/Breadcrumb';
import { screen } from '@testing-library/react'; import { BreadcrumbProvider } from 'components/common/Breadcrumb/Breadcrumb.provider';
import { BreadcrumbRoute } from 'components/common/Breadcrumb/Breadcrumb.route';
import { render } from 'lib/testHelpers'; import { render } from 'lib/testHelpers';
const brokersPath = '/ui/clusters/local/brokers';
const createTopicPath = '/ui/clusters/local/topics/create-new'; const createTopicPath = '/ui/clusters/local/topics/create-new';
const createTopicRoutePath = '/ui/clusters/:clusterName/topics/create-new';
const topicPath = '/ui/clusters/secondLocal/topics/topic-name';
const topicRoutePath = '/ui/clusters/:clusterName/topics/:topicName';
describe('Breadcrumb component', () => { describe('Breadcrumb component', () => {
const setupComponent = (pathname: string) => const setupComponent = (pathname: string, routePath: string) =>
render(<Breadcrumb />, { pathname }); render(
<BreadcrumbProvider>
<Breadcrumb />
<BreadcrumbRoute path={routePath} />
</BreadcrumbProvider>,
{ pathname }
);
it('renders the name of brokers path', () => { it('renders the list of links', async () => {
setupComponent(brokersPath); const { getByText } = setupComponent(createTopicPath, createTopicRoutePath);
expect(screen.queryByText('Brokers')).not.toBeInTheDocument(); expect(getByText('Topics')).toBeInTheDocument();
expect(getByText('Create New')).toBeInTheDocument();
}); });
it('renders the list of links', () => { it('renders the topic overview', async () => {
setupComponent(createTopicPath); const { getByText } = setupComponent(topicPath, topicRoutePath);
expect(screen.getByText('Topics')).toBeInTheDocument(); expect(getByText('Topics')).toBeInTheDocument();
expect(screen.getByText('Create New')).toBeInTheDocument(); expect(getByText('topic-name')).toBeInTheDocument();
}); });
}); });