Skip to content

Commit

Permalink
[App Search] DRY helper for encoding/decoding routes that can have sp…
Browse files Browse the repository at this point in the history
…ecial characters in params (#89811)

* Add encodePathParam helper + update components that need it

- Primarily document URLs & analytics queries (which uses generateEnginePath)

* Add useDecodedParams helper + update components that need it

- Documents titles & Analytics queries

* [Misc] Change popout icon to eye

- Feedback from Davey - the pages don't open in a new window, so shouldn't use the popout icon
- Not strictly related but since we're touching these links anyway, I'm shoving it in (sorry)

* Remove document detail decode test

- now handled/tested by useDecodedParams helper

* Add new generateEncodedPath helper

- Should be used in place of generatePath

* Update all instances of generatePath to generateEncodedPath

for consistency across the App Search codebase

* Fix failing tests due to extra encodeURI() done by generatePath

* Add missing branch test for analytics query titles

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
  • Loading branch information
Constance and kibanamachine authored Feb 2, 2021
1 parent 4940a1c commit 977fc6c
Show file tree
Hide file tree
Showing 12 changed files with 125 additions and 30 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,20 @@ export const mockLocation = {
state: {},
};

jest.mock('react-router-dom', () => ({
...(jest.requireActual('react-router-dom') as object),
useHistory: jest.fn(() => mockHistory),
useLocation: jest.fn(() => mockLocation),
useParams: jest.fn(() => ({})),
}));
jest.mock('react-router-dom', () => {
const originalModule = jest.requireActual('react-router-dom');
return {
...originalModule,
useHistory: jest.fn(() => mockHistory),
useLocation: jest.fn(() => mockLocation),
useParams: jest.fn(() => ({})),
// Note: RR's generatePath() opinionatedly encodeURI()s paths (although this doesn't actually
// show up/affect the final browser URL). Since we already have a generateEncodedPath helper &
// RR is removing this behavior in history 5.0+, I'm mocking tests to remove the extra encoding
// for now to make reading generateEncodedPath URLs a little less of a pain
generatePath: jest.fn((path, params) => decodeURI(originalModule.generatePath(path, params))),
};
});

/**
* For example usage, @see public/applications/shared/react_router_helpers/eui_link.test.tsx
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@
* you may not use this file except in compliance with the Elastic License.
*/

import { generatePath } from 'react-router-dom';
import { generateEncodedPath } from '../utils/encode_path_params';

export const mockEngineValues = {
engineName: 'some-engine',
engine: {},
};

export const mockGenerateEnginePath = jest.fn((path, pathParams = {}) =>
generatePath(path, { engineName: mockEngineValues.engineName, ...pathParams })
generateEncodedPath(path, { engineName: mockEngineValues.engineName, ...pathParams })
);

jest.mock('../components/engine', () => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export const ACTIONS_COLUMN = {
{ defaultMessage: 'View query analytics' }
),
type: 'icon',
icon: 'popout',
icon: 'eye',
color: 'primary',
onClick: (item: Query | RecentQuery) => {
const { navigateToUrl } = KibanaLogic.values;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,15 @@ import { shallow } from 'enzyme';

import { SetAppSearchChrome as SetPageChrome } from '../../../../shared/kibana_chrome';

import { AnalyticsLayout } from '../analytics_layout';
import { AnalyticsCards, AnalyticsChart, QueryClicksTable } from '../components';
import { QueryDetail } from './';

describe('QueryDetail', () => {
const mockBreadcrumbs = ['Engines', 'some-engine', 'Analytics'];

beforeEach(() => {
(useParams as jest.Mock).mockReturnValueOnce({ query: 'some-query' });
(useParams as jest.Mock).mockReturnValue({ query: 'some-query' });

setMockValues({
totalQueriesForQuery: 100,
Expand All @@ -31,6 +32,7 @@ describe('QueryDetail', () => {
it('renders', () => {
const wrapper = shallow(<QueryDetail breadcrumbs={mockBreadcrumbs} />);

expect(wrapper.find(AnalyticsLayout).prop('title')).toEqual('"some-query"');
expect(wrapper.find(SetPageChrome).prop('trail')).toEqual([
'Engines',
'some-engine',
Expand All @@ -43,4 +45,11 @@ describe('QueryDetail', () => {
expect(wrapper.find(AnalyticsChart)).toHaveLength(1);
expect(wrapper.find(QueryClicksTable)).toHaveLength(1);
});

it('renders empty "" search titles correctly', () => {
(useParams as jest.Mock).mockReturnValue({ query: '""' });
const wrapper = shallow(<QueryDetail breadcrumbs={mockBreadcrumbs} />);

expect(wrapper.find(AnalyticsLayout).prop('title')).toEqual('""');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@
*/

import React from 'react';
import { useParams } from 'react-router-dom';
import { useValues } from 'kea';

import { i18n } from '@kbn/i18n';
import { EuiSpacer } from '@elastic/eui';

import { SetAppSearchChrome as SetPageChrome } from '../../../../shared/kibana_chrome';
import { BreadcrumbTrail } from '../../../../shared/kibana_chrome/generate_breadcrumbs';
import { useDecodedParams } from '../../../utils/encode_path_params';

import { AnalyticsLayout } from '../analytics_layout';
import { AnalyticsSection, QueryClicksTable } from '../components';
Expand All @@ -27,14 +27,15 @@ interface Props {
breadcrumbs: BreadcrumbTrail;
}
export const QueryDetail: React.FC<Props> = ({ breadcrumbs }) => {
const { query } = useParams() as { query: string };
const { query } = useDecodedParams();
const queryTitle = query === '""' ? query : `"${query}"`;

const { totalQueriesForQuery, queriesPerDayForQuery, startDate, topClicksForQuery } = useValues(
AnalyticsLogic
);

return (
<AnalyticsLayout isQueryView title={`"${query}"`}>
<AnalyticsLayout isQueryView title={queryTitle}>
<SetPageChrome trail={[...breadcrumbs, QUERY_DETAIL_TITLE, query]} />

<AnalyticsCards
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,10 +104,4 @@ describe('DocumentDetail', () => {

expect(actions.deleteDocument).toHaveBeenCalledWith('1');
});

it('correctly decodes document IDs', () => {
(useParams as jest.Mock).mockReturnValueOnce({ documentId: 'hello%20world%20%26%3F!' });
const wrapper = shallow(<DocumentDetail engineBreadcrumb={['test']} />);
expect(wrapper.find('h1').text()).toEqual('Document: hello world &?!');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { i18n } from '@kbn/i18n';
import { Loading } from '../../../shared/loading';
import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome';
import { FlashMessages } from '../../../shared/flash_messages';
import { useDecodedParams } from '../../utils/encode_path_params';
import { ResultFieldValue } from '../result';

import { DocumentDetailLogic } from './document_detail_logic';
Expand All @@ -43,6 +44,7 @@ export const DocumentDetail: React.FC<Props> = ({ engineBreadcrumb }) => {
const { deleteDocument, getDocumentDetails, setFields } = useActions(DocumentDetailLogic);

const { documentId } = useParams() as { documentId: string };
const { documentId: documentTitle } = useDecodedParams();

useEffect(() => {
getDocumentDetails(documentId);
Expand Down Expand Up @@ -74,13 +76,11 @@ export const DocumentDetail: React.FC<Props> = ({ engineBreadcrumb }) => {

return (
<>
<SetPageChrome
trail={[...engineBreadcrumb, DOCUMENTS_TITLE, decodeURIComponent(documentId)]}
/>
<SetPageChrome trail={[...engineBreadcrumb, DOCUMENTS_TITLE, documentTitle]} />
<EuiPageHeader>
<EuiPageHeaderSection>
<EuiTitle size="l">
<h1>{DOCUMENT_DETAIL_TITLE(decodeURIComponent(documentId))}</h1>
<h1>{DOCUMENT_DETAIL_TITLE(documentTitle)}</h1>
</EuiTitle>
</EuiPageHeaderSection>
<EuiPageHeaderSection>
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 { generatePath } from 'react-router-dom';
import { generateEncodedPath } from '../../utils/encode_path_params';

import { EngineLogic } from './';

Expand All @@ -13,5 +13,5 @@ import { EngineLogic } from './';
*/
export const generateEnginePath = (path: string, pathParams: object = {}) => {
const { engineName } = EngineLogic.values;
return generatePath(path, { engineName, ...pathParams });
return generateEncodedPath(path, { engineName, ...pathParams });
};
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@
*/

import React from 'react';
import { generatePath } from 'react-router-dom';
import { useActions } from 'kea';
import { EuiBasicTable, EuiBasicTableColumn } from '@elastic/eui';
import { FormattedMessage, FormattedDate, FormattedNumber } from '@kbn/i18n/react';
import { i18n } from '@kbn/i18n';

import { TelemetryLogic } from '../../../shared/telemetry';
import { EuiLinkTo } from '../../../shared/react_router_helpers';
import { generateEncodedPath } from '../../utils/encode_path_params';
import { ENGINE_PATH } from '../../routes';

import { ENGINES_PAGE_SIZE } from '../../../../../common/constants';
Expand Down Expand Up @@ -41,7 +41,7 @@ export const EnginesTable: React.FC<EnginesTableProps> = ({
const { sendAppSearchTelemetry } = useActions(TelemetryLogic);

const engineLinkProps = (engineName: string) => ({
to: generatePath(ENGINE_PATH, { engineName }),
to: generateEncodedPath(ENGINE_PATH, { engineName }),
onClick: () =>
sendAppSearchTelemetry({
action: 'clicked',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
*/

import React, { useState, useMemo } from 'react';
import { generatePath } from 'react-router-dom';
import classNames from 'classnames';

import './result.scss';
Expand All @@ -14,6 +13,7 @@ import { EuiPanel, EuiIcon } from '@elastic/eui';
import { i18n } from '@kbn/i18n';

import { ReactRouterHelper } from '../../../shared/react_router_helpers/eui_components';
import { generateEncodedPath } from '../../utils/encode_path_params';
import { ENGINE_DOCUMENT_DETAIL_PATH } from '../../routes';

import { Schema } from '../../../shared/types';
Expand Down Expand Up @@ -52,7 +52,7 @@ export const Result: React.FC<Props> = ({
if (schemaForTypeHighlights) return schemaForTypeHighlights[fieldName];
};

const documentLink = generatePath(ENGINE_DOCUMENT_DETAIL_PATH, {
const documentLink = generateEncodedPath(ENGINE_DOCUMENT_DETAIL_PATH, {
engineName: resultMeta.engine,
documentId: resultMeta.id,
});
Expand Down Expand Up @@ -135,7 +135,7 @@ export const Result: React.FC<Props> = ({
{ defaultMessage: 'Visit document details' }
)}
>
<EuiIcon type="popout" />
<EuiIcon type="eye" />
</a>
</ReactRouterHelper>
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/

import '../../../__mocks__/react_router_history.mock';
import { useParams } from 'react-router-dom';

import { encodePathParams, generateEncodedPath, useDecodedParams } from './';

describe('encodePathParams', () => {
it('encodeURIComponent()s all object values', () => {
const params = {
someValue: 'hello world???',
anotherValue: 'test!@#$%^&*[]/|;:"<>~`',
};
expect(encodePathParams(params)).toEqual({
someValue: 'hello%20world%3F%3F%3F',
anotherValue: 'test!%40%23%24%25%5E%26*%5B%5D%2F%7C%3B%3A%22%3C%3E~%60',
});
});
});

describe('generateEncodedPath', () => {
it('generates a react router path with encoded path parameters', () => {
expect(
generateEncodedPath('/values/:someValue/:anotherValue/new', {
someValue: 'hello world???',
anotherValue: 'test!@#$%^&*[]/|;:"<>~`',
})
).toEqual(
'/values/hello%20world%3F%3F%3F/test!%40%23%24%25%5E%26*%5B%5D%2F%7C%3B%3A%22%3C%3E~%60/new'
);
});
});

describe('useDecodedParams', () => {
it('decodeURIComponent()s all object values from useParams()', () => {
(useParams as jest.Mock).mockReturnValue({
someValue: 'hello%20world%3F%3F%3F',
anotherValue: 'test!%40%23%24%25%5E%26*%5B%5D%2F%7C%3B%3A%22%3C%3E~%60',
});
expect(useDecodedParams()).toEqual({
someValue: 'hello world???',
anotherValue: 'test!@#$%^&*[]/|;:"<>~`',
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/

import { generatePath, useParams } from 'react-router-dom';

type PathParams = Record<string, string>;

export const encodePathParams = (pathParams: PathParams) => {
const encodedParams: PathParams = {};

Object.entries(pathParams).map(([key, value]) => {
encodedParams[key] = encodeURIComponent(value);
});

return encodedParams;
};

export const generateEncodedPath = (path: string, pathParams: PathParams) => {
return generatePath(path, encodePathParams(pathParams));
};

export const useDecodedParams = () => {
const decodedParams: PathParams = {};

const params = useParams();
Object.entries(params).map(([key, value]) => {
decodedParams[key] = decodeURIComponent(value as string);
});

return decodedParams;
};

0 comments on commit 977fc6c

Please sign in to comment.