[Discover] Preserve discover main route state in breadcrumb links (#119838)

* [Discover] preserve discover main state in breadcrumb links

* [Discover] change imports

* [Discover] fix unit tests

* [Discover] fix the case when doc was expanded and then main discover state changed

* [Discover] change naming, add unit tests

* [Discover] fix linting

* [Discover] add functional test

* [Discover] fix tests

* [Discover] try fix tests

* [Discover] use css selector manually

* [Discover] fix functional

* [Discover] apply backticks suggestion

* [Discover] return before statement, try to run separately

* [Discover] unskip test

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Dmitry Tomashevich 2021-12-08 17:04:37 +03:00 committed by GitHub
parent b8189ed107
commit ad6c22dfc3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 303 additions and 135 deletions

View file

@ -97,4 +97,5 @@ export const discoverServiceMock = {
storage: {
get: jest.fn(),
},
addBasePath: jest.fn(),
} as unknown as DiscoverServices;

View file

@ -15,6 +15,7 @@ import { ContextApp } from './context_app';
import { getRootBreadcrumbs } from '../../utils/breadcrumbs';
import { LoadingIndicator } from '../../components/common/loading_indicator';
import { useIndexPattern } from '../../utils/use_index_pattern';
import { useMainRouteBreadcrumb } from '../../utils/use_navigation_props';
export interface ContextAppProps {
/**
@ -33,17 +34,18 @@ export function ContextAppRoute(props: ContextAppProps) {
const { chrome } = services;
const { indexPatternId, id } = useParams<ContextUrlParams>();
const breadcrumb = useMainRouteBreadcrumb();
useEffect(() => {
chrome.setBreadcrumbs([
...getRootBreadcrumbs(),
...getRootBreadcrumbs(breadcrumb),
{
text: i18n.translate('discover.context.breadcrumb', {
defaultMessage: 'Surrounding documents',
}),
},
]);
}, [chrome]);
}, [chrome, breadcrumb]);
const { indexPattern, error } = useIndexPattern(services.indexPatterns, indexPatternId);

View file

@ -14,6 +14,7 @@ import { getRootBreadcrumbs } from '../../utils/breadcrumbs';
import { Doc } from './components/doc';
import { LoadingIndicator } from '../../components/common/loading_indicator';
import { useIndexPattern } from '../../utils/use_index_pattern';
import { useMainRouteBreadcrumb } from '../../utils/use_navigation_props';
export interface SingleDocRouteProps {
/**
@ -36,18 +37,19 @@ export function SingleDocRoute(props: SingleDocRouteProps) {
const { chrome, timefilter } = services;
const { indexPatternId, index } = useParams<DocUrlParams>();
const breadcrumb = useMainRouteBreadcrumb();
const query = useQuery();
const docId = query.get('id') || '';
useEffect(() => {
chrome.setBreadcrumbs([
...getRootBreadcrumbs(),
...getRootBreadcrumbs(breadcrumb),
{
text: `${index}#${docId}`,
},
]);
}, [chrome, index, docId]);
}, [chrome, index, docId, breadcrumb]);
useEffect(() => {
timefilter.disableAutoRefreshSelector();

View file

@ -27,8 +27,7 @@ import {
import { DocViewer } from '../../services/doc_views/components/doc_viewer/doc_viewer';
import { DocViewFilterFn } from '../../services/doc_views/doc_views_types';
import { DiscoverServices } from '../../build_services';
import { getContextUrl } from '../../utils/get_context_url';
import { getSingleDocUrl } from '../../utils/get_single_doc_url';
import { useNavigationProps } from '../../utils/use_navigation_props';
import { ElasticSearchHit } from '../../types';
interface Props {
@ -103,6 +102,15 @@ export function DiscoverGridFlyout({
[activePage, setPage]
);
const { singleDocProps, surrDocsProps } = useNavigationProps({
indexPatternId: indexPattern.id!,
rowIndex: hit._index,
rowId: hit._id,
filterManager: services.filterManager,
addBasePath: services.addBasePath,
columns,
});
return (
<EuiPortal>
<EuiFlyout
@ -143,8 +151,8 @@ export function DiscoverGridFlyout({
size="xs"
iconType="document"
flush="left"
href={services.addBasePath(getSingleDocUrl(indexPattern.id!, hit._index, hit._id))}
data-test-subj="docTableRowAction"
{...singleDocProps}
>
{i18n.translate('discover.grid.tableRow.viewSingleDocumentLinkTextSimple', {
defaultMessage: 'Single document',
@ -157,13 +165,7 @@ export function DiscoverGridFlyout({
size="xs"
iconType="documents"
flush="left"
href={getContextUrl(
String(hit._id),
indexPattern.id,
columns,
services.filterManager,
services.addBasePath
)}
{...surrDocsProps}
data-test-subj="docTableRowAction"
>
{i18n.translate('discover.grid.tableRow.viewSurroundingDocumentsLinkTextSimple', {

View file

@ -15,12 +15,11 @@ import { flattenHit } from '../../../../../data/common';
import { DocViewer } from '../../../services/doc_views/components/doc_viewer/doc_viewer';
import { FilterManager, IndexPattern } from '../../../../../data/public';
import { TableCell } from './table_row/table_cell';
import { DocViewFilterFn } from '../../../services/doc_views/doc_views_types';
import { getContextUrl } from '../../../utils/get_context_url';
import { getSingleDocUrl } from '../../../utils/get_single_doc_url';
import { TableRowDetails } from './table_row_details';
import { formatRow, formatTopLevelObject } from '../lib/row_formatter';
import { useNavigationProps } from '../../../utils/use_navigation_props';
import { DocViewFilterFn } from '../../../services/doc_views/doc_views_types';
import { ElasticSearchHit } from '../../../types';
import { TableRowDetails } from './table_row_details';
export type DocTableRow = ElasticSearchHit & {
isAnchor?: boolean;
@ -100,13 +99,14 @@ export const TableRow = ({
[filter, flattenedRow, indexPattern.fields]
);
const getContextAppHref = () => {
return getContextUrl(row._id, indexPattern.id!, columns, filterManager, addBasePath);
};
const getSingleDocHref = () => {
return addBasePath(getSingleDocUrl(indexPattern.id!, row._index, row._id));
};
const { singleDocProps, surrDocsProps } = useNavigationProps({
indexPatternId: indexPattern.id!,
rowIndex: row._index,
rowId: row._id,
filterManager,
addBasePath,
columns,
});
const rowCells = [
<td className="kbnDocTableCell__toggleDetails" key="toggleDetailsCell">
@ -208,8 +208,8 @@ export const TableRow = ({
open={open}
colLength={(columns.length || 1) + 2}
isTimeBased={indexPattern.isTimeBased()}
getContextAppHref={getContextAppHref}
getSingleDocHref={getSingleDocHref}
singleDocProps={singleDocProps}
surrDocsProps={surrDocsProps}
>
<DocViewer
columns={columns}

View file

@ -9,12 +9,13 @@
import React from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiLink, EuiTitle } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { DiscoverNavigationProps } from '../../../utils/use_navigation_props';
interface TableRowDetailsProps {
open: boolean;
colLength: number;
isTimeBased: boolean;
getContextAppHref: () => string;
getSingleDocHref: () => string;
singleDocProps: DiscoverNavigationProps;
surrDocsProps: DiscoverNavigationProps;
children: JSX.Element;
}
@ -22,8 +23,8 @@ export const TableRowDetails = ({
open,
colLength,
isTimeBased,
getContextAppHref,
getSingleDocHref,
singleDocProps,
surrDocsProps,
children,
}: TableRowDetailsProps) => {
if (!open) {
@ -54,7 +55,7 @@ export const TableRowDetails = ({
<EuiFlexGroup gutterSize="l" alignItems="center" responsive={false}>
<EuiFlexItem grow={false}>
{isTimeBased && (
<EuiLink data-test-subj="docTableRowAction" href={getContextAppHref()}>
<EuiLink data-test-subj="docTableRowAction" {...surrDocsProps}>
<FormattedMessage
id="discover.docTable.tableRow.viewSurroundingDocumentsLinkText"
defaultMessage="View surrounding documents"
@ -63,7 +64,7 @@ export const TableRowDetails = ({
)}
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiLink data-test-subj="docTableRowAction" href={getSingleDocHref()}>
<EuiLink data-test-subj="docTableRowAction" {...singleDocProps}>
<FormattedMessage
id="discover.docTable.tableRow.viewSingleDocumentLinkText"
defaultMessage="View single document"

View file

@ -10,13 +10,13 @@ import { ChromeStart } from 'kibana/public';
import { i18n } from '@kbn/i18n';
import { SavedSearch } from '../services/saved_searches';
export function getRootBreadcrumbs() {
export function getRootBreadcrumbs(breadcrumb?: string) {
return [
{
text: i18n.translate('discover.rootBreadcrumb', {
defaultMessage: 'Discover',
}),
href: '#/',
href: breadcrumb || '#/',
},
];
}

View file

@ -1,43 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { getContextUrl } from './get_context_url';
import { FilterManager } from '../../../data/public/query/filter_manager';
const filterManager = {
getGlobalFilters: () => [],
getAppFilters: () => [],
} as unknown as FilterManager;
const addBasePath = (path: string) => `/base${path}`;
describe('Get context url', () => {
test('returning a valid context url', async () => {
const url = await getContextUrl(
'docId',
'ipId',
['test1', 'test2'],
filterManager,
addBasePath
);
expect(url).toMatchInlineSnapshot(
`"/base/app/discover#/context/ipId/docId?_g=(filters:!())&_a=(columns:!(test1,test2),filters:!())"`
);
});
test('returning a valid context url when docId contains whitespace', async () => {
const url = await getContextUrl(
'doc Id',
'ipId',
['test1', 'test2'],
filterManager,
addBasePath
);
expect(url).toMatchInlineSnapshot(
`"/base/app/discover#/context/ipId/doc%20Id?_g=(filters:!())&_a=(columns:!(test1,test2),filters:!())"`
);
});
});

View file

@ -1,46 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { stringify } from 'query-string';
import rison from 'rison-node';
import { url } from '../../../kibana_utils/common';
import { esFilters, FilterManager } from '../../../data/public';
import { DiscoverServices } from '../build_services';
/**
* Helper function to generate an URL to a document in Discover's context view
*/
export function getContextUrl(
documentId: string,
indexPatternId: string,
columns: string[],
filterManager: FilterManager,
addBasePath: DiscoverServices['addBasePath']
) {
const globalFilters = filterManager.getGlobalFilters();
const appFilters = filterManager.getAppFilters();
const hash = stringify(
url.encodeQuery({
_g: rison.encode({
filters: globalFilters || [],
}),
_a: rison.encode({
columns,
filters: (appFilters || []).map(esFilters.disableFilter),
}),
}),
{ encode: false, sort: false }
);
return addBasePath(
`/app/discover#/context/${encodeURIComponent(indexPatternId)}/${encodeURIComponent(
documentId
)}?${hash}`
);
}

View file

@ -1,11 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export const getSingleDocUrl = (indexPatternId: string, rowIndex: string, rowId: string) => {
return `/app/discover#/doc/${indexPatternId}/${rowIndex}?id=${encodeURIComponent(rowId)}`;
};

View file

@ -0,0 +1,101 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { ReactElement } from 'react';
import { renderHook } from '@testing-library/react-hooks';
import { createFilterManagerMock } from '../../../data/public/query/filter_manager/filter_manager.mock';
import {
getContextHash,
HistoryState,
useNavigationProps,
UseNavigationProps,
} from './use_navigation_props';
import { Router } from 'react-router-dom';
import { createMemoryHistory } from 'history';
import { setServices } from '../kibana_services';
import { DiscoverServices } from '../build_services';
const filterManager = createFilterManagerMock();
const defaultProps = {
indexPatternId: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f',
rowIndex: 'kibana_sample_data_ecommerce',
rowId: 'QmsYdX0BQ6gV8MTfoPYE',
columns: ['customer_first_name', 'products.manufacturer'],
filterManager,
addBasePath: jest.fn(),
} as UseNavigationProps;
const basePathPrefix = 'localhost:5601/xqj';
const getSearch = () => {
return `?_g=(filters:!(),refreshInterval:(pause:!t,value:0),time:(from:now-15m,to:now))
&_a=(columns:!(${defaultProps.columns.join()}),filters:!(),index:${defaultProps.indexPatternId}
,interval:auto,query:(language:kuery,query:''),sort:!(!(order_date,desc)))`;
};
const getSingeDocRoute = () => {
return `/doc/${defaultProps.indexPatternId}/${defaultProps.rowIndex}`;
};
const getContextRoute = () => {
return `/context/${defaultProps.indexPatternId}/${defaultProps.rowId}`;
};
const render = () => {
const history = createMemoryHistory<HistoryState>({
initialEntries: ['/' + getSearch()],
});
setServices({ history: () => history } as unknown as DiscoverServices);
const wrapper = ({ children }: { children: ReactElement }) => (
<Router history={history}>{children}</Router>
);
return {
result: renderHook(() => useNavigationProps(defaultProps), { wrapper }).result,
history,
};
};
describe('useNavigationProps', () => {
test('should provide valid breadcrumb for single doc page from main view', () => {
const { result, history } = render();
result.current.singleDocProps.onClick?.();
expect(history.location.pathname).toEqual(getSingeDocRoute());
expect(history.location.search).toEqual(`?id=${defaultProps.rowId}`);
expect(history.location.state?.breadcrumb).toEqual(`#/${getSearch()}`);
});
test('should provide valid breadcrumb for context page from main view', () => {
const { result, history } = render();
result.current.surrDocsProps.onClick?.();
expect(history.location.pathname).toEqual(getContextRoute());
expect(history.location.search).toEqual(
`?${getContextHash(defaultProps.columns, filterManager)}`
);
expect(history.location.state?.breadcrumb).toEqual(`#/${getSearch()}`);
});
test('should create valid links to the context and single doc pages from embeddable', () => {
const { result } = renderHook(() =>
useNavigationProps({
...defaultProps,
addBasePath: (val: string) => `${basePathPrefix}${val}`,
})
);
expect(result.current.singleDocProps.href!).toEqual(
`${basePathPrefix}/app/discover#${getSingeDocRoute()}?id=${defaultProps.rowId}`
);
expect(result.current.surrDocsProps.href!).toEqual(
`${basePathPrefix}/app/discover#${getContextRoute()}?${getContextHash(
defaultProps.columns,
filterManager
)}`
);
});
});

View file

@ -0,0 +1,132 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { useMemo, useRef } from 'react';
import { useHistory, matchPath } from 'react-router-dom';
import { stringify } from 'query-string';
import rison from 'rison-node';
import { esFilters, FilterManager } from '../../../data/public';
import { url } from '../../../kibana_utils/common';
import { getServices } from '../kibana_services';
export type DiscoverNavigationProps = { onClick: () => void } | { href: string };
export interface UseNavigationProps {
indexPatternId: string;
rowIndex: string;
rowId: string;
columns: string[];
filterManager: FilterManager;
addBasePath: (url: string) => string;
}
export type HistoryState = { breadcrumb?: string } | undefined;
export const getContextHash = (columns: string[], filterManager: FilterManager) => {
const globalFilters = filterManager.getGlobalFilters();
const appFilters = filterManager.getAppFilters();
const hash = stringify(
url.encodeQuery({
_g: rison.encode({
filters: globalFilters || [],
}),
_a: rison.encode({
columns,
filters: (appFilters || []).map(esFilters.disableFilter),
}),
}),
{ encode: false, sort: false }
);
return hash;
};
/**
* When it's context route, breadcrumb link should point to the main discover page anyway.
* Otherwise, we are on main page and should create breadcrumb link from it.
* Current history object should be used in callback, since url state might be changed
* after expanded document opened.
*/
const getCurrentBreadcrumbs = (isContextRoute: boolean, prevBreadcrumb?: string) => {
const { history: getHistory } = getServices();
const currentHistory = getHistory();
return isContextRoute
? prevBreadcrumb
: '#' + currentHistory?.location.pathname + currentHistory?.location.search;
};
export const useMainRouteBreadcrumb = () => {
// useRef needed to retrieve initial breadcrumb link from the push state without updates
return useRef(useHistory<HistoryState>().location.state?.breadcrumb).current;
};
export const useNavigationProps = ({
indexPatternId,
rowIndex,
rowId,
columns,
filterManager,
addBasePath,
}: UseNavigationProps) => {
const history = useHistory<HistoryState>();
const prevBreadcrumb = useRef(history?.location.state?.breadcrumb).current;
const contextSearchHash = useMemo(
() => getContextHash(columns, filterManager),
[columns, filterManager]
);
/**
* When history can be accessed via hooks,
* it is discover main or context route.
*/
if (!!history) {
const isContextRoute = matchPath(history.location.pathname, {
path: '/context/:indexPatternId/:id',
exact: true,
});
const onOpenSingleDoc = () => {
history.push({
pathname: `/doc/${indexPatternId}/${rowIndex}`,
search: `?id=${encodeURIComponent(rowId)}`,
state: { breadcrumb: getCurrentBreadcrumbs(!!isContextRoute, prevBreadcrumb) },
});
};
const onOpenSurrDocs = () =>
history.push({
pathname: `/context/${encodeURIComponent(indexPatternId)}/${encodeURIComponent(
String(rowId)
)}`,
search: `?${contextSearchHash}`,
state: { breadcrumb: getCurrentBreadcrumbs(!!isContextRoute, prevBreadcrumb) },
});
return {
singleDocProps: { onClick: onOpenSingleDoc },
surrDocsProps: { onClick: onOpenSurrDocs },
};
}
// for embeddable absolute href should be kept
return {
singleDocProps: {
href: addBasePath(
`/app/discover#/doc/${indexPatternId}/${rowIndex}?id=${encodeURIComponent(rowId)}`
),
},
surrDocsProps: {
href: addBasePath(
`/app/discover#/context/${encodeURIComponent(indexPatternId)}/${encodeURIComponent(
rowId
)}?${contextSearchHash}`
),
},
};
};

View file

@ -6,6 +6,7 @@
* Side Public License, v 1.
*/
import expect from '@kbn/expect';
import { FtrProviderContext } from '../../ftr_provider_context';
const TEST_FILTER_COLUMN_NAMES = [
@ -22,6 +23,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const docTable = getService('docTable');
const PageObjects = getPageObjects(['common', 'context', 'discover', 'timePicker']);
const kibanaServer = getService('kibanaServer');
const filterBar = getService('filterBar');
const find = getService('find');
describe('discover - context - back navigation', function contextSize() {
before(async function () {
@ -56,5 +59,29 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
return initialHitCount === hitCount;
});
});
it('should go back via breadcrumbs with preserved state', async function () {
await retry.waitFor(
'user navigating to context and returning to discover via breadcrumbs',
async () => {
await docTable.clickRowToggle({ rowIndex: 0 });
const rowActions = await docTable.getRowActions({ rowIndex: 0 });
await rowActions[0].click();
await PageObjects.context.waitUntilContextLoadingHasFinished();
await find.clickByCssSelector(`[data-test-subj="breadcrumb first"]`);
await PageObjects.discover.waitForDocTableLoadingComplete();
for (const [columnName, value] of TEST_FILTER_COLUMN_NAMES) {
expect(await filterBar.hasFilter(columnName, value)).to.eql(true);
}
expect(await PageObjects.timePicker.getTimeConfigAsAbsoluteTimes()).to.eql({
start: 'Sep 18, 2015 @ 06:31:44.000',
end: 'Sep 23, 2015 @ 18:31:44.000',
});
return true;
}
);
});
});
}