Skip to content

Commit

Permalink
[Enterprise Search] Update chrome/breadcrumbs to support dynamic/nest…
Browse files Browse the repository at this point in the history
…ed breadcrumbs (#79231)

* [Setup] Add new stripLeadingSlash util

- will be used by upcoming breadcrumb/path logic
- rename folder + update references
+ clean up tests

* Update breadcrumb helpers with new useGenerateBreadcrumbs
- responsible for generating an array of IBreadcrumb objs with correct react router paths, given an array of breadcrumb text

+ rename previous generic useBreadcrumbs helper to a more specific useEuiBreadcrumbs (indicates the type of transforming happening)

+ misc typing updates/improvements

* Update SetChrome helpers
- to use new useGenerateBreadcrumbs() helper

+ simplify props - remove `isRoot` and `text` (now just accepts a single `trail` array - an empty trail creates the same effect as isRoot
+ simplify/improve typing as a result (yay!)
- improve docs

+ useEffect update - update breadcrumbs/titles if `trail` ever changes. This will primarily be most helpful for pages that fetch dynamic data on page load (e.g. a dynamic engineName, groupName, etc.)
- note that in the above case trail arrays should probably be wrapped in useMemo() to reduce unnecessary rerenders

* Update all instances of SetPageChrome to new props

Co-authored-by: Kibana Machine <[email protected]>
  • Loading branch information
Constance and kibanamachine authored Oct 5, 2020
1 parent ce4641a commit 5812217
Show file tree
Hide file tree
Showing 19 changed files with 204 additions and 99 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,24 @@
* you may not use this file except in compliance with the Elastic License.
*/

import { stripTrailingSlash } from './';
import { stripTrailingSlash, stripLeadingSlash } from './';

describe('Strip Trailing Slash helper', () => {
it('strips trailing slashes', async () => {
it('strips trailing slashes', () => {
expect(stripTrailingSlash('http://trailing.slash/')).toEqual('http://trailing.slash');
});

it('does nothing is there is no trailing slash', async () => {
it('does nothing if there is no trailing slash', () => {
expect(stripTrailingSlash('http://ok.url')).toEqual('http://ok.url');
});
});

describe('Strip Leading Slash helper', () => {
it('strips leading slashes', () => {
expect(stripLeadingSlash('/some/long/path/')).toEqual('some/long/path/');
});

it('does nothing if there is no trailing slash', () => {
expect(stripLeadingSlash('ok')).toEqual('ok');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,14 @@
*/

/**
* Small helper for stripping trailing slashes from URLs or paths
* Helpers for stripping trailing or leading slashes from URLs or paths
* (usually ones that come in from React Router or API endpoints)
*/

export const stripTrailingSlash = (url: string): string => {
return url && url.endsWith('/') ? url.slice(0, -1) : url;
};

export const stripLeadingSlash = (path: string): string => {
return path && path.startsWith('/') ? path.substring(1) : path;
};
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export const EmptyState: React.FC = () => {

return (
<>
<SetPageChrome isRoot />
<SetPageChrome />
<EngineOverviewHeader />
<EuiPageContent className="emptyState">
<EuiEmptyPrompt
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { EngineOverviewHeader } from './header';
export const LoadingState: React.FC = () => {
return (
<>
<SetPageChrome isRoot />
<SetPageChrome />
<EngineOverviewHeader />
<EuiPageContent paddingSize="l">
<EuiLoadingContent lines={5} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export const EngineOverview: React.FC = () => {

return (
<>
<SetPageChrome isRoot />
<SetPageChrome />
<SendTelemetry action="viewed" metric="engines_overview" />

<EngineOverviewHeader />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { SendAppSearchTelemetry as SendTelemetry } from '../../../shared/telemet
export const ErrorConnecting: React.FC = () => {
return (
<>
<SetPageChrome isRoot />
<SetPageChrome />
<SendTelemetry action="error" metric="cannot_connect" />

<EuiPageContent>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,11 @@ export const SetupGuide: React.FC = () => (
elasticsearchNativeAuthLink="https://www.elastic.co/guide/en/app-search/current/security-and-users.html#app-search-self-managed-security-and-user-management-elasticsearch-native-realm"
>
<SetPageChrome
text={i18n.translate('xpack.enterpriseSearch.setupGuide.title', {
defaultMessage: 'Setup Guide',
})}
trail={[
i18n.translate('xpack.enterpriseSearch.setupGuide.title', {
defaultMessage: 'Setup Guide',
}),
]}
/>
<SendTelemetry action="viewed" metric="setup_guide" />

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export const ProductSelector: React.FC<IProductSelectorProps> = ({ access }) =>

return (
<EuiPage restrictWidth className="enterpriseSearchOverview">
<SetPageChrome isRoot />
<SetPageChrome />
<SendTelemetry action="viewed" metric="overview" />

<EuiPageBody>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,11 @@ export const SetupGuide: React.FC = () => (
elasticsearchNativeAuthLink="https://www.elastic.co/guide/en/app-search/current/security-and-users.html#app-search-self-managed-security-and-user-management-elasticsearch-native-realm"
>
<SetPageChrome
text={i18n.translate('xpack.enterpriseSearch.setupGuide.title', {
defaultMessage: 'Setup Guide',
})}
trail={[
i18n.translate('xpack.enterpriseSearch.setupGuide.title', {
defaultMessage: 'Setup Guide',
}),
]}
/>
<SendTelemetry action="viewed" metric="setup_guide" />

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/

import '../../__mocks__/kea.mock';
import { setMockValues } from '../../__mocks__/kea.mock';
import { mockKibanaValues, mockHistory } from '../../__mocks__';

jest.mock('../react_router_helpers', () => ({
Expand All @@ -14,19 +14,64 @@ jest.mock('../react_router_helpers', () => ({
import { letBrowserHandleEvent } from '../react_router_helpers';

import {
useBreadcrumbs,
useGenerateBreadcrumbs,
useEuiBreadcrumbs,
useEnterpriseSearchBreadcrumbs,
useAppSearchBreadcrumbs,
useWorkplaceSearchBreadcrumbs,
} from './generate_breadcrumbs';

describe('useBreadcrumbs', () => {
describe('useGenerateBreadcrumbs', () => {
const mockCurrentPath = (pathname: string) =>
setMockValues({ history: { location: { pathname } } });

afterAll(() => {
setMockValues({ history: mockHistory });
});

it('accepts a trail of breadcrumb text and generates IBreadcrumb objs based on the current routing path', () => {
const trail = ['Groups', 'Example Group Name', 'Source Prioritization'];
const path = '/groups/{id}/source_prioritization';

mockCurrentPath(path);
const breadcrumbs = useGenerateBreadcrumbs(trail);

expect(breadcrumbs).toEqual([
{ text: 'Groups', path: '/groups' },
{ text: 'Example Group Name', path: '/groups/{id}' },
{ text: 'Source Prioritization', path: '/groups/{id}/source_prioritization' },
]);
});

it('handles empty arrays gracefully', () => {
mockCurrentPath('');
expect(useGenerateBreadcrumbs([])).toEqual([]);
});

it('attempts to handle mismatched trail/path lengths gracefully', () => {
mockCurrentPath('/page1/page2');
expect(useGenerateBreadcrumbs(['Page 1', 'Page 2', 'Page 3'])).toEqual([
{ text: 'Page 1', path: '/page1' },
{ text: 'Page 2', path: '/page1/page2' },
{ text: 'Page 3' }, // The missing path falls back to breadcrumb text w/ no link
]);

mockCurrentPath('/page1/page2/page3');
expect(useGenerateBreadcrumbs(['Page 1', 'Page 2'])).toEqual([
{ text: 'Page 1', path: '/page1' },
{ text: 'Page 2', path: '/page1/page2' },
// the /page3 path is ignored/not used
]);
});
});

describe('useEuiBreadcrumbs', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('accepts an array of breadcrumbs and to the array correctly injects SPA link navigation props', () => {
const breadcrumb = useBreadcrumbs([
const breadcrumb = useEuiBreadcrumbs([
{
text: 'Hello',
path: '/hello',
Expand All @@ -51,7 +96,7 @@ describe('useBreadcrumbs', () => {
});

it('prevents default navigation and uses React Router history on click', () => {
const breadcrumb = useBreadcrumbs([{ text: '', path: '/test' }])[0] as any;
const breadcrumb = useEuiBreadcrumbs([{ text: '', path: '/test' }])[0] as any;

expect(breadcrumb.href).toEqual('/app/enterprise_search/test');
expect(mockHistory.createHref).toHaveBeenCalled();
Expand All @@ -64,7 +109,7 @@ describe('useBreadcrumbs', () => {
});

it('does not call createHref if shouldNotCreateHref is passed', () => {
const breadcrumb = useBreadcrumbs([
const breadcrumb = useEuiBreadcrumbs([
{ text: '', path: '/test', shouldNotCreateHref: true },
])[0] as any;

Expand All @@ -73,7 +118,7 @@ describe('useBreadcrumbs', () => {
});

it('does not prevent default browser behavior on new tab/window clicks', () => {
const breadcrumb = useBreadcrumbs([{ text: '', path: '/' }])[0] as any;
const breadcrumb = useEuiBreadcrumbs([{ text: '', path: '/' }])[0] as any;

(letBrowserHandleEvent as jest.Mock).mockImplementationOnce(() => true);
breadcrumb.onClick();
Expand All @@ -82,7 +127,7 @@ describe('useBreadcrumbs', () => {
});

it('does not generate link behavior if path is excluded', () => {
const breadcrumb = useBreadcrumbs([{ text: 'Unclickable breadcrumb' }])[0];
const breadcrumb = useEuiBreadcrumbs([{ text: 'Unclickable breadcrumb' }])[0];

expect(breadcrumb.href).toBeUndefined();
expect(breadcrumb.onClick).toBeUndefined();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,11 @@ import {
WORKPLACE_SEARCH_PLUGIN,
} from '../../../../common/constants';

import { stripLeadingSlash } from '../../../../common/strip_slashes';
import { letBrowserHandleEvent, createHref } from '../react_router_helpers';

/**
* Generate React-Router-friendly EUI breadcrumb objects
* https://elastic.github.io/eui/#/navigation/breadcrumbs
* Types
*/

interface IBreadcrumb {
Expand All @@ -30,12 +30,45 @@ interface IBreadcrumb {
shouldNotCreateHref?: boolean;
}
export type TBreadcrumbs = IBreadcrumb[];
export type TBreadcrumbTrail = string[]; // A trail of breadcrumb text

/**
* Generate an array of breadcrumbs based on:
* 1. A passed array of breadcrumb text (the trail prop)
* 2. The current React Router path
*
* To correctly generate working breadcrumbs, ensure the trail array passed to
* SetPageChrome matches up with the routed path. For example, a page with a trail of:
* `['Groups', 'Example Group Name', 'Source Prioritization']`
* should have a router pathname of:
* `'/groups/{example-group-id}/source_prioritization'`
*
* Which should then generate the following breadcrumb output:
* Groups (linked to `/groups`)
* > Example Group Name (linked to `/groups/{example-group-id}`)
* > Source Prioritization (linked to `/groups/{example-group-id}/source_prioritization`)
*/

export const useGenerateBreadcrumbs = (trail: TBreadcrumbTrail): TBreadcrumbs => {
const { history } = useValues(KibanaLogic);
const pathArray = stripLeadingSlash(history.location.pathname).split('/');

return trail.map((text, i) => {
const path = pathArray[i] ? '/' + pathArray.slice(0, i + 1).join('/') : undefined;
return { text, path };
});
};

/**
* Convert IBreadcrumb objects to React-Router-friendly EUI breadcrumb objects
* https://elastic.github.io/eui/#/navigation/breadcrumbs
*/

export const useBreadcrumbs = (breadcrumbs: TBreadcrumbs) => {
export const useEuiBreadcrumbs = (breadcrumbs: TBreadcrumbs): EuiBreadcrumb[] => {
const { navigateToUrl, history } = useValues(KibanaLogic);

return breadcrumbs.map(({ text, path, shouldNotCreateHref }) => {
const breadcrumb = { text } as EuiBreadcrumb;
const breadcrumb: EuiBreadcrumb = { text };

if (path) {
breadcrumb.href = createHref(path, history, { shouldNotCreateHref });
Expand All @@ -55,7 +88,7 @@ export const useBreadcrumbs = (breadcrumbs: TBreadcrumbs) => {
*/

export const useEnterpriseSearchBreadcrumbs = (breadcrumbs: TBreadcrumbs = []) =>
useBreadcrumbs([
useEuiBreadcrumbs([
{
text: ENTERPRISE_SEARCH_PLUGIN.NAME,
path: ENTERPRISE_SEARCH_PLUGIN.URL,
Expand Down
Loading

0 comments on commit 5812217

Please sign in to comment.