Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[7.x] [Logs / Metrics UI] Switch to scopedHistory and enhance useLinkProps hook (#61667) #64190

Merged
merged 1 commit into from
Apr 22, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions x-pack/plugins/infra/public/apps/start_app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
* you may not use this file except in compliance with the Elastic License.
*/

import { createBrowserHistory } from 'history';
import React from 'react';
import ReactDOM from 'react-dom';
import { ApolloProvider } from 'react-apollo';
Expand All @@ -25,6 +24,7 @@ import { AppRouter } from '../routers';
import { TriggersAndActionsUIPublicPluginSetup } from '../../../triggers_actions_ui/public';
import { TriggersActionsProvider } from '../utils/triggers_actions_context';
import '../index.scss';
import { NavigationWarningPromptProvider } from '../utils/navigation_warning_prompt';

export const CONTAINER_CLASSNAME = 'infra-container-element';

Expand All @@ -36,8 +36,8 @@ export async function startApp(
Router: AppRouter,
triggersActionsUI: TriggersAndActionsUIPublicPluginSetup
) {
const { element, appBasePath } = params;
const history = createBrowserHistory({ basename: appBasePath });
const { element, history } = params;

const InfraPluginRoot: React.FunctionComponent = () => {
const [darkMode] = useUiSetting$<boolean>('theme:darkMode');

Expand All @@ -49,7 +49,9 @@ export async function startApp(
<ApolloClientContext.Provider value={libs.apolloClient}>
<EuiThemeProvider darkMode={darkMode}>
<HistoryContext.Provider value={history}>
<Router history={history} />
<NavigationWarningPromptProvider>
<Router history={history} />
</NavigationWarningPromptProvider>
</HistoryContext.Provider>
</EuiThemeProvider>
</ApolloClientContext.Provider>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import {
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import React, { useCallback, useContext, useMemo } from 'react';
import { Prompt } from 'react-router-dom';

import { Source } from '../../containers/source';
import { FieldsConfigurationPanel } from './fields_configuration_panel';
Expand All @@ -26,6 +25,7 @@ import { NameConfigurationPanel } from './name_configuration_panel';
import { LogColumnsConfigurationPanel } from './log_columns_configuration_panel';
import { useSourceConfigurationFormState } from './source_configuration_form_state';
import { SourceLoadingPage } from '../source_loading_page';
import { Prompt } from '../../utils/navigation_warning_prompt';

interface SourceConfigurationSettingsProps {
shouldAllowEdit: boolean;
Expand Down Expand Up @@ -100,10 +100,13 @@ export const SourceConfigurationSettings = ({
data-test-subj="sourceConfigurationContent"
>
<Prompt
when={isFormDirty}
message={i18n.translate('xpack.infra.sourceConfiguration.unsavedFormPrompt', {
defaultMessage: 'Are you sure you want to leave? Changes will be lost',
})}
prompt={
isFormDirty
? i18n.translate('xpack.infra.sourceConfiguration.unsavedFormPrompt', {
defaultMessage: 'Are you sure you want to leave? Changes will be lost',
})
: undefined
}
/>
<EuiPanel paddingSize="l">
<NameConfigurationPanel
Expand Down
24 changes: 10 additions & 14 deletions x-pack/plugins/infra/public/hooks/use_link_props.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,14 @@
*/

import { encode } from 'rison-node';
import { createMemoryHistory, LocationDescriptorObject } from 'history';
import { createMemoryHistory } from 'history';
import { renderHook } from '@testing-library/react-hooks';
import React from 'react';
import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public';
import { HistoryContext } from '../utils/history_context';
import { coreMock } from 'src/core/public/mocks';
import { useLinkProps, LinkDescriptor } from './use_link_props';
import { ScopedHistory } from '../../../../../src/core/public';

const PREFIX = '/test-basepath/s/test-space/app/';

Expand All @@ -23,18 +24,13 @@ coreStartMock.application.getUrlForApp.mockImplementation((app, options) => {

const INTERNAL_APP = 'metrics';

// Note: Memory history doesn't support basename,
// we'll work around this by re-assigning 'createHref' so that
// it includes a basename, this then acts as our browserHistory instance would.
const history = createMemoryHistory();
const originalCreateHref = history.createHref;
history.createHref = (location: LocationDescriptorObject): string => {
return `${PREFIX}${INTERNAL_APP}${originalCreateHref.call(history, location)}`;
};
history.push(`${PREFIX}${INTERNAL_APP}`);
const scopedHistory = new ScopedHistory(history, `${PREFIX}${INTERNAL_APP}`);

const ProviderWrapper: React.FC = ({ children }) => {
return (
<HistoryContext.Provider value={history}>
<HistoryContext.Provider value={scopedHistory}>
<KibanaContextProvider services={{ ...coreStartMock }}>{children}</KibanaContextProvider>;
</HistoryContext.Provider>
);
Expand Down Expand Up @@ -111,7 +107,7 @@ describe('useLinkProps hook', () => {
pathname: '/',
});
expect(result.current.href).toBe('/test-basepath/s/test-space/app/ml/');
expect(result.current.onClick).not.toBeDefined();
expect(result.current.onClick).toBeDefined();
});

it('Provides the correct props with pathname options', () => {
Expand All @@ -127,7 +123,7 @@ describe('useLinkProps hook', () => {
expect(result.current.href).toBe(
'/test-basepath/s/test-space/app/ml/explorer?type=host&id=some-id&count=12345'
);
expect(result.current.onClick).not.toBeDefined();
expect(result.current.onClick).toBeDefined();
});

it('Provides the correct props with hash options', () => {
Expand All @@ -143,7 +139,7 @@ describe('useLinkProps hook', () => {
expect(result.current.href).toBe(
'/test-basepath/s/test-space/app/ml#/explorer?type=host&id=some-id&count=12345'
);
expect(result.current.onClick).not.toBeDefined();
expect(result.current.onClick).toBeDefined();
});

it('Provides the correct props with more complex encoding', () => {
Expand All @@ -161,7 +157,7 @@ describe('useLinkProps hook', () => {
expect(result.current.href).toBe(
'/test-basepath/s/test-space/app/ml#/explorer?type=host%20%2B%20host&name=this%20name%20has%20spaces%20and%20**%20and%20%25&id=some-id&count=12345&animals=dog,cat,bear'
);
expect(result.current.onClick).not.toBeDefined();
expect(result.current.onClick).toBeDefined();
});

it('Provides the correct props with a consumer using Rison encoding for search', () => {
Expand All @@ -180,7 +176,7 @@ describe('useLinkProps hook', () => {
expect(result.current.href).toBe(
'/test-basepath/s/test-space/app/rison-app#rison-route?type=host%20%2B%20host&state=(refreshInterval:(pause:!t,value:0),time:(from:12345,to:54321))'
);
expect(result.current.onClick).not.toBeDefined();
expect(result.current.onClick).toBeDefined();
});
});
});
78 changes: 36 additions & 42 deletions x-pack/plugins/infra/public/hooks/use_link_props.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import { stringify } from 'query-string';
import url from 'url';
import { url as urlUtils } from '../../../../../src/plugins/kibana_utils/public';
import { usePrefixPathWithBasepath } from './use_prefix_path_with_basepath';
import { useHistory } from '../utils/history_context';
import { useKibana } from '../../../../../src/plugins/kibana_react/public';
import { useNavigationWarningPrompt } from '../utils/navigation_warning_prompt';

type Search = Record<string, string | string[]>;

Expand All @@ -28,60 +29,63 @@ interface LinkProps {
export const useLinkProps = ({ app, pathname, hash, search }: LinkDescriptor): LinkProps => {
validateParams({ app, pathname, hash, search });

const history = useHistory();
const { prompt } = useNavigationWarningPrompt();
const prefixer = usePrefixPathWithBasepath();
const navigateToApp = useKibana().services.application?.navigateToApp;

const encodedSearch = useMemo(() => {
return search ? encodeSearch(search) : undefined;
}, [search]);

const internalLinkResult = useMemo(() => {
// When the logs / metrics apps are first mounted a history instance is setup with a 'basename' equal to the
// 'appBasePath' received from Core's 'AppMountParams', e.g. /BASE_PATH/s/SPACE_ID/app/APP_ID. With internal
// linking we are using 'createHref' and 'push' on top of this history instance. So a pathname of /inventory used within
// the metrics app will ultimatey end up as /BASE_PATH/s/SPACE_ID/app/metrics/inventory. React-router responds to this
// as it is instantiated with the same history instance.
return history?.createHref({
pathname: pathname ? formatPathname(pathname) : undefined,
search: encodedSearch,
});
}, [history, pathname, encodedSearch]);

const externalLinkResult = useMemo(() => {
const mergedHash = useMemo(() => {
// The URI spec defines that the query should appear before the fragment
// https://tools.ietf.org/html/rfc3986#section-3 (e.g. url.format()). However, in Kibana, apps that use
// hash based routing expect the query to be part of the hash. This will handle that.
const mergedHash = hash && encodedSearch ? `${hash}?${encodedSearch}` : hash;
return hash && encodedSearch ? `${hash}?${encodedSearch}` : hash;
}, [hash, encodedSearch]);

const mergedPathname = useMemo(() => {
return pathname && encodedSearch ? `${pathname}?${encodedSearch}` : pathname;
}, [pathname, encodedSearch]);

const href = useMemo(() => {
const link = url.format({
pathname,
hash: mergedHash,
search: !hash ? encodedSearch : undefined,
});

return prefixer(app, link);
}, [hash, encodedSearch, pathname, prefixer, app]);
}, [mergedHash, hash, encodedSearch, pathname, prefixer, app]);

const onClick = useMemo(() => {
// If these results are equal we know we're trying to navigate within the same application
// that the current history instance is representing
if (internalLinkResult && linksAreEquivalent(externalLinkResult, internalLinkResult)) {
return (e: React.MouseEvent | React.MouseEvent<HTMLAnchorElement | HTMLButtonElement>) => {
e.preventDefault();
if (history) {
history.push({
pathname: pathname ? formatPathname(pathname) : undefined,
search: encodedSearch,
});
return (e: React.MouseEvent | React.MouseEvent<HTMLAnchorElement | HTMLButtonElement>) => {
e.preventDefault();

const navigate = () => {
if (navigateToApp) {
const navigationPath = mergedHash ? `#${mergedHash}` : mergedPathname;
navigateToApp(app, { path: navigationPath ? navigationPath : undefined });
}
};
} else {
return undefined;
}
}, [internalLinkResult, externalLinkResult, history, pathname, encodedSearch]);

// A <Prompt /> component somewhere within the app hierarchy is requesting that we
// prompt the user before navigating.
if (prompt) {
const wantsToNavigate = window.confirm(prompt);
if (wantsToNavigate) {
navigate();
} else {
return;
}
} else {
navigate();
}
};
}, [navigateToApp, mergedHash, mergedPathname, app, prompt]);

return {
href: externalLinkResult,
href,
onClick,
};
};
Expand All @@ -90,20 +94,10 @@ const encodeSearch = (search: Search) => {
return stringify(urlUtils.encodeQuery(search), { sort: false, encode: false });
};

const formatPathname = (pathname: string) => {
return pathname[0] === '/' ? pathname : `/${pathname}`;
};

const validateParams = ({ app, pathname, hash, search }: LinkDescriptor) => {
if (!app && hash) {
throw new Error(
'The metrics and logs apps use browserHistory. Please provide a pathname rather than a hash.'
);
}
};

const linksAreEquivalent = (externalLink: string, internalLink: string): boolean => {
// Compares with trailing slashes removed. This handles the case where the pathname is '/'
// and 'createHref' will include the '/' but Kibana's 'getUrlForApp' will remove it.
return externalLink.replace(/\/$/, '') === internalLink.replace(/\/$/, '');
};
4 changes: 2 additions & 2 deletions x-pack/plugins/infra/public/utils/history_context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
*/

import { createContext, useContext } from 'react';
import { History } from 'history';
import { ScopedHistory } from 'src/core/public';

export const HistoryContext = createContext<History | undefined>(undefined);
export const HistoryContext = createContext<ScopedHistory | undefined>(undefined);

export const useHistory = () => {
return useContext(HistoryContext);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/*
* 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 React, { useState } from 'react';
import { createContext, useContext } from 'react';

interface ContextValues {
prompt?: string;
setPrompt: (prompt: string | undefined) => void;
}

export const NavigationWarningPromptContext = createContext<ContextValues>({
setPrompt: (prompt: string | undefined) => {},
});

export const useNavigationWarningPrompt = () => {
return useContext(NavigationWarningPromptContext);
};

export const NavigationWarningPromptProvider: React.FC = ({ children }) => {
const [prompt, setPrompt] = useState<string | undefined>(undefined);

return (
<NavigationWarningPromptContext.Provider value={{ prompt, setPrompt }}>
{children}
</NavigationWarningPromptContext.Provider>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/*
* 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.
*/

export * from './context';
export * from './prompt';
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/*
* 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 React, { useEffect } from 'react';
import { useNavigationWarningPrompt } from './context';

interface Props {
prompt?: string;
}

export const Prompt: React.FC<Props> = ({ prompt }) => {
const { setPrompt } = useNavigationWarningPrompt();

useEffect(() => {
setPrompt(prompt);
return () => {
setPrompt(undefined);
};
}, [prompt, setPrompt]);

return null;
};