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 Nav from 'components/Nav/Nav';
import PageLoader from 'components/common/PageLoader/PageLoader';
import Breadcrumb from 'components/common/Breadcrumb/Breadcrumb';
import Dashboard from 'components/Dashboard/Dashboard';
import ClusterPage from 'components/Cluster/Cluster';
import Version from 'components/Version/Version';
@ -82,20 +81,14 @@ const App: React.FC = () => {
aria-label="Overlay"
/>
{areClustersFulfilled ? (
<>
<Breadcrumb />
<Switch>
<Route
exact
path={['/', '/ui', '/ui/clusters']}
component={Dashboard}
/>
<Route
path="/ui/clusters/:clusterName"
component={ClusterPage}
/>
</Switch>
</>
<Switch>
<Route
exact
path={['/', '/ui', '/ui/clusters']}
component={Dashboard}
/>
<Route path="/ui/clusters/:clusterName" component={ClusterPage} />
</Switch>
) : (
<PageLoader />
)}

View file

@ -1,6 +1,6 @@
import React from 'react';
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 {
getClustersFeatures,
@ -22,6 +22,9 @@ import ClusterContext from 'components/contexts/ClusterContext';
import Brokers from 'components/Brokers/Brokers';
import ConsumersGroups from 'components/ConsumerGroups/ConsumerGroups';
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 { clusterName } = useParams<{ clusterName: string }>();
@ -50,41 +53,53 @@ const Cluster: React.FC = () => {
);
return (
<ClusterContext.Provider value={contextValue}>
<Switch>
<Route path={clusterBrokersPath(':clusterName')} component={Brokers} />
<Route path={clusterTopicsPath(':clusterName')} component={Topics} />
<Route
path={clusterConsumerGroupsPath(':clusterName')}
component={ConsumersGroups}
/>
{hasSchemaRegistryConfigured && (
<Route
path={clusterSchemasPath(':clusterName')}
component={Schemas}
<BreadcrumbProvider>
<Breadcrumb />
<ClusterContext.Provider value={contextValue}>
<Switch>
<BreadcrumbRoute
path={clusterBrokersPath(':clusterName')}
component={Brokers}
/>
)}
{hasKafkaConnectConfigured && (
<Route
path={clusterConnectsPath(':clusterName')}
component={Connect}
<BreadcrumbRoute
path={clusterTopicsPath(':clusterName')}
component={Topics}
/>
)}
{hasKafkaConnectConfigured && (
<Route
path={clusterConnectorsPath(':clusterName')}
component={Connect}
<BreadcrumbRoute
path={clusterConsumerGroupsPath(':clusterName')}
component={ConsumersGroups}
/>
)}
{hasKsqlDbConfigured && (
<Route path={clusterKsqlDbPath(':clusterName')} component={KsqlDb} />
)}
<Redirect
from="/ui/clusters/:clusterName"
to="/ui/clusters/:clusterName/brokers"
/>
</Switch>
</ClusterContext.Provider>
{hasSchemaRegistryConfigured && (
<BreadcrumbRoute
path={clusterSchemasPath(':clusterName')}
component={Schemas}
/>
)}
{hasKafkaConnectConfigured && (
<BreadcrumbRoute
path={clusterConnectsPath(':clusterName')}
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 { Switch, Route } from 'react-router-dom';
import { Switch } from 'react-router-dom';
import {
clusterConnectorsPath,
clusterConnectorNewPath,
clusterConnectConnectorPath,
clusterConnectConnectorEditPath,
} from 'lib/paths';
import { BreadcrumbRoute } from 'components/common/Breadcrumb/Breadcrumb.route';
import ListContainer from './List/ListContainer';
import NewContainer from './New/NewContainer';
@ -15,17 +16,17 @@ import EditContainer from './Edit/EditContainer';
const Connect: React.FC = () => (
<div>
<Switch>
<Route
<BreadcrumbRoute
exact
path={clusterConnectorsPath(':clusterName')}
component={ListContainer}
/>
<Route
<BreadcrumbRoute
exact
path={clusterConnectorNewPath(':clusterName')}
component={NewContainer}
/>
<Route
<BreadcrumbRoute
exact
path={clusterConnectConnectorEditPath(
':clusterName',
@ -34,7 +35,7 @@ const Connect: React.FC = () => (
)}
component={EditContainer}
/>
<Route
<BreadcrumbRoute
path={clusterConnectConnectorPath(
':clusterName',
':connectName',

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,10 +1,11 @@
import React from 'react';
import { Switch, Route } from 'react-router-dom';
import { Switch } from 'react-router-dom';
import {
clusterTopicNewPath,
clusterTopicPath,
clusterTopicsPath,
} from 'lib/paths';
import { BreadcrumbRoute } from 'components/common/Breadcrumb/Breadcrumb.route';
import ListContainer from './List/ListContainer';
import TopicContainer from './Topic/TopicContainer';
@ -12,13 +13,17 @@ import New from './New/New';
const Topics: React.FC = () => (
<Switch>
<Route
<BreadcrumbRoute
exact
path={clusterTopicsPath(':clusterName')}
component={ListContainer}
/>
<Route exact path={clusterTopicNewPath(':clusterName')} component={New} />
<Route
<BreadcrumbRoute
exact
path={clusterTopicNewPath(':clusterName')}
component={New}
/>
<BreadcrumbRoute
path={clusterTopicPath(':clusterName', ':topicName')}
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 { Link, useLocation, useParams } from 'react-router-dom';
import React, { useContext } from 'react';
import { Link } from 'react-router-dom';
import cn from 'classnames';
import { clusterPath } from 'lib/paths';
import { capitalize } from 'lodash';
import { BreadcrumbWrapper } from './Breadcrumb.styled';
export interface BreadcrumbItem {
label: string;
href: string;
}
interface Props {
links?: BreadcrumbItem[];
}
import { BreadcrumbContext } from './Breadcrumb.context';
const basePathEntriesLength = clusterPath(':clusterName').split('/').length;
const Breadcrumb: React.FC<Props> = () => {
const location = useLocation();
const params = useParams();
const pathParams = React.useMemo(() => Object.values(params), [params]);
const Breadcrumb: React.FC = () => {
const breadcrumbContext = useContext(BreadcrumbContext);
const paths = location.pathname.split('/');
const links = React.useMemo(
() =>
paths.slice(basePathEntriesLength).map((path, index) => {
return !pathParams.includes(paths[basePathEntriesLength + index])
? path.split('-').map(capitalize).join(' ')
: path;
}),
[paths]
() => breadcrumbContext.path.slice(basePathEntriesLength),
[breadcrumbContext.link]
);
const currentLink = React.useMemo(() => {
if (paths.length < basePathEntriesLength) {
return 'Dashboard';
}
return links[links.length - 1];
}, [links]);
const getPathPredicate = React.useCallback(
(index: number) =>
`${paths.slice(0, basePathEntriesLength + index + 1).join('/')}`,
[paths]
`${breadcrumbContext.link
.split('/')
.slice(0, basePathEntriesLength + index + 1)
.join('/')}`,
[breadcrumbContext.link]
);
if (links.length < 2) {
return null;
}
return (
<BreadcrumbWrapper role="list">
{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,
})}
>
<span>{currentLink}</span>
<span>{links[links.length - 1]}</span>
</li>
</BreadcrumbWrapper>
);

View file

@ -1,22 +1,33 @@
import React from 'react';
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';
const brokersPath = '/ui/clusters/local/brokers';
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', () => {
const setupComponent = (pathname: string) =>
render(<Breadcrumb />, { pathname });
const setupComponent = (pathname: string, routePath: string) =>
render(
<BreadcrumbProvider>
<Breadcrumb />
<BreadcrumbRoute path={routePath} />
</BreadcrumbProvider>,
{ pathname }
);
it('renders the name of brokers path', () => {
setupComponent(brokersPath);
expect(screen.queryByText('Brokers')).not.toBeInTheDocument();
it('renders the list of links', async () => {
const { getByText } = setupComponent(createTopicPath, createTopicRoutePath);
expect(getByText('Topics')).toBeInTheDocument();
expect(getByText('Create New')).toBeInTheDocument();
});
it('renders the list of links', () => {
setupComponent(createTopicPath);
expect(screen.getByText('Topics')).toBeInTheDocument();
expect(screen.getByText('Create New')).toBeInTheDocument();
it('renders the topic overview', async () => {
const { getByText } = setupComponent(topicPath, topicRoutePath);
expect(getByText('Topics')).toBeInTheDocument();
expect(getByText('topic-name')).toBeInTheDocument();
});
});