mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
Replace history-extra in place of standard url routing (#121440)
* Replace history-extra in place of standard url routing * Fix smoke test * Fix smoke test again * Handle embeddalbe save + return * Update StoryShot
This commit is contained in:
parent
2ef21c2d44
commit
27d1fa1797
16 changed files with 179 additions and 104 deletions
|
@ -271,7 +271,6 @@
|
|||
"handlebars": "4.7.7",
|
||||
"he": "^1.2.0",
|
||||
"history": "^4.9.0",
|
||||
"history-extra": "^5.0.1",
|
||||
"hjson": "3.2.1",
|
||||
"http-proxy-agent": "^2.1.0",
|
||||
"https-proxy-agent": "^5.0.0",
|
||||
|
|
|
@ -5,7 +5,8 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { FC } from 'react';
|
||||
import useObservable from 'react-use/lib/useObservable';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { CoreStart } from '@kbn/core/public';
|
||||
import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public';
|
||||
|
@ -32,9 +33,28 @@ const embeddablesRegistry: {
|
|||
|
||||
const renderEmbeddableFactory = (core: CoreStart, plugins: StartDeps) => {
|
||||
const I18nContext = core.i18n.Context;
|
||||
const EmbeddableRenderer: FC<{ embeddable: IEmbeddable }> = ({ embeddable }) => {
|
||||
const currentAppId = useObservable(core.application.currentAppId$, undefined);
|
||||
|
||||
const embeddableContainerContext: EmbeddableContainerContext = {
|
||||
getCurrentPath: () => window.location.hash,
|
||||
if (!currentAppId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const embeddableContainerContext: EmbeddableContainerContext = {
|
||||
getCurrentPath: () => {
|
||||
const urlToApp = core.application.getUrlForApp(currentAppId);
|
||||
const inAppPath = window.location.pathname.replace(urlToApp, '');
|
||||
|
||||
return inAppPath + window.location.search + window.location.hash;
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<plugins.embeddable.EmbeddablePanel
|
||||
embeddable={embeddable}
|
||||
containerContext={embeddableContainerContext}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (embeddableObject: IEmbeddable) => {
|
||||
|
@ -45,10 +65,7 @@ const renderEmbeddableFactory = (core: CoreStart, plugins: StartDeps) => {
|
|||
>
|
||||
<I18nContext>
|
||||
<KibanaThemeProvider theme$={core.theme.theme$}>
|
||||
<plugins.embeddable.EmbeddablePanel
|
||||
embeddable={embeddableObject}
|
||||
containerContext={embeddableContainerContext}
|
||||
/>
|
||||
<EmbeddableRenderer embeddable={embeddableObject} />
|
||||
</KibanaThemeProvider>
|
||||
</I18nContext>
|
||||
</div>
|
||||
|
|
|
@ -5,14 +5,9 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { FC, useRef, useEffect } from 'react';
|
||||
import { Observable } from 'rxjs';
|
||||
import React, { FC, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { History } from 'history';
|
||||
// @ts-expect-error
|
||||
import createHashStateHistory from 'history-extra/dist/createHashStateHistory';
|
||||
import { ScopedHistory } from '@kbn/core/public';
|
||||
import { skipWhile, timeout, take } from 'rxjs/operators';
|
||||
import { useNavLinkService } from '../../services';
|
||||
// @ts-expect-error
|
||||
import { shortcutManager } from '../../lib/shortcut_manager';
|
||||
|
@ -33,64 +28,18 @@ class ShortcutManagerContextWrapper extends React.Component {
|
|||
}
|
||||
|
||||
export const App: FC<{ history: ScopedHistory }> = ({ history }) => {
|
||||
const historyRef = useRef<History>(createHashStateHistory() as History);
|
||||
const { updatePath } = useNavLinkService();
|
||||
|
||||
useEffect(() => {
|
||||
return historyRef.current.listen(({ pathname }) => {
|
||||
updatePath(pathname);
|
||||
return history.listen(({ pathname, search }) => {
|
||||
updatePath(pathname + search);
|
||||
});
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
return history.listen(({ pathname, hash }) => {
|
||||
// The scoped history could have something that triggers a url change, and that change is not seen by
|
||||
// our hash router. For example, a scopedHistory.replace() as done as part of the saved object resolve
|
||||
// alias match flow will do the replace on the scopedHistory, and our app doesn't react appropriately
|
||||
|
||||
// So, to work around this, whenever we see a url on the scoped history, we're going to wait a beat and see
|
||||
// if it shows up in our hash router. If it doesn't, then we're going to force it onto our hash router
|
||||
|
||||
// I don't like this at all, and to overcome this we should switch away from hash router sooner rather than later
|
||||
// and just use scopedHistory as our history object
|
||||
const expectedPath = hash.substr(1);
|
||||
const action = history.action;
|
||||
|
||||
// Observable of all the path
|
||||
const hashPaths$ = new Observable<string>((subscriber) => {
|
||||
subscriber.next(historyRef.current.location.pathname);
|
||||
|
||||
const unsubscribeHashListener = historyRef.current.listen(({ pathname: newPath }) => {
|
||||
subscriber.next(newPath);
|
||||
});
|
||||
|
||||
return unsubscribeHashListener;
|
||||
});
|
||||
|
||||
const subscription = hashPaths$
|
||||
.pipe(
|
||||
skipWhile((value) => value !== expectedPath),
|
||||
timeout(100),
|
||||
take(1)
|
||||
)
|
||||
.subscribe({
|
||||
error: (e) => {
|
||||
if (action === 'REPLACE') {
|
||||
historyRef.current.replace(expectedPath);
|
||||
} else {
|
||||
historyRef.current.push(expectedPath);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
window.setTimeout(() => subscription.unsubscribe(), 150);
|
||||
});
|
||||
}, [history, historyRef]);
|
||||
|
||||
return (
|
||||
<ShortcutManagerContextWrapper>
|
||||
<div className="canvas canvasContainer">
|
||||
<CanvasRouter history={historyRef.current} />
|
||||
<CanvasRouter history={history} />
|
||||
</div>
|
||||
</ShortcutManagerContextWrapper>
|
||||
);
|
||||
|
|
|
@ -7,14 +7,10 @@
|
|||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import { getBaseBreadcrumb } from '../../lib/breadcrumbs';
|
||||
import { resetWorkpad } from '../../state/actions/workpad';
|
||||
import { Home as Component } from './home.component';
|
||||
import { usePlatformService } from '../../services';
|
||||
|
||||
export const Home = () => {
|
||||
const { setBreadcrumbs } = usePlatformService();
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
|
@ -25,9 +21,5 @@ export const Home = () => {
|
|||
}
|
||||
}, [dispatch, isMounted, setIsMounted]);
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([getBaseBreadcrumb()]);
|
||||
}, [setBreadcrumbs]);
|
||||
|
||||
return <Component />;
|
||||
};
|
||||
|
|
|
@ -382,6 +382,7 @@ exports[`Storyshots Home/Components/Workpad Table Workpad Table 1`] = `
|
|||
className="euiLink euiLink--primary"
|
||||
data-test-subj="canvasWorkpadTableWorkpad"
|
||||
href="/workpad/workpad-2"
|
||||
onClick={[Function]}
|
||||
rel="noreferrer"
|
||||
>
|
||||
<span>
|
||||
|
@ -558,6 +559,7 @@ exports[`Storyshots Home/Components/Workpad Table Workpad Table 1`] = `
|
|||
className="euiLink euiLink--primary"
|
||||
data-test-subj="canvasWorkpadTableWorkpad"
|
||||
href="/workpad/workpad-1"
|
||||
onClick={[Function]}
|
||||
rel="noreferrer"
|
||||
>
|
||||
<span>
|
||||
|
@ -734,6 +736,7 @@ exports[`Storyshots Home/Components/Workpad Table Workpad Table 1`] = `
|
|||
className="euiLink euiLink--primary"
|
||||
data-test-subj="canvasWorkpadTableWorkpad"
|
||||
href="/workpad/workpad-0"
|
||||
onClick={[Function]}
|
||||
rel="noreferrer"
|
||||
>
|
||||
<span>
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
*/
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { getBaseBreadcrumb } from '../../lib/breadcrumbs';
|
||||
import { resetWorkpad } from '../../state/actions/workpad';
|
||||
|
@ -16,10 +17,11 @@ export const HomeApp = () => {
|
|||
const { setBreadcrumbs } = usePlatformService();
|
||||
const dispatch = useDispatch();
|
||||
const onLoad = () => dispatch(resetWorkpad());
|
||||
const history = useHistory();
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([getBaseBreadcrumb()]);
|
||||
}, [setBreadcrumbs]);
|
||||
setBreadcrumbs([getBaseBreadcrumb(history)]);
|
||||
}, [setBreadcrumbs, history]);
|
||||
|
||||
return <Component onLoad={onLoad} />;
|
||||
};
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { FC } from 'react';
|
||||
import React, { FC, useCallback, MouseEvent } from 'react';
|
||||
import { EuiLink, EuiLinkProps, EuiButtonIcon, EuiButtonIconProps } from '@elastic/eui';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
|
@ -15,13 +15,43 @@ interface RoutingProps {
|
|||
|
||||
type RoutingLinkProps = Omit<EuiLinkProps, 'href' | 'onClick'> & RoutingProps;
|
||||
|
||||
const isModifiedEvent = (event: MouseEvent) =>
|
||||
!!(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey);
|
||||
|
||||
const isLeftClickEvent = (event: MouseEvent) => event.button === 0;
|
||||
|
||||
const isTargetBlank = (event: MouseEvent) => {
|
||||
const target = (event.target as HTMLElement).getAttribute('target');
|
||||
return target && target !== '_self';
|
||||
};
|
||||
|
||||
export const RoutingLink: FC<RoutingLinkProps> = ({ to, ...rest }) => {
|
||||
const history = useHistory();
|
||||
|
||||
const onClick = useCallback(
|
||||
(event: MouseEvent) => {
|
||||
if (event.defaultPrevented) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Let the browser handle links that open new tabs/windows
|
||||
if (isModifiedEvent(event) || !isLeftClickEvent(event) || isTargetBlank(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent regular link behavior, which causes a browser refresh.
|
||||
event.preventDefault();
|
||||
|
||||
// Push the route to the history.
|
||||
history.push(to);
|
||||
},
|
||||
[history, to]
|
||||
);
|
||||
|
||||
// Generate the correct link href (with basename accounted for)
|
||||
const href = history.createHref({ pathname: to });
|
||||
|
||||
const props = { ...rest, href } as EuiLinkProps;
|
||||
const props = { ...rest, href, onClick } as EuiLinkProps;
|
||||
|
||||
return <EuiLink {...props} />;
|
||||
};
|
||||
|
@ -31,10 +61,30 @@ type RoutingButtonIconProps = Omit<EuiButtonIconProps, 'href' | 'onClick'> & Rou
|
|||
export const RoutingButtonIcon: FC<RoutingButtonIconProps> = ({ to, ...rest }) => {
|
||||
const history = useHistory();
|
||||
|
||||
const onClick = useCallback(
|
||||
(event: MouseEvent) => {
|
||||
if (event.defaultPrevented) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Let the browser handle links that open new tabs/windows
|
||||
if (isModifiedEvent(event) || !isLeftClickEvent(event) || isTargetBlank(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent regular link behavior, which causes a browser refresh.
|
||||
event.preventDefault();
|
||||
|
||||
// Push the route to the history.
|
||||
history.push(to);
|
||||
},
|
||||
[history, to]
|
||||
);
|
||||
|
||||
// Generate the correct link href (with basename accounted for)
|
||||
const href = history.createHref({ pathname: to });
|
||||
|
||||
const props = { ...rest, href } as EuiButtonIconProps;
|
||||
const props = { ...rest, href, onClick } as EuiButtonIconProps;
|
||||
|
||||
return <EuiButtonIcon {...props} />;
|
||||
};
|
||||
|
|
|
@ -29,7 +29,7 @@ interface Props {
|
|||
|
||||
export const EditorMenu: FC<Props> = ({ addElement }) => {
|
||||
const embeddablesService = useEmbeddablesService();
|
||||
const { pathname, search } = useLocation();
|
||||
const { pathname, search, hash } = useLocation();
|
||||
const platformService = usePlatformService();
|
||||
const stateTransferService = embeddablesService.getStateTransfer();
|
||||
const visualizationsService = useVisualizationsService();
|
||||
|
@ -61,11 +61,11 @@ export const EditorMenu: FC<Props> = ({ addElement }) => {
|
|||
path,
|
||||
state: {
|
||||
originatingApp: CANVAS_APP,
|
||||
originatingPath: `#/${pathname}${search}`,
|
||||
originatingPath: `${pathname}${search}${hash}`,
|
||||
},
|
||||
});
|
||||
},
|
||||
[stateTransferService, pathname, search]
|
||||
[stateTransferService, pathname, search, hash]
|
||||
);
|
||||
|
||||
const createNewEmbeddable = useCallback(
|
||||
|
|
|
@ -5,12 +5,47 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { MouseEvent } from 'react';
|
||||
import { History } from 'history';
|
||||
import { ChromeBreadcrumb } from '@kbn/core/public';
|
||||
|
||||
export const getBaseBreadcrumb = (): ChromeBreadcrumb => ({
|
||||
text: 'Canvas',
|
||||
href: '#/',
|
||||
});
|
||||
const isModifiedEvent = (event: MouseEvent) =>
|
||||
!!(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey);
|
||||
|
||||
const isLeftClickEvent = (event: MouseEvent) => event.button === 0;
|
||||
|
||||
const isTargetBlank = (event: MouseEvent) => {
|
||||
const target = (event.target as HTMLElement).getAttribute('target');
|
||||
return target && target !== '_self';
|
||||
};
|
||||
|
||||
export const getBaseBreadcrumb = (history: History): ChromeBreadcrumb => {
|
||||
const path = '/';
|
||||
const href = history.createHref({ pathname: path });
|
||||
|
||||
const onClick = (event: MouseEvent) => {
|
||||
if (event.defaultPrevented) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Let the browser handle links that open new tabs/windows
|
||||
if (isModifiedEvent(event) || !isLeftClickEvent(event) || isTargetBlank(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent regular link behavior, which causes a browser refresh.
|
||||
event.preventDefault();
|
||||
|
||||
// Push the route to the history.
|
||||
history.push(path);
|
||||
};
|
||||
|
||||
return {
|
||||
text: 'Canvas',
|
||||
href,
|
||||
onClick,
|
||||
};
|
||||
};
|
||||
|
||||
export const getWorkpadBreadcrumb = ({
|
||||
name = 'Workpad',
|
||||
|
|
|
@ -6,17 +6,48 @@
|
|||
*/
|
||||
|
||||
import React, { FC } from 'react';
|
||||
import { Router, Switch } from 'react-router-dom';
|
||||
import { Router, Switch, Route, RouteComponentProps, Redirect } from 'react-router-dom';
|
||||
import { History } from 'history';
|
||||
import { parse, stringify } from 'query-string';
|
||||
import { HomeRoute } from './home';
|
||||
import { WorkpadRoute, ExportWorkpadRoute } from './workpad';
|
||||
|
||||
const isHashPath = (hash: string) => {
|
||||
return hash.indexOf('#/') === 0;
|
||||
};
|
||||
|
||||
const mergeQueryStrings = (query: string, queryFromHash: string) => {
|
||||
const queryObject = parse(query);
|
||||
const hashObject = parse(queryFromHash);
|
||||
|
||||
return stringify({ ...queryObject, ...hashObject });
|
||||
};
|
||||
|
||||
export const CanvasRouter: FC<{ history: History }> = ({ history }) => (
|
||||
<Router history={history}>
|
||||
<Switch>
|
||||
{ExportWorkpadRoute()}
|
||||
{WorkpadRoute()}
|
||||
{HomeRoute()}
|
||||
</Switch>
|
||||
<Route
|
||||
path="/"
|
||||
children={(route: RouteComponentProps) => {
|
||||
// If it looks like the hash is a route then we will do a redirect
|
||||
if (isHashPath(route.location.hash)) {
|
||||
const [hashPath, hashQuery] = route.location.hash.split('?');
|
||||
let search = route.location.search || '?';
|
||||
|
||||
if (hashQuery !== undefined) {
|
||||
search = mergeQueryStrings(search, `?${hashQuery}`);
|
||||
}
|
||||
|
||||
return <Redirect to={`${hashPath.substr(1)}${search.length > 1 ? `?${search}` : ''}`} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
{ExportWorkpadRoute()}
|
||||
{WorkpadRoute()}
|
||||
{HomeRoute()}
|
||||
</Switch>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Router>
|
||||
);
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
import { i18n } from '@kbn/i18n';
|
||||
import React, { FC, useEffect } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { getBaseBreadcrumb, getWorkpadBreadcrumb } from '../../lib/breadcrumbs';
|
||||
// @ts-expect-error
|
||||
import { setDocTitle } from '../../lib/doc_title';
|
||||
|
@ -27,13 +28,14 @@ export const WorkpadPresentationHelper: FC = ({ children }) => {
|
|||
useFullscreenPresentationHelper();
|
||||
useAutoplayHelper();
|
||||
useRefreshHelper();
|
||||
const history = useHistory();
|
||||
|
||||
useEffect(() => {
|
||||
platformService.setBreadcrumbs([
|
||||
getBaseBreadcrumb(),
|
||||
getBaseBreadcrumb(history),
|
||||
getWorkpadBreadcrumb({ name: workpad.name }),
|
||||
]);
|
||||
}, [workpad.name, workpad.id, platformService]);
|
||||
}, [workpad.name, workpad.id, platformService, history]);
|
||||
|
||||
useEffect(() => {
|
||||
setDocTitle(workpad.name);
|
||||
|
@ -44,7 +46,7 @@ export const WorkpadPresentationHelper: FC = ({ children }) => {
|
|||
objectNoun: getWorkpadLabel(),
|
||||
currentObjectId: workpad.id,
|
||||
otherObjectId: workpad.aliasId,
|
||||
otherObjectPath: `#/workpad/${workpad.aliasId}`,
|
||||
otherObjectPath: `/workpad/${workpad.aliasId}`,
|
||||
})
|
||||
: null;
|
||||
|
||||
|
|
|
@ -20,7 +20,7 @@ export type CanvasNavLinkServiceFactory = KibanaPluginServiceFactory<
|
|||
export const navLinkServiceFactory: CanvasNavLinkServiceFactory = ({ coreStart, appUpdater }) => ({
|
||||
updatePath: (path: string) => {
|
||||
appUpdater?.next(() => ({
|
||||
defaultPath: `#${path}`,
|
||||
defaultPath: `${path}`,
|
||||
}));
|
||||
|
||||
getSessionStorage().set(`${SESSIONSTORAGE_LASTPATH}:${coreStart.http.basePath.get()}`, path);
|
||||
|
|
|
@ -94,7 +94,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
|
|||
it(`allows a workpad to be edited`, async () => {
|
||||
await PageObjects.common.navigateToActualUrl(
|
||||
'canvas',
|
||||
'workpad/workpad-1705f884-6224-47de-ba49-ca224fe6ec31',
|
||||
'/workpad/workpad-1705f884-6224-47de-ba49-ca224fe6ec31',
|
||||
{
|
||||
ensureCurrentUrl: true,
|
||||
shouldLoginIfPrompted: false,
|
||||
|
@ -171,7 +171,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
|
|||
it(`does not allow a workpad to be edited`, async () => {
|
||||
await PageObjects.common.navigateToActualUrl(
|
||||
'canvas',
|
||||
'workpad/workpad-1705f884-6224-47de-ba49-ca224fe6ec31',
|
||||
'/workpad/workpad-1705f884-6224-47de-ba49-ca224fe6ec31',
|
||||
{
|
||||
ensureCurrentUrl: true,
|
||||
shouldLoginIfPrompted: false,
|
||||
|
|
|
@ -80,7 +80,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
|
|||
it(`allows a workpad to be edited`, async () => {
|
||||
await PageObjects.common.navigateToActualUrl(
|
||||
'canvas',
|
||||
'workpad/workpad-1705f884-6224-47de-ba49-ca224fe6ec31',
|
||||
'/workpad/workpad-1705f884-6224-47de-ba49-ca224fe6ec31',
|
||||
{
|
||||
ensureCurrentUrl: true,
|
||||
shouldLoginIfPrompted: false,
|
||||
|
|
|
@ -48,9 +48,9 @@ export default function canvasSmokeTest({ getService, getPageObjects }) {
|
|||
await retry.try(async () => {
|
||||
const url = await browser.getCurrentUrl();
|
||||
|
||||
// remove all the search params, just compare the route
|
||||
const hashRoute = new URL(url).hash.split('?')[0];
|
||||
expect(hashRoute).to.equal(`#/workpad/${testWorkpadId}/page/1`);
|
||||
const path = new URL(url).pathname;
|
||||
|
||||
expect(path).to.equal(`/app/canvas/workpad/${testWorkpadId}/page/1`);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -16133,11 +16133,6 @@ highlight.js@^10.1.1, highlight.js@^10.4.1, highlight.js@~10.4.0:
|
|||
resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-10.4.1.tgz#d48fbcf4a9971c4361b3f95f302747afe19dbad0"
|
||||
integrity sha512-yR5lWvNz7c85OhVAEAeFhVCc/GV4C30Fjzc/rCP0aCWzc1UUOPUk55dK/qdwTZHBvMZo+eZ2jpk62ndX/xMFlg==
|
||||
|
||||
history-extra@^5.0.1:
|
||||
version "5.0.1"
|
||||
resolved "https://registry.yarnpkg.com/history-extra/-/history-extra-5.0.1.tgz#95a2e59dda526c4241d0ae1b124a77a5e4675ce8"
|
||||
integrity sha512-6XV1L1lHgporVWgppa/Kq+Fnz4lhBew7iMxYCTfzVmoEywsAKJnTjdw1zOd+EGLHGYp0/V8jSVMEgqx4QbHLTw==
|
||||
|
||||
history@^4.9.0:
|
||||
version "4.9.0"
|
||||
resolved "https://registry.yarnpkg.com/history/-/history-4.9.0.tgz#84587c2068039ead8af769e9d6a6860a14fa1bca"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue