Skip to content

Commit

Permalink
[Logs / Metrics UI] Switch to scopedHistory and enhance useLinkProps …
Browse files Browse the repository at this point in the history
…hook (#61667)
  • Loading branch information
Kerry350 committed Apr 22, 2020
1 parent b529a4f commit 3fc6e98
Show file tree
Hide file tree
Showing 8 changed files with 126 additions and 67 deletions.
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;
};

0 comments on commit 3fc6e98

Please sign in to comment.