[Canvas] Switch Canvas to use React Router (#100579)

* Switch Canvas to use React Router

* Fix typescript errors

* Remove @scant/router from package.json

* Fix tests

* Fix functional test

* Fix functional tests

* Fix bad merge in package.json

* Cleanup from code review comments

* Fix double basepath append

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Corey Robertson 2021-06-01 17:35:56 -04:00 committed by GitHub
parent 72d5b8a388
commit b62848ce8a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
107 changed files with 2071 additions and 2311 deletions

View file

@ -131,20 +131,21 @@
"@kbn/config": "link:bazel-bin/packages/kbn-config/npm_module",
"@kbn/config-schema": "link:bazel-bin/packages/kbn-config-schema/npm_module",
"@kbn/crypto": "link:bazel-bin/packages/kbn-crypto/npm_module",
"@kbn/mapbox-gl": "link:bazel-bin/packages/kbn-mapbox-gl/npm_module",
"@kbn/i18n": "link:bazel-bin/packages/kbn-i18n/npm_module",
"@kbn/interpreter": "link:packages/kbn-interpreter",
"@kbn/io-ts-utils": "link:bazel-bin/packages/kbn-io-ts-utils/npm_module",
"@kbn/legacy-logging": "link:bazel-bin/packages/kbn-legacy-logging/npm_module",
"@kbn/logging": "link:bazel-bin/packages/kbn-logging/npm_module",
"@kbn/mapbox-gl": "link:bazel-bin/packages/kbn-mapbox-gl/npm_module",
"@kbn/monaco": "link:bazel-bin/packages/kbn-monaco/npm_module",
"@kbn/securitysolution-list-constants": "link:bazel-bin/packages/kbn-securitysolution-list-constants/npm_module",
"@kbn/rule-data-utils": "link:packages/kbn-rule-data-utils",
"@kbn/securitysolution-es-utils": "link:bazel-bin/packages/kbn-securitysolution-es-utils/npm_module",
"@kbn/securitysolution-io-ts-types": "link:bazel-bin/packages/kbn-securitysolution-io-ts-types/npm_module",
"@kbn/securitysolution-io-ts-alerting-types": "link:bazel-bin/packages/kbn-securitysolution-io-ts-alerting-types/npm_module",
"@kbn/securitysolution-io-ts-list-types": "link:bazel-bin/packages/kbn-securitysolution-io-ts-list-types/npm_module",
"@kbn/securitysolution-io-ts-types": "link:bazel-bin/packages/kbn-securitysolution-io-ts-types/npm_module",
"@kbn/securitysolution-io-ts-utils": "link:bazel-bin/packages/kbn-securitysolution-io-ts-utils/npm_module",
"@kbn/securitysolution-list-api": "link:bazel-bin/packages/kbn-securitysolution-list-api/npm_module",
"@kbn/securitysolution-list-constants": "link:bazel-bin/packages/kbn-securitysolution-list-constants/npm_module",
"@kbn/securitysolution-list-hooks": "link:bazel-bin/packages/kbn-securitysolution-list-hooks/npm_module",
"@kbn/securitysolution-list-utils": "link:bazel-bin/packages/kbn-securitysolution-list-utils/npm_module",
"@kbn/securitysolution-utils": "link:bazel-bin/packages/kbn-securitysolution-utils/npm_module",
@ -163,7 +164,6 @@
"@mapbox/mapbox-gl-rtl-text": "0.2.3",
"@mapbox/vector-tile": "1.3.1",
"@reduxjs/toolkit": "^1.5.1",
"@scant/router": "^0.1.1",
"@slack/webhook": "^5.0.4",
"@turf/along": "6.0.1",
"@turf/area": "6.0.1",
@ -276,7 +276,6 @@
"json-stringify-safe": "5.0.1",
"jsonwebtoken": "^8.5.1",
"jsts": "^1.6.2",
"@kbn/rule-data-utils": "link:packages/kbn-rule-data-utils",
"kea": "^2.4.2",
"leaflet": "1.5.1",
"leaflet-draw": "0.4.14",

View file

@ -175,6 +175,12 @@ export const ComponentStrings = {
defaultMessage: 'Asset thumbnail',
}),
},
CanvasLoading: {
getLoadingLabel: () =>
i18n.translate('xpack.canvas.canvasLoading.loadingMessage', {
defaultMessage: 'Loading',
}),
},
ColorManager: {
getAddAriaLabel: () =>
i18n.translate('xpack.canvas.colorManager.addAriaLabel', {
@ -1384,6 +1390,14 @@ export const ComponentStrings = {
i18n.translate('xpack.canvas.workpadHeaderKioskControl.controlTitle', {
defaultMessage: 'Cycle fullscreen pages',
}),
getAutoplayListDurationManualText: () =>
i18n.translate('xpack.canvas.workpadHeaderKioskControl.autoplayListDurationManual', {
defaultMessage: 'Manually',
}),
getDisableTooltip: () =>
i18n.translate('xpack.canvas.workpadHeaderKioskControl.disableTooltip', {
defaultMessage: 'Disable auto-play',
}),
},
WorkpadHeaderRefreshControlSettings: {
getRefreshAriaLabel: () =>

View file

@ -18,7 +18,6 @@ import { includes, remove } from 'lodash';
import { AppMountParameters, CoreStart, CoreSetup, AppUpdater } from 'kibana/public';
import { CanvasStartDeps, CanvasSetupDeps } from './plugin';
// @ts-expect-error untyped local
import { App } from './components/app';
import { KibanaContextProvider } from '../../../../src/plugins/kibana_react/public';
import { registerLanguage } from './lib/monaco_language_def';
@ -32,10 +31,6 @@ import { init as initStatsReporter } from './lib/ui_metric';
import { CapabilitiesStrings } from '../i18n';
import { startServices, services, ServicesProvider } from './services';
// @ts-expect-error untyped local
import { createHistory, destroyHistory } from './lib/history_provider';
// @ts-expect-error untyped local
import { stopRouter } from './lib/router_provider';
import { initFunctions } from './functions';
// @ts-expect-error untyped local
import { appUnload } from './state/actions/app';
@ -103,9 +98,6 @@ export const initializeCanvas = async (
services.expressions.getService().registerFunction(fn);
}
// Re-initialize our history
createHistory();
// Create Store
const canvasStore = await createStore(coreSetup, setupPlugins);
@ -178,7 +170,4 @@ export const teardownCanvas = (coreStart: CoreStart, startPlugins: CanvasStartDe
coreStart.chrome.setBadge(undefined);
coreStart.chrome.setHelpExtension(undefined);
destroyHistory();
stopRouter();
};

View file

@ -1,55 +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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { Dispatch } from 'redux';
// @ts-expect-error Untyped local
import * as workpadService from '../../lib/workpad_service';
import { setWorkpad } from '../../state/actions/workpad';
// @ts-expect-error Untyped local
import { fetchAllRenderables } from '../../state/actions/elements';
// @ts-expect-error Untyped local
import { setPage } from '../../state/actions/pages';
// @ts-expect-error Untyped local
import { setAssets } from '../../state/actions/assets';
import { ExportApp } from './export';
export const routes = [
{
path: '/export/workpad',
children: [
{
name: 'exportWorkpad',
path: '/pdf/:id/page/:page',
action: (dispatch: Dispatch) => async ({
params,
// @ts-expect-error Fix when Router is typed.
router,
}: {
params: { id: string; page: string };
}) => {
// load workpad if given a new id via url param
const fetchedWorkpad = await workpadService.get(params.id);
const pageNumber = parseInt(params.page, 10);
// redirect to home app on invalid workpad id or page number
if (fetchedWorkpad == null && isNaN(pageNumber)) {
return router.redirectTo('home');
}
const { assets, ...workpad } = fetchedWorkpad;
dispatch(setAssets(assets));
dispatch(setWorkpad(workpad, { loadPages: false }));
dispatch(setPage(pageNumber - 1));
dispatch(fetchAllRenderables({ onlyActivePage: true }));
},
meta: {
component: ExportApp,
},
},
],
},
];

View file

@ -1,16 +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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { connect } from 'react-redux';
import { resetWorkpad } from '../../../state/actions/workpad';
import { HomeApp as Component } from './home_app.component';
export const HomeApp = connect(null, (dispatch) => ({
onLoad() {
dispatch(resetWorkpad());
},
}))(Component);

View file

@ -1,22 +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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { getBaseBreadcrumb, setBreadcrumb } from '../../lib/breadcrumbs';
import { HomeApp } from './home_app';
export const routes = [
{
name: 'home',
path: '/',
action: () => () => {
setBreadcrumb([getBaseBreadcrumb()]);
},
meta: {
component: HomeApp,
},
},
];

View file

@ -1,15 +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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import * as home from './home';
import * as workpad from './workpad';
import * as exp from './export';
// @ts-expect-error Router and routes are not yet strongly typed
export const routes = [].concat(workpad.routes, home.routes, exp.routes);
export const apps = [workpad.WorkpadApp, home.HomeApp, exp.ExportApp];

View file

@ -1,110 +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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { Dispatch } from 'redux';
// @ts-expect-error
import * as workpadService from '../../lib/workpad_service';
import { notifyService } from '../../services';
import { getBaseBreadcrumb, getWorkpadBreadcrumb, setBreadcrumb } from '../../lib/breadcrumbs';
// @ts-expect-error
import { getDefaultWorkpad } from '../../state/defaults';
import { setWorkpad } from '../../state/actions/workpad';
// @ts-expect-error
import { setAssets, resetAssets } from '../../state/actions/assets';
// @ts-expect-error
import { setPage } from '../../state/actions/pages';
import { getWorkpad } from '../../state/selectors/workpad';
// @ts-expect-error
import { setZoomScale } from '../../state/actions/transient';
import { ErrorStrings } from '../../../i18n';
import { WorkpadApp } from './workpad_app';
import { State } from '../../../types';
const { workpadRoutes: strings } = ErrorStrings;
export const routes = [
{
path: '/workpad',
children: [
{
name: 'createWorkpad',
path: '/create',
// @ts-expect-error Fix when Router is typed.
action: (dispatch: Dispatch) => async ({ router }) => {
const newWorkpad = getDefaultWorkpad();
try {
await workpadService.create(newWorkpad);
dispatch(setWorkpad(newWorkpad));
dispatch(resetAssets());
router.redirectTo('loadWorkpad', { id: newWorkpad.id, page: 1 });
} catch (err) {
notifyService
.getService()
.error(err, { title: strings.getCreateFailureErrorMessage() });
router.redirectTo('home');
}
},
meta: {
component: WorkpadApp,
},
},
{
name: 'loadWorkpad',
path: '/:id(/page/:page)',
action: (dispatch: Dispatch, getState: () => State) => async ({
params,
// @ts-expect-error Fix when Router is typed.
router,
}: {
params: { id: string; page?: string };
}) => {
// load workpad if given a new id via url param
const state = getState();
const currentWorkpad = getWorkpad(state);
if (params.id !== currentWorkpad.id) {
try {
const fetchedWorkpad = await workpadService.get(params.id);
const { assets, ...workpad } = fetchedWorkpad;
dispatch(setAssets(assets));
dispatch(setWorkpad(workpad));
// reset transient properties when changing workpads
dispatch(setZoomScale(1));
} catch (err) {
notifyService
.getService()
.error(err, { title: strings.getLoadFailureErrorMessage() });
return router.redirectTo('home');
}
}
// fetch the workpad again, to get changes
const workpad = getWorkpad(getState());
const pageNumber = params.page ? parseInt(params.page, 10) : null;
// no page provided, append current page to url
if (!pageNumber || isNaN(pageNumber)) {
return router.redirectTo('loadWorkpad', { id: workpad.id, page: workpad.page + 1 });
}
// set the active page using the number provided in the url
const pageIndex = pageNumber - 1;
if (pageIndex !== workpad.page) {
dispatch(setPage(pageIndex));
}
// update the application's breadcrumb
setBreadcrumb([getBaseBreadcrumb(), getWorkpadBreadcrumb(workpad)]);
},
meta: {
component: WorkpadApp,
},
},
],
},
];

View file

@ -1,74 +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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import PropTypes from 'prop-types';
import { routes } from '../../apps';
import { shortcutManager } from '../../lib/shortcut_manager';
import { getWindow } from '../../lib/get_window';
import { Router } from '../router';
import { ComponentStrings } from '../../../i18n';
const { App: strings } = ComponentStrings;
export class App extends React.PureComponent {
static propTypes = {
appState: PropTypes.object.isRequired,
setAppReady: PropTypes.func.isRequired,
setAppError: PropTypes.func.isRequired,
onRouteChange: PropTypes.func.isRequired,
};
static childContextTypes = {
shortcuts: PropTypes.object.isRequired,
};
getChildContext() {
return { shortcuts: shortcutManager };
}
componentDidMount() {
const win = getWindow();
win.canvasInitErrorHandler && win.canvasInitErrorHandler();
}
componentWillUnmount() {
const win = getWindow();
win.canvasRestoreErrorHandler && win.canvasRestoreErrorHandler();
}
renderError = () => {
console.error(this.props.appState);
return (
<div>
<div>{strings.getLoadErrorTitle()}</div>
<div>{strings.getLoadErrorMessage(this.props.appState.messgae)}</div>
</div>
);
};
render() {
if (this.props.appState instanceof Error) {
return this.renderError();
}
return (
<div className="canvas canvasContainer">
<Router
routes={routes}
showLoading={this.props.appState.ready === false}
loadingMessage={strings.getLoadingMessage()}
onRouteChange={this.props.onRouteChange}
onLoad={() => this.props.setAppReady(true)}
onError={(err) => this.props.setAppError(err)}
/>
</div>
);
}
}

View file

@ -1,53 +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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { connect } from 'react-redux';
import { compose, withProps } from 'recompose';
import { getAppReady, getBasePath } from '../../state/selectors/app';
import { appReady, appError } from '../../state/actions/app';
import { withServices } from '../../services';
import { App as Component } from './app';
const mapStateToProps = (state) => {
// appReady could be an error object
const appState = getAppReady(state);
return {
appState: typeof appState === 'object' ? appState : { ready: appState },
basePath: getBasePath(state),
};
};
const mapDispatchToProps = (dispatch) => ({
setAppReady: () => async () => {
try {
// set app state to ready
dispatch(appReady());
} catch (e) {
dispatch(appError(e));
}
},
setAppError: (payload) => dispatch(appError(payload)),
});
const mergeProps = (stateProps, dispatchProps, ownProps) => {
return {
...ownProps,
...stateProps,
...dispatchProps,
setAppReady: dispatchProps.setAppReady(stateProps.basePath),
};
};
export const App = compose(
connect(mapStateToProps, mapDispatchToProps, mergeProps),
withServices,
withProps((props) => ({
onRouteChange: props.services.navLink.updatePath,
}))
)(Component);

View file

@ -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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { FC, useRef, useEffect } from 'react';
import PropTypes from 'prop-types';
import { History } from 'history';
// @ts-expect-error
import createHashStateHistory from 'history-extra/dist/createHashStateHistory';
import { useServices } from '../../services';
// @ts-expect-error
import { shortcutManager } from '../../lib/shortcut_manager';
import { CanvasRouter } from '../../routes';
class ShortcutManagerContextWrapper extends React.Component {
static childContextTypes = {
shortcuts: PropTypes.object.isRequired,
};
getChildContext() {
return { shortcuts: shortcutManager };
}
render() {
return <>{this.props.children}</>;
}
}
export const App: FC = () => {
const historyRef = useRef<History>(createHashStateHistory() as History);
const services = useServices();
useEffect(() => {
return historyRef.current.listen(({ pathname }) => {
services.navLink.updatePath(pathname);
});
});
return (
<ShortcutManagerContextWrapper>
<div className="canvas canvasContainer">
<CanvasRouter history={historyRef.current} />
</div>
</ShortcutManagerContextWrapper>
);
};

View file

@ -5,11 +5,15 @@
* 2.0.
*/
import React from 'react';
import PropTypes from 'prop-types';
import React, { FC } from 'react';
import { EuiPanel, EuiLoadingChart, EuiSpacer, EuiText } from '@elastic/eui';
import { ComponentStrings } from '../../../i18n/components';
export const CanvasLoading = ({ msg }) => (
const { CanvasLoading: strings } = ComponentStrings;
export const CanvasLoading: FC<{ msg?: string }> = ({
msg = `${strings.getLoadingLabel()}...`,
}) => (
<div className="canvasContainer--loading">
<EuiPanel>
<EuiLoadingChart size="m" />
@ -20,11 +24,3 @@ export const CanvasLoading = ({ msg }) => (
</EuiPanel>
</div>
);
CanvasLoading.propTypes = {
msg: PropTypes.string,
};
CanvasLoading.defaultProps = {
msg: 'Loading...',
};

View file

@ -5,5 +5,4 @@
* 2.0.
*/
export { routes } from './routes';
export { ExportApp } from './export';
export * from './canvas_loading.component';

View file

@ -38,18 +38,13 @@ exports[`<ExportApp /> renders as expected 1`] = `
<div
className="canvasLayout__stageHeader"
>
<Link
name="loadWorkpad"
params={
Object {
"id": "my-workpad-abcd",
}
}
<RoutingLink
to="/workpad/my-workpad-abcd"
>
<div>
Link
</div>
</Link>
</RoutingLink>
</div>
<div
className="canvasExport__stageContent"
@ -108,18 +103,13 @@ exports[`<ExportApp /> renders as expected 2`] = `
<div
className="canvasLayout__stageHeader"
>
<Link
name="loadWorkpad"
params={
Object {
"id": "my-workpad-abcd",
}
}
<RoutingLink
to="/workpad/my-workpad-abcd"
>
<div>
Link
</div>
</Link>
</RoutingLink>
</div>
<div
className="canvasExport__stageContent"

View file

@ -10,9 +10,9 @@ import PropTypes from 'prop-types';
// @ts-expect-error untyped library
import Style from 'style-it';
// @ts-expect-error untyped local
import { WorkpadPage } from '../../../components/workpad_page';
import { Link } from '../../../components/link';
import { CanvasWorkpad } from '../../../../types';
import { WorkpadPage } from '../workpad_page';
import { RoutingLink } from '../routing';
import { CanvasWorkpad } from '../../../types';
export interface Props {
workpad: CanvasWorkpad;
@ -31,9 +31,7 @@ export const ExportApp: FC<Props> = ({ workpad, selectedPageIndex, initializeWor
<div className="canvasExport" data-shared-page={selectedPageIndex + 1}>
<div className="canvasExport__stage">
<div className="canvasLayout__stageHeader">
<Link name="loadWorkpad" params={{ id }}>
Edit Workpad
</Link>
<RoutingLink to={`/workpad/${id}`}>Edit Workpad</RoutingLink>
</div>
{Style.it(
workpad.css,

View file

@ -8,18 +8,18 @@
import React from 'react';
import { mount } from 'enzyme';
import { ExportApp } from './export_app.component';
import { CanvasWorkpad } from '../../../../types';
import { CanvasWorkpad } from '../../../types';
jest.mock('style-it', () => ({
it: (css: string, Component: any) => Component,
}));
jest.mock('../../../components/workpad_page', () => ({
jest.mock('../workpad_page', () => ({
WorkpadPage: (props: any) => <div>Page</div>,
}));
jest.mock('../../../components/link', () => ({
Link: (props: any) => <div>Link</div>,
jest.mock('../routing', () => ({
RoutingLink: (props: any) => <div>Link</div>,
}));
describe('<ExportApp />', () => {

View file

@ -6,10 +6,10 @@
*/
import { connect } from 'react-redux';
import { initializeWorkpad } from '../../../state/actions/workpad';
import { getWorkpad, getSelectedPageIndex } from '../../../state/selectors/workpad';
import { initializeWorkpad } from '../../state/actions/workpad';
import { getWorkpad, getSelectedPageIndex } from '../../state/selectors/workpad';
import { ExportApp as Component } from './export_app.component';
import { State } from '../../../../types';
import { State } from '../../../types';
export const ExportApp = connect(
(state: State) => ({

View file

@ -1,16 +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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { connect } from 'react-redux';
import { getFullscreen } from '../../state/selectors/app';
import { Fullscreen as Component } from './fullscreen';
const mapStateToProps = (state) => ({
isFullscreen: getFullscreen(state),
});
export const Fullscreen = connect(mapStateToProps)(Component);

View file

@ -0,0 +1,18 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { FC, useContext } from 'react';
// @ts-expect-error
import { Fullscreen as Component } from './fullscreen';
import { WorkpadRoutingContext } from '../../routes/workpad';
export const Fullscreen: FC = ({ children }) => {
const { isFullscreen } = useContext(WorkpadRoutingContext);
return <Component isFullscreen={isFullscreen} children={children} />;
};

View file

@ -8,9 +8,9 @@
import React, { FC } from 'react';
import { EuiPage, EuiPageBody, EuiPageContent } from '@elastic/eui';
// @ts-expect-error untyped local
import { WorkpadManager } from '../../../components/workpad_manager';
import { WorkpadManager } from '../workpad_manager';
// @ts-expect-error untyped local
import { setDocTitle } from '../../../lib/doc_title';
import { setDocTitle } from '../../lib/doc_title';
export interface Props {
onLoad: () => void;

View file

@ -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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useEffect } from 'react';
import { useDispatch } from 'react-redux';
import { getBaseBreadcrumb } from '../../lib/breadcrumbs';
import { resetWorkpad } from '../../state/actions/workpad';
import { HomeApp as Component } from './home_app.component';
import { usePlatformService } from '../../services';
export const HomeApp = () => {
const { setBreadcrumbs } = usePlatformService();
const dispatch = useDispatch();
const onLoad = () => dispatch(resetWorkpad());
useEffect(() => {
setBreadcrumbs([getBaseBreadcrumb()]);
}, [setBreadcrumbs]);
return <Component onLoad={onLoad} />;
};

View file

@ -1,73 +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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { FC, MouseEvent, useContext } from 'react';
import PropTypes from 'prop-types';
import { EuiLink, EuiLinkProps } from '@elastic/eui';
import { RouterContext } from '../router';
import { ComponentStrings } from '../../../i18n';
const { Link: strings } = ComponentStrings;
const isModifiedEvent = (ev: MouseEvent) =>
!!(ev.metaKey || ev.altKey || ev.ctrlKey || ev.shiftKey);
interface Props {
name: string;
params: Record<string, any>;
}
export const Link: FC<Props & EuiLinkProps> = ({
onClick,
target,
name,
params,
children,
...linkArgs
}) => {
const router = useContext(RouterContext);
if (router) {
const navigateTo = (ev: MouseEvent<HTMLButtonElement, globalThis.MouseEvent>) => {
if (onClick) {
onClick(ev);
}
if (
!ev.defaultPrevented && // onClick prevented default
ev.button === 0 && // ignore everything but left clicks
!target && // let browser handle "target=_blank" etc.
!isModifiedEvent(ev) // ignore clicks with modifier keys
) {
ev.preventDefault();
router.navigateTo(name, params);
}
};
try {
return (
<EuiLink {...linkArgs} target={target} onClick={navigateTo}>
{children}
</EuiLink>
);
} catch (e) {
return <div>{strings.getErrorMessage(e.message)}</div>;
}
}
return <div>{strings.getErrorMessage('Router Undefined')}</div>;
};
Link.contextTypes = {
router: PropTypes.object,
};
Link.propTypes = {
name: PropTypes.string.isRequired,
params: PropTypes.object,
};

View file

@ -11,9 +11,9 @@ import { EuiIcon, EuiFlexGroup, EuiFlexItem, EuiText, EuiToolTip } from '@elasti
import { DragDropContext, Droppable, Draggable, DragDropContextProps } from 'react-beautiful-dnd';
// @ts-expect-error untyped dependency
import Style from 'style-it';
import { ConfirmModal } from '../confirm_modal';
import { Link } from '../link';
import { RoutingLink } from '../routing';
import { WorkpadRoutingContext } from '../../routes/workpad';
import { PagePreview } from '../page_preview';
import { ComponentStrings } from '../../../i18n';
@ -131,14 +131,10 @@ export class PageManager extends Component<Props, State> {
resetRemove = () => this._isMounted && this.setState({ removeId: null });
doRemove = () => {
const { onPreviousPage, onRemovePage, selectedPage } = this.props;
const { onRemovePage } = this.props;
const { removeId } = this.state;
this.resetRemove();
if (removeId === selectedPage) {
onPreviousPage();
}
if (removeId !== null) {
onRemovePage(removeId);
}
@ -156,7 +152,7 @@ export class PageManager extends Component<Props, State> {
};
renderPage = (page: CanvasPage, i: number) => {
const { isWriteable, selectedPage, workpadId, workpadCSS } = this.props;
const { isWriteable, selectedPage, workpadCSS } = this.props;
const pageNumber = i + 1;
return (
@ -183,18 +179,18 @@ export class PageManager extends Component<Props, State> {
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<Link
name="loadWorkpad"
params={{ id: workpadId, page: pageNumber }}
aria-label={strings.getPageNumberAriaLabel(pageNumber)}
>
{Style.it(
workpadCSS,
<div>
<PagePreview height={100} page={page} onRemove={this.onConfirmRemove} />
</div>
<WorkpadRoutingContext.Consumer>
{({ getUrl }) => (
<RoutingLink to={getUrl(pageNumber)}>
{Style.it(
workpadCSS,
<div>
<PagePreview height={100} page={page} onRemove={this.onConfirmRemove} />
</div>
)}
</RoutingLink>
)}
</Link>
</WorkpadRoutingContext.Consumer>
</EuiFlexItem>
</EuiFlexGroup>
</div>

View file

@ -1,32 +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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { Dispatch } from 'redux';
import { connect } from 'react-redux';
// @ts-expect-error untyped local
import * as pageActions from '../../state/actions/pages';
import { canUserWrite } from '../../state/selectors/app';
import { getSelectedPage, getWorkpad, getPages, isWriteable } from '../../state/selectors/workpad';
import { DEFAULT_WORKPAD_CSS } from '../../../common/lib/constants';
import { PageManager as Component } from './page_manager.component';
import { State } from '../../../types';
const mapStateToProps = (state: State) => ({
isWriteable: isWriteable(state) && canUserWrite(state),
pages: getPages(state),
selectedPage: getSelectedPage(state),
workpadId: getWorkpad(state).id,
workpadCSS: getWorkpad(state).css || DEFAULT_WORKPAD_CSS,
});
const mapDispatchToProps = (dispatch: Dispatch) => ({
onAddPage: () => dispatch(pageActions.addPage()),
onMovePage: (id: string, position: number) => dispatch(pageActions.movePage(id, position)),
onRemovePage: (id: string) => dispatch(pageActions.removePage(id)),
});
export const PageManager = connect(mapStateToProps, mapDispatchToProps)(Component);

View file

@ -0,0 +1,55 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { FC, useCallback, useContext } from 'react';
import { useSelector, useDispatch } from 'react-redux';
// @ts-expect-error untyped local
import * as pageActions from '../../state/actions/pages';
import { canUserWrite } from '../../state/selectors/app';
import { getSelectedPage, getWorkpad, getPages, isWriteable } from '../../state/selectors/workpad';
import { DEFAULT_WORKPAD_CSS } from '../../../common/lib/constants';
import { PageManager as Component } from './page_manager.component';
import { State } from '../../../types';
import { WorkpadRoutingContext } from '../../routes/workpad';
export const PageManager: FC<{ onPreviousPage: () => void }> = ({ onPreviousPage }) => {
const dispatch = useDispatch();
const propsFromState = useSelector((state: State) => ({
isWriteable: isWriteable(state) && canUserWrite(state),
pages: getPages(state),
selectedPage: getSelectedPage(state),
workpadId: getWorkpad(state).id,
workpadCSS: getWorkpad(state).css || DEFAULT_WORKPAD_CSS,
}));
const { gotoPage } = useContext(WorkpadRoutingContext);
const onAddPage = useCallback(() => dispatch(pageActions.addPage({ gotoPage })), [
dispatch,
gotoPage,
]);
const onMovePage = useCallback(
(id: string, position: number) => dispatch(pageActions.movePage(id, position, gotoPage)),
[dispatch, gotoPage]
);
const onRemovePage = useCallback(
(id: string) => dispatch(pageActions.removePage({ id, gotoPage })),
[dispatch, gotoPage]
);
return (
<Component
onPreviousPage={onPreviousPage}
onAddPage={onAddPage}
onMovePage={onMovePage}
onRemovePage={onRemovePage}
{...propsFromState}
/>
);
};

View file

@ -1,25 +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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { Dispatch } from 'redux';
import { connect } from 'react-redux';
// @ts-expect-error untyped local
import * as pageActions from '../../state/actions/pages';
import { canUserWrite } from '../../state/selectors/app';
import { isWriteable } from '../../state/selectors/workpad';
import { PagePreview as Component } from './page_preview.component';
import { State } from '../../../types';
const mapStateToProps = (state: State) => ({
isWriteable: isWriteable(state) && canUserWrite(state),
});
const mapDispatchToProps = (dispatch: Dispatch) => ({
onDuplicate: (id: string) => dispatch(pageActions.duplicatePage(id)),
});
export const PagePreview = connect(mapStateToProps, mapDispatchToProps)(Component);

View file

@ -0,0 +1,36 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { FC, useContext, useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
// @ts-expect-error untyped local
import * as pageActions from '../../state/actions/pages';
import { canUserWrite } from '../../state/selectors/app';
import { isWriteable } from '../../state/selectors/workpad';
import { PagePreview as Component, Props } from './page_preview.component';
import { State } from '../../../types';
import { WorkpadRoutingContext } from '../../routes/workpad';
export const PagePreview: FC<Omit<Props, 'onDuplicate' | 'isWriteable'>> = (props) => {
const dispatch = useDispatch();
const stateFromProps = useSelector((state: State) => ({
isWriteable: isWriteable(state) && canUserWrite(state),
}));
const { gotoPage } = useContext(WorkpadRoutingContext);
const onDuplicate = useCallback(
(id: string) => {
dispatch(pageActions.duplicatePage({ id, gotoPage }));
},
[dispatch, gotoPage]
);
return (
<Component {...props} onDuplicate={onDuplicate} isWriteable={stateFromProps.isWriteable} />
);
};

View file

@ -1,20 +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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
// TODO: We should fully build out this interface for our router
// or switch to a different router that is already typed
interface Router {
navigateTo: (
name: string,
params: Record<string, number | string>,
state?: Record<string, string>
) => void;
}
export const RouterContext = React.createContext<Router | undefined>(undefined);

View file

@ -1,64 +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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { connect } from 'react-redux';
// @ts-expect-error untyped local
import { setFullscreen } from '../../state/actions/transient';
import {
enableAutoplay,
setRefreshInterval,
setAutoplayInterval,
} from '../../state/actions/workpad';
// @ts-expect-error untyped local
import { Router as Component } from './router';
import { State } from '../../../types';
export * from './context';
const mapDispatchToProps = {
enableAutoplay,
setAutoplayInterval,
setFullscreen,
setRefreshInterval,
};
const mapStateToProps = (state: State) => ({
refreshInterval: state.transient.refresh.interval,
autoplayInterval: state.transient.autoplay.interval,
autoplay: state.transient.autoplay.enabled,
fullscreen: state.transient.fullScreen,
});
export const Router = connect(
mapStateToProps,
mapDispatchToProps,
(stateProps, dispatchProps, ownProps) => {
return {
...ownProps,
...dispatchProps,
setRefreshInterval: (interval: number) => {
if (interval !== stateProps.refreshInterval) {
dispatchProps.setRefreshInterval(interval);
}
},
setAutoplayInterval: (interval: number) => {
if (interval !== stateProps.autoplayInterval) {
dispatchProps.setRefreshInterval(interval);
}
},
enableAutoplay: (autoplay: boolean) => {
if (autoplay !== stateProps.autoplay) {
dispatchProps.enableAutoplay(autoplay);
}
},
setFullscreen: (fullscreen: boolean) => {
if (fullscreen !== stateProps.fullscreen) {
dispatchProps.setFullscreen(fullscreen);
}
},
};
}
)(Component);

View file

@ -1,108 +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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import PropTypes from 'prop-types';
import { routerProvider } from '../../lib/router_provider';
import { getAppState } from '../../lib/app_state';
import { getTimeInterval } from '../../lib/time_interval';
import { CanvasLoading } from './canvas_loading';
import { RouterContext } from './';
export class Router extends React.PureComponent {
static propTypes = {
showLoading: PropTypes.bool.isRequired,
onLoad: PropTypes.func.isRequired,
onError: PropTypes.func.isRequired,
routes: PropTypes.array.isRequired,
loadingMessage: PropTypes.string,
onRouteChange: PropTypes.func,
setFullscreen: PropTypes.func.isRequired,
};
static childContextTypes = {
router: PropTypes.object.isRequired,
};
state = {
router: {},
activeComponent: CanvasLoading,
};
getChildContext() {
const { router } = this.state;
return { router };
}
UNSAFE_componentWillMount() {
// routerProvider is a singleton, and will only ever return one instance
const { routes, onRouteChange, onLoad, onError } = this.props;
const router = routerProvider(routes);
let firstLoad = true;
// when the component in the route changes, render it
router.onPathChange((route) => {
const { pathname } = route.location;
const { component } = route.meta;
if (!component) {
// TODO: render some kind of 404 page, maybe from a prop?
if (process.env.NODE_ENV !== 'production') {
console.warn(`No component defined on route: ${route.name}`);
}
return;
}
// if this is the first load, execute the route
if (firstLoad) {
firstLoad = false;
// execute the route
router
.execute()
.then(() => onLoad())
.catch((err) => onError(err));
}
const appState = getAppState();
if (appState.__fullscreen) {
this.props.setFullscreen(appState.__fullscreen);
}
if (appState.__refreshInterval) {
this.props.setRefreshInterval(getTimeInterval(appState.__refreshInterval));
}
if (!!appState.__autoplayInterval) {
this.props.enableAutoplay(true);
this.props.setAutoplayInterval(getTimeInterval(appState.__autoplayInterval));
}
// notify upstream handler of route change
onRouteChange && onRouteChange(pathname);
this.setState({ activeComponent: component });
});
this.setState({ router });
}
render() {
// show loading
if (this.props.showLoading) {
return React.createElement(CanvasLoading, { msg: this.props.loadingMessage });
}
return (
<RouterContext.Provider value={this.state.router}>
<this.state.activeComponent />
</RouterContext.Provider>
);
}
}

View file

@ -5,5 +5,4 @@
* 2.0.
*/
export { routes } from './routes';
export { HomeApp } from './home_app';
export * from './routing_link';

View file

@ -0,0 +1,40 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { FC } from 'react';
import { EuiLink, EuiLinkProps, EuiButtonIcon, EuiButtonIconProps } from '@elastic/eui';
import { useHistory } from 'react-router-dom';
interface RoutingProps {
to: string;
}
type RoutingLinkProps = Omit<EuiLinkProps, 'href' | 'onClick'> & RoutingProps;
export const RoutingLink: FC<RoutingLinkProps> = ({ to, ...rest }) => {
const history = useHistory();
// Generate the correct link href (with basename accounted for)
const href = history.createHref({ pathname: to });
const props = { ...rest, href } as EuiLinkProps;
return <EuiLink {...props} />;
};
type RoutingButtonIconProps = Omit<EuiButtonIconProps, 'href' | 'onClick'> & RoutingProps;
export const RoutingButtonIcon: FC<RoutingButtonIconProps> = ({ to, ...rest }) => {
const history = useHistory();
// Generate the correct link href (with basename accounted for)
const href = history.createHref({ pathname: to });
const props = { ...rest, href } as EuiButtonIconProps;
return <EuiButtonIcon {...props} />;
};

View file

@ -9,7 +9,6 @@ import React, { FC, useState, useContext, useEffect } from 'react';
import PropTypes from 'prop-types';
import {
EuiButtonEmpty,
EuiButtonIcon,
EuiFlexGroup,
EuiFlexItem,
EuiModal,
@ -19,13 +18,15 @@ import {
// @ts-expect-error untyped local
import { WorkpadManager } from '../workpad_manager';
import { RouterContext } from '../router';
import { PageManager } from '../page_manager';
import { Expression } from '../expression';
import { Tray } from './tray';
import { CanvasElement } from '../../../types';
import { ComponentStrings } from '../../../i18n';
import { RoutingButtonIcon } from '../routing';
import { WorkpadRoutingContext } from '../../routes/workpad';
const { Toolbar: strings } = ComponentStrings;
@ -50,7 +51,7 @@ export const Toolbar: FC<Props> = ({
}) => {
const [activeTray, setActiveTray] = useState<TrayType | null>(null);
const [showWorkpadManager, setShowWorkpadManager] = useState(false);
const router = useContext(RouterContext);
const { getUrl, previousPage } = useContext(WorkpadRoutingContext);
// While the tray doesn't get activated if the workpad isn't writeable,
// this effect will ensure that if the tray is open and the workpad
@ -61,20 +62,6 @@ export const Toolbar: FC<Props> = ({
}
}, [isWriteable, activeTray]);
if (!router) {
return <div>{strings.getErrorMessage('Router Undefined')}</div>;
}
const nextPage = () => {
const page = Math.min(selectedPageNumber + 1, totalPages);
router.navigateTo('loadWorkpad', { id: workpadId, page });
};
const previousPage = () => {
const page = Math.max(1, selectedPageNumber - 1);
router.navigateTo('loadWorkpad', { id: workpadId, page });
};
const elementIsSelected = Boolean(selectedElement);
const toggleTray = (tray: TrayType) => {
@ -119,11 +106,11 @@ export const Toolbar: FC<Props> = ({
</EuiFlexItem>
<EuiFlexItem grow={false} />
<EuiFlexItem grow={false}>
<EuiButtonIcon
<RoutingButtonIcon
color="text"
onClick={previousPage}
to={getUrl(selectedPageNumber - 1)}
iconType="arrowLeft"
disabled={selectedPageNumber <= 1}
isDisabled={selectedPageNumber <= 1}
aria-label={strings.getPreviousPageAriaLabel()}
/>
</EuiFlexItem>
@ -133,11 +120,11 @@ export const Toolbar: FC<Props> = ({
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonIcon
<RoutingButtonIcon
color="text"
onClick={nextPage}
to={getUrl(selectedPageNumber + 1)}
iconType="arrowRight"
disabled={selectedPageNumber >= totalPages}
isDisabled={selectedPageNumber >= totalPages}
aria-label={strings.getNextPageAriaLabel()}
/>
</EuiFlexItem>

View file

@ -4,14 +4,13 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useContext, useCallback } from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import { pure, compose, withState, withProps, getContext, withHandlers } from 'recompose';
import { transitionsRegistry } from '../../lib/transitions_registry';
import { undoHistory, redoHistory } from '../../state/actions/history';
import { fetchAllRenderables } from '../../state/actions/elements';
import { setZoomScale, setFullscreen } from '../../state/actions/transient';
import { setZoomScale } from '../../state/actions/transient';
import { getFullscreen, getZoomScale } from '../../state/selectors/app';
import {
getSelectedPageIndex,
@ -22,7 +21,8 @@ import {
import { zoomHandlerCreators } from '../../lib/app_handler_creators';
import { trackCanvasUiMetric, METRIC_TYPE } from '../../lib/ui_metric';
import { LAUNCHED_FULLSCREEN, LAUNCHED_FULLSCREEN_AUTOPLAY } from '../../../common/lib/constants';
import { Workpad as Component } from './workpad';
import { WorkpadRoutingContext } from '../../routes/workpad';
import { Workpad as WorkpadComponent } from './workpad';
const mapStateToProps = (state) => {
const { width, height, id: workpadId, css: workpadCss } = getWorkpad(state);
@ -40,11 +40,8 @@ const mapStateToProps = (state) => {
};
const mapDispatchToProps = {
undoHistory,
redoHistory,
fetchAllRenderables,
setZoomScale,
setFullscreen,
};
const mergeProps = (stateProps, dispatchProps, ownProps) => {
@ -52,19 +49,38 @@ const mergeProps = (stateProps, dispatchProps, ownProps) => {
...ownProps,
...stateProps,
...dispatchProps,
setFullscreen: (value) => {
dispatchProps.setFullscreen(value);
};
};
if (value === true) {
const AddContexts = (props) => {
const { isFullscreen, setFullscreen, undo, redo, autoplayInterval } = useContext(
WorkpadRoutingContext
);
const setFullscreenWithEffect = useCallback(
(fullscreen) => {
setFullscreen(fullscreen);
if (fullscreen === true) {
trackCanvasUiMetric(
METRIC_TYPE.COUNT,
stateProps.autoplayEnabled
autoplayInterval > 0
? [LAUNCHED_FULLSCREEN, LAUNCHED_FULLSCREEN_AUTOPLAY]
: LAUNCHED_FULLSCREEN
);
}
},
};
[setFullscreen, autoplayInterval]
);
return (
<WorkpadComponent
{...props}
setFullscreen={setFullscreenWithEffect}
isFullscreen={isFullscreen}
undoHistory={undo}
redoHistory={redo}
/>
);
};
export const Workpad = compose(
@ -119,4 +135,4 @@ export const Workpad = compose(
},
}),
withHandlers(zoomHandlerCreators)
)(Component);
)(AddContexts);

View file

@ -123,14 +123,6 @@ export class Workpad extends React.PureComponent {
style={fsStyle}
data-shared-items-count={totalElementCount}
>
{isFullscreen && (
<Shortcuts
name="PRESENTATION"
handler={this.keyHandler}
targetNodeSelector="body"
global
/>
)}
{pages.map((page, i) => (
<WorkpadPage
key={page.id}

View file

@ -7,13 +7,13 @@
import React, { FC, MouseEventHandler, useRef } from 'react';
import PropTypes from 'prop-types';
import { Sidebar } from '../../../components/sidebar';
import { Toolbar } from '../../../components/toolbar';
import { Sidebar } from '../../components/sidebar';
import { Toolbar } from '../../components/toolbar';
// @ts-expect-error Untyped local
import { Workpad } from '../../../components/workpad';
import { WorkpadHeader } from '../../../components/workpad_header';
import { CANVAS_LAYOUT_STAGE_CONTENT_SELECTOR } from '../../../../common/lib/constants';
import { CommitFn } from '../../../../types';
import { Workpad } from '../workpad';
import { WorkpadHeader } from '../workpad_header';
import { CANVAS_LAYOUT_STAGE_CONTENT_SELECTOR } from '../../../common/lib/constants';
import { CommitFn } from '../../../types';
export const WORKPAD_CONTAINER_ID = 'canvasWorkpadContainer';

View file

@ -9,12 +9,12 @@ import { MouseEventHandler } from 'react';
import { Dispatch } from 'redux';
import { connect } from 'react-redux';
// @ts-expect-error untyped local
import { selectToplevelNodes } from '../../../state/actions/transient';
import { canUserWrite } from '../../../state/selectors/app';
import { getWorkpad, isWriteable } from '../../../state/selectors/workpad';
import { selectToplevelNodes } from '../../state/actions/transient';
import { canUserWrite } from '../../state/selectors/app';
import { getWorkpad, isWriteable } from '../../state/selectors/workpad';
import { WorkpadApp as Component } from './workpad_app.component';
import { withElementsLoadedTelemetry } from './workpad_telemetry';
import { State } from '../../../../types';
import { State } from '../../../types';
export { WORKPAD_CONTAINER_ID } from './workpad_app.component';

View file

@ -12,8 +12,8 @@ import {
WorkpadLoadedMetric,
WorkpadLoadedWithErrorsMetric,
} from './workpad_telemetry';
import { METRIC_TYPE } from '../../../lib/ui_metric';
import { ResolvedArgType } from '../../../../types';
import { METRIC_TYPE } from '../../lib/ui_metric';
import { ResolvedArgType } from '../../../types';
const trackMetric = jest.fn();
const Component = withUnconnectedElementsLoadedTelemetry(() => <div />, trackMetric);

View file

@ -7,9 +7,9 @@
import React, { useState, useEffect } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { trackCanvasUiMetric, METRIC_TYPE } from '../../../lib/ui_metric';
import { getElementCounts } from '../../../state/selectors/workpad';
import { getArgs } from '../../../state/selectors/resolved_args';
import { trackCanvasUiMetric, METRIC_TYPE } from '../../lib/ui_metric';
import { getElementCounts } from '../../state/selectors/workpad';
import { getArgs } from '../../state/selectors/resolved_args';
const WorkpadLoadedMetric = 'workpad-loaded';
const WorkpadLoadedWithErrorsMetric = 'workpad-loaded-with-errors';

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import React, { FC, useContext } from 'react';
import { connect } from 'react-redux';
import { compose, withHandlers, withProps } from 'recompose';
import { Dispatch } from 'redux';
@ -35,6 +36,7 @@ import {
alignmentDistributionHandlerCreators,
} from '../../../lib/element_handler_creators';
import { EditMenu as Component, Props as ComponentProps } from './edit_menu.component';
import { WorkpadRoutingContext } from '../../../routes/workpad';
type LayoutState = any;
@ -102,8 +104,6 @@ const mapDispatchToProps = (dispatch: Dispatch) => ({
elementLayer: (pageId: string, elementId: string, movement: number) => {
dispatch(elementLayer({ pageId, elementId, movement }));
},
undoHistory: () => dispatch(undoHistory()),
redoHistory: () => dispatch(redoHistory()),
dispatch,
});
@ -123,6 +123,12 @@ const mergeProps = (
};
};
export const EditMenuWithContext: FC<ComponentProps> = (props) => {
const { undo, redo } = useContext(WorkpadRoutingContext);
return <Component {...props} undoHistory={undo} redoHistory={redo} />;
};
export const EditMenu = compose<ComponentProps, OwnProps>(
connect(mapStateToProps, mapDispatchToProps, mergeProps),
withProps(() => ({ hasPasteData: Boolean(getClipboardData()) })),
@ -131,4 +137,4 @@ export const EditMenu = compose<ComponentProps, OwnProps>(
withHandlers(layerHandlerCreators),
withHandlers(groupHandlerCreators),
withHandlers(alignmentDistributionHandlerCreators)
)(Component);
)(EditMenuWithContext);

View file

@ -21,7 +21,7 @@ interface Props {
setFullscreen: (fullscreen: boolean) => void;
autoplayEnabled: boolean;
enableAutoplay: (autoplay: boolean) => void;
toggleAutoplay: () => void;
onPageChange: (pageNumber: number) => void;
previousPage: () => void;
@ -39,19 +39,37 @@ export class FullscreenControl extends React.PureComponent<Props> {
children: PropTypes.func.isRequired,
};
/*
We need these instance functions because ReactShortcuts bind the handlers on it's mount,
but then does no rebinding if it's props change. Using these instance functions will
properly handle changes to incoming props since the instance functions are bound to the components
"this" context
*/
_toggleFullscreen = () => {
const { setFullscreen, isFullscreen } = this.props;
setFullscreen(!isFullscreen);
};
toggleAutoplay = () => {
this.props.toggleAutoplay();
};
nextPage = () => {
this.props.nextPage();
};
previousPage = () => {
this.props.previousPage();
};
// handle keypress events for presentation events
_keyMap: { [key: string]: (...args: any[]) => void } = {
REFRESH: this.props.fetchAllRenderables,
PREV: this.props.previousPage,
NEXT: this.props.nextPage,
PREV: this.previousPage,
NEXT: this.nextPage,
FULLSCREEN: this._toggleFullscreen,
FULLSCREEN_EXIT: this._toggleFullscreen,
PAGE_CYCLE_TOGGLE: () => this.props.enableAutoplay(!this.props.autoplayEnabled),
PAGE_CYCLE_TOGGLE: this.toggleAutoplay,
};
_keyHandler = (action: string, event: KeyboardEvent) => {

View file

@ -5,18 +5,12 @@
* 2.0.
*/
import { connect } from 'react-redux';
import React, { useContext, useCallback } from 'react';
import { connect, useDispatch } from 'react-redux';
import PropTypes from 'prop-types';
import { withState, withProps, withHandlers, compose, getContext } from 'recompose';
import { setFullscreen, selectToplevelNodes } from '../../../state/actions/transient';
import { enableAutoplay } from '../../../state/actions/workpad';
import { getFullscreen } from '../../../state/selectors/app';
import {
getAutoplay,
getSelectedPageIndex,
getPages,
getWorkpad,
} from '../../../state/selectors/workpad';
import { selectToplevelNodes } from '../../../state/actions/transient';
import { getSelectedPageIndex, getPages, getWorkpad } from '../../../state/selectors/workpad';
import { trackCanvasUiMetric, METRIC_TYPE } from '../../../lib/ui_metric';
import {
LAUNCHED_FULLSCREEN,
@ -24,6 +18,7 @@ import {
} from '../../../../common/lib/constants';
import { transitionsRegistry } from '../../../lib/transitions_registry';
import { fetchAllRenderables } from '../../../state/actions/elements';
import { WorkpadRoutingContext } from '../../../routes/workpad/workpad_routing_context';
import { FullscreenControl as Component } from './fullscreen_control';
// TODO: a lot of this is borrowed code from `/components/workpad/index.js`.
@ -32,44 +27,65 @@ const mapStateToProps = (state) => ({
workpadId: getWorkpad(state).id,
pages: getPages(state),
selectedPageNumber: getSelectedPageIndex(state) + 1,
isFullscreen: getFullscreen(state),
autoplayEnabled: getAutoplay(state).enabled,
});
const mapDispatchToProps = (dispatch) => ({
setFullscreen: (value) => {
dispatch(setFullscreen(value));
value && dispatch(selectToplevelNodes([]));
},
enableAutoplay: (enabled) => dispatch(enableAutoplay(enabled)),
fetchAllRenderables: () => dispatch(fetchAllRenderables()),
});
const mergeProps = (stateProps, dispatchProps, ownProps) => {
return {
...ownProps,
...stateProps,
...dispatchProps,
setFullscreen: (value) => {
dispatchProps.setFullscreen(value);
export const FullscreenControlWithContext = (props) => {
const {
isFullscreen,
autoplayInterval,
nextPage,
previousPage,
setFullscreen,
setIsAutoplayPaused,
isAutoplayPaused,
} = useContext(WorkpadRoutingContext);
const autoplayEnabled = autoplayInterval > 0 ? true : false;
const dispatch = useDispatch();
const setFullscreenWithEffects = useCallback(
(value) => {
value && dispatch(selectToplevelNodes([]));
setFullscreen(value);
if (value === true) {
trackCanvasUiMetric(
METRIC_TYPE.COUNT,
stateProps.autoplayEnabled
autoplayEnabled
? [LAUNCHED_FULLSCREEN, LAUNCHED_FULLSCREEN_AUTOPLAY]
: LAUNCHED_FULLSCREEN
);
}
},
};
[dispatch, setFullscreen, autoplayEnabled]
);
const toggleAutoplay = useCallback(() => {
setIsAutoplayPaused(!isAutoplayPaused);
}, [setIsAutoplayPaused, isAutoplayPaused]);
return (
<Component
isFullscreen={isFullscreen}
nextPage={nextPage}
previousPage={previousPage}
autoplayEnabled={autoplayEnabled}
setFullscreen={setFullscreenWithEffects}
toggleAutoplay={toggleAutoplay}
{...props}
/>
);
};
export const FullscreenControl = compose(
getContext({
router: PropTypes.object,
}),
connect(mapStateToProps, mapDispatchToProps, mergeProps),
connect(mapStateToProps, mapDispatchToProps),
withState('transition', 'setTransition', null),
withState('prevSelectedPageNumber', 'setPrevSelectedPageNumber', 0),
withProps(({ selectedPageNumber, prevSelectedPageNumber, transition }) => {
@ -89,29 +105,7 @@ export const FullscreenControl = compose(
return { getAnimation };
}),
withHandlers({
onPageChange: (props) => (pageNumber) => {
if (pageNumber === props.selectedPageNumber) {
return;
}
props.setPrevSelectedPageNumber(props.selectedPageNumber);
const transitionPage = Math.max(props.selectedPageNumber, pageNumber) - 1;
const { transition } = props.pages[transitionPage];
if (transition) {
props.setTransition(transition);
}
props.router.navigateTo('loadWorkpad', { id: props.workpadId, page: pageNumber });
},
}),
withHandlers({
onTransitionEnd: ({ setTransition }) => () => setTransition(null),
nextPage: (props) => () => {
const pageNumber = Math.min(props.selectedPageNumber + 1, props.pages.length);
props.onPageChange(pageNumber);
},
previousPage: (props) => () => {
const pageNumber = Math.max(1, props.selectedPageNumber - 1);
props.onPageChange(pageNumber);
},
})
)(Component);
)(FullscreenControlWithContext);

View file

@ -32,7 +32,7 @@ const { getSecondsText, getMinutesText, getHoursText } = timeStrings;
interface Props {
refreshInterval: number;
setRefresh: (interval: number | undefined) => void;
setRefresh: (interval: number) => void;
disableInterval: () => void;
}

View file

@ -5,13 +5,15 @@
* 2.0.
*/
import React, { ReactNode } from 'react';
import React, { ReactNode, useCallback } from 'react';
import PropTypes from 'prop-types';
import {
EuiButtonIcon,
EuiDescriptionList,
EuiDescriptionListDescription,
EuiDescriptionListTitle,
EuiTitle,
EuiToolTip,
EuiHorizontalRule,
EuiLink,
EuiSpacer,
@ -30,7 +32,7 @@ const { getSecondsText, getMinutesText } = timeStrings;
interface Props {
autoplayInterval: number;
onSetInterval: (interval: number | undefined) => void;
onSetInterval: (interval: number) => void;
}
interface ListGroupProps {
@ -53,6 +55,10 @@ const ListGroup = ({ children, ...rest }: ListGroupProps) => (
const generateId = htmlIdGenerator();
export const KioskControls = ({ autoplayInterval, onSetInterval }: Props) => {
const disableAutoplay = useCallback(() => {
onSetInterval(0);
}, [onSetInterval]);
const RefreshItem = ({ duration, label, descriptionId }: RefreshItemProps) => (
<li>
<EuiLink onClick={() => onSetInterval(duration)} aria-describedby={descriptionId}>
@ -71,12 +77,37 @@ export const KioskControls = ({ autoplayInterval, onSetInterval }: Props) => {
className="canvasViewMenu__kioskSettings"
>
<EuiFlexItem grow={false}>
<EuiDescriptionList textStyle="reverse">
<EuiDescriptionListTitle>{strings.getTitle()}</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
{timeStrings.getCycleTimeText(interval.length, interval.format)}
</EuiDescriptionListDescription>
</EuiDescriptionList>
<EuiFlexGroup alignItems="center" justifyContent="spaceAround" gutterSize="xs">
<EuiFlexItem>
<EuiDescriptionList textStyle="reverse">
<EuiDescriptionListTitle>{strings.getTitle()}</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
{autoplayInterval > 0 ? (
<>{timeStrings.getCycleTimeText(interval.length, interval.format)}</>
) : (
<>{strings.getAutoplayListDurationManualText()}</>
)}
</EuiDescriptionListDescription>
</EuiDescriptionList>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup justifyContent="flexEnd" gutterSize="xs">
{autoplayInterval > 0 ? (
<EuiFlexItem grow={false}>
<EuiToolTip position="bottom" content={strings.getDisableTooltip()}>
<EuiButtonIcon
iconType="cross"
onClick={disableAutoplay}
aria-label={strings.getDisableTooltip()}
/>
</EuiToolTip>
</EuiFlexItem>
) : null}
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
<EuiHorizontalRule margin="m" />
<EuiTitle size="xxxs" id={intervalTitleId}>
<p>{strings.getCycleFormLabel()}</p>

View file

@ -76,7 +76,7 @@ export interface Props {
/**
* Sets auto refresh interval
*/
setRefreshInterval: (interval?: number) => void;
setRefreshInterval: (interval: number) => void;
/**
* Is autoplay enabled?
*/
@ -92,7 +92,7 @@ export interface Props {
/**
* Sets autoplay interval
*/
setAutoplayInterval: (interval?: number) => void;
setAutoplayInterval: (interval: number) => void;
}
export const ViewMenu: FunctionComponent<Props> = ({
@ -113,7 +113,7 @@ export const ViewMenu: FunctionComponent<Props> = ({
enableAutoplay,
setAutoplayInterval,
}) => {
const setRefresh = (val: number | undefined) => setRefreshInterval(val);
const setRefresh = (val: number) => setRefreshInterval(val);
const disableInterval = () => {
setRefresh(0);
@ -196,16 +196,6 @@ export const ViewMenu: FunctionComponent<Props> = ({
closePopover();
},
},
{
name: autoplayEnabled
? strings.getAutoplayOffMenuItemLabel()
: strings.getAutoplayOnMenuItemLabel(),
icon: autoplayEnabled ? 'stop' : 'play',
onClick: () => {
enableAutoplay(!autoplayEnabled);
closePopover();
},
},
{
name: strings.getAutoplaySettingsMenuItemLabel(),
icon: 'empty',

View file

@ -4,8 +4,8 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { connect } from 'react-redux';
import React, { FC, useCallback, useContext } from 'react';
import { connect, useDispatch } from 'react-redux';
import { compose, withHandlers } from 'recompose';
import { Dispatch } from 'redux';
import { zoomHandlerCreators } from '../../../lib/app_handler_creators';
@ -13,22 +13,16 @@ import { State, CanvasWorkpadBoundingBox } from '../../../../types';
// @ts-expect-error untyped local
import { fetchAllRenderables } from '../../../state/actions/elements';
// @ts-expect-error untyped local
import { setZoomScale, setFullscreen, selectToplevelNodes } from '../../../state/actions/transient';
import {
setWriteable,
setRefreshInterval,
enableAutoplay,
setAutoplayInterval,
} from '../../../state/actions/workpad';
import { setZoomScale, selectToplevelNodes } from '../../../state/actions/transient';
import { setWriteable } from '../../../state/actions/workpad';
import { getZoomScale, canUserWrite } from '../../../state/selectors/app';
import {
getWorkpadBoundingBox,
getWorkpadWidth,
getWorkpadHeight,
isWriteable,
getRefreshInterval,
getAutoplay,
} from '../../../state/selectors/workpad';
import { WorkpadRoutingContext } from '../../../routes/workpad';
import { ViewMenu as Component, Props as ComponentProps } from './view_menu.component';
import { getFitZoomScale } from './lib/get_fit_zoom_scale';
@ -43,38 +37,31 @@ interface StateProps {
interface DispatchProps {
setWriteable: (isWorkpadWriteable: boolean) => void;
setZoomScale: (scale: number) => void;
setFullscreen: (showFullscreen: boolean) => void;
doRefresh: () => void;
}
const mapStateToProps = (state: State) => {
const { enabled, interval } = getAutoplay(state);
type PropsFromContext =
| 'enterFullscreen'
| 'setAutoplayInterval'
| 'autoplayEnabled'
| 'autoplayInterval'
| 'setRefreshInterval'
| 'refreshInterval';
const mapStateToProps = (state: State) => {
return {
zoomScale: getZoomScale(state),
boundingBox: getWorkpadBoundingBox(state),
workpadWidth: getWorkpadWidth(state),
workpadHeight: getWorkpadHeight(state),
isWriteable: isWriteable(state) && canUserWrite(state),
refreshInterval: getRefreshInterval(state),
autoplayEnabled: enabled,
autoplayInterval: interval,
};
};
const mapDispatchToProps = (dispatch: Dispatch) => ({
setZoomScale: (scale: number) => dispatch(setZoomScale(scale)),
setWriteable: (isWorkpadWriteable: boolean) => dispatch(setWriteable(isWorkpadWriteable)),
setFullscreen: (value: boolean) => {
dispatch(setFullscreen(value));
if (value) {
dispatch(selectToplevelNodes([]));
}
},
doRefresh: () => dispatch(fetchAllRenderables()),
setRefreshInterval: (interval: number) => dispatch(setRefreshInterval(interval)),
enableAutoplay: (autoplay: number) => dispatch(enableAutoplay(!!autoplay)),
setAutoplayInterval: (interval: number) => dispatch(setAutoplayInterval(interval)),
});
const mergeProps = (
@ -89,13 +76,40 @@ const mergeProps = (
...dispatchProps,
...ownProps,
toggleWriteable: () => dispatchProps.setWriteable(!stateProps.isWriteable),
enterFullscreen: () => dispatchProps.setFullscreen(true),
fitToWindow: () =>
dispatchProps.setZoomScale(getFitZoomScale(boundingBox, workpadWidth, workpadHeight)),
};
};
export const ViewMenu = compose<ComponentProps, {}>(
const ViewMenuWithContext: FC<Omit<ComponentProps, PropsFromContext>> = (props) => {
const dispatch = useDispatch();
const {
autoplayInterval,
setAutoplayInterval,
setFullscreen,
setRefreshInterval,
refreshInterval,
} = useContext(WorkpadRoutingContext);
const enterFullscreen = useCallback(() => {
dispatch(selectToplevelNodes([]));
setFullscreen(true);
}, [dispatch, setFullscreen]);
return (
<Component
{...props}
enterFullscreen={enterFullscreen}
setAutoplayInterval={setAutoplayInterval}
autoplayEnabled={true}
autoplayInterval={autoplayInterval}
setRefreshInterval={setRefreshInterval}
refreshInterval={refreshInterval}
/>
);
};
export const ViewMenu = compose<Omit<ComponentProps, PropsFromContext>, {}>(
connect(mapStateToProps, mapDispatchToProps, mergeProps),
withHandlers(zoomHandlerCreators)
)(Component);
)(ViewMenuWithContext);

View file

@ -1,145 +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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { compose, withState, getContext, withHandlers, withProps } from 'recompose';
import moment from 'moment';
import * as workpadService from '../../lib/workpad_service';
import { canUserWrite } from '../../state/selectors/app';
import { getWorkpad } from '../../state/selectors/workpad';
import { getId } from '../../lib/get_id';
import { downloadWorkpad } from '../../lib/download_workpad';
import { ComponentStrings, ErrorStrings } from '../../../i18n';
import { withServices } from '../../services';
import { WorkpadLoader as Component } from './workpad_loader';
const { WorkpadLoader: strings } = ComponentStrings;
const { WorkpadLoader: errors } = ErrorStrings;
const mapStateToProps = (state) => ({
workpadId: getWorkpad(state).id,
canUserWrite: canUserWrite(state),
});
export const WorkpadLoader = compose(
getContext({
router: PropTypes.object,
}),
connect(mapStateToProps),
withState('workpads', 'setWorkpads', null),
withServices,
withProps(({ services }) => ({
notify: services.notify,
})),
withHandlers(({ services }) => ({
// Workpad creation via navigation
createWorkpad: (props) => async (workpad) => {
// workpad data uploaded, create and load it
if (workpad != null) {
try {
await workpadService.create(workpad);
props.router.navigateTo('loadWorkpad', { id: workpad.id, page: 1 });
} catch (err) {
services.notify.error(err, {
title: errors.getUploadFailureErrorMessage(),
});
}
return;
}
props.router.navigateTo('createWorkpad');
},
// Workpad search
findWorkpads: ({ setWorkpads }) => async (text) => {
try {
const workpads = await workpadService.find(text);
setWorkpads(workpads);
} catch (err) {
services.notify.error(err, { title: errors.getFindFailureErrorMessage() });
}
},
// Workpad import/export methods
downloadWorkpad: () => (workpadId) => downloadWorkpad(workpadId),
// Clone workpad given an id
cloneWorkpad: (props) => async (workpadId) => {
try {
const workpad = await workpadService.get(workpadId);
workpad.name = strings.getClonedWorkpadName(workpad.name);
workpad.id = getId('workpad');
await workpadService.create(workpad);
props.router.navigateTo('loadWorkpad', { id: workpad.id, page: 1 });
} catch (err) {
services.notify.error(err, { title: errors.getCloneFailureErrorMessage() });
}
},
// Remove workpad given an array of id
removeWorkpads: (props) => async (workpadIds) => {
const { setWorkpads, workpads, workpadId: loadedWorkpad } = props;
const removeWorkpads = workpadIds.map((id) =>
workpadService
.remove(id)
.then(() => ({ id, err: null }))
.catch((err) => ({
id,
err,
}))
);
return Promise.all(removeWorkpads).then((results) => {
let redirectHome = false;
const [passes, errored] = results.reduce(
([passes, errors], result) => {
if (result.id === loadedWorkpad && !result.err) {
redirectHome = true;
}
if (result.err) {
errors.push(result.id);
} else {
passes.push(result.id);
}
return [passes, errors];
},
[[], []]
);
const remainingWorkpads = workpads.workpads.filter(({ id }) => !passes.includes(id));
const workpadState = {
total: remainingWorkpads.length,
workpads: remainingWorkpads,
};
if (errored.length > 0) {
services.notify.error(errors.getDeleteFailureErrorMessage());
}
setWorkpads(workpadState);
if (redirectHome) {
props.router.navigateTo('home');
}
return errored.map(({ id }) => id);
});
},
})),
withProps((props) => ({
formatDate: (date) => {
const dateFormat = props.services.platform.getUISetting('dateFormat');
return date && moment(date).format(dateFormat);
},
}))
)(Component);

View file

@ -0,0 +1,173 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { FC, useState, useCallback } from 'react';
import { useHistory } from 'react-router-dom';
import { useSelector } from 'react-redux';
import moment from 'moment';
// @ts-expect-error
import { getDefaultWorkpad } from '../../state/defaults';
import { canUserWrite as canUserWriteSelector } from '../../state/selectors/app';
import { getWorkpad } from '../../state/selectors/workpad';
import { getId } from '../../lib/get_id';
import { downloadWorkpad } from '../../lib/download_workpad';
import { ComponentStrings, ErrorStrings } from '../../../i18n';
import { State, CanvasWorkpad } from '../../../types';
import { useNotifyService, useWorkpadService, usePlatformService } from '../../services';
// @ts-expect-error
import { WorkpadLoader as Component } from './workpad_loader';
const { WorkpadLoader: strings } = ComponentStrings;
const { WorkpadLoader: errors } = ErrorStrings;
type WorkpadStatePromise = ReturnType<ReturnType<typeof useWorkpadService>['find']>;
type WorkpadState = WorkpadStatePromise extends PromiseLike<infer U> ? U : never;
export const WorkpadLoader: FC<{ onClose: () => void }> = ({ onClose }) => {
const fromState = useSelector((state: State) => ({
workpadId: getWorkpad(state).id,
canUserWrite: canUserWriteSelector(state),
}));
const [workpadsState, setWorkpadsState] = useState<WorkpadState | null>(null);
const workpadService = useWorkpadService();
const notifyService = useNotifyService();
const platformService = usePlatformService();
const history = useHistory();
const createWorkpad = useCallback(
async (_workpad: CanvasWorkpad | null | undefined) => {
const workpad = _workpad || getDefaultWorkpad();
if (workpad != null) {
try {
await workpadService.create(workpad);
history.push(`/workpad/${workpad.id}/page/1`);
} catch (err) {
notifyService.error(err, {
title: errors.getUploadFailureErrorMessage(),
});
}
return;
}
},
[workpadService, notifyService, history]
);
const findWorkpads = useCallback(
async (text) => {
try {
const fetchedWorkpads = await workpadService.find(text);
setWorkpadsState(fetchedWorkpads);
} catch (err) {
notifyService.error(err, { title: errors.getFindFailureErrorMessage() });
}
},
[notifyService, workpadService]
);
const onDownloadWorkpad = useCallback((workpadId: string) => downloadWorkpad(workpadId), []);
const cloneWorkpad = useCallback(
async (workpadId: string) => {
try {
const workpad = await workpadService.get(workpadId);
workpad.name = strings.getClonedWorkpadName(workpad.name);
workpad.id = getId('workpad');
await workpadService.create(workpad);
history.push(`/workpad/${workpad.id}/page/1`);
} catch (err) {
notifyService.error(err, { title: errors.getCloneFailureErrorMessage() });
}
},
[notifyService, workpadService, history]
);
const removeWorkpads = useCallback(
(workpadIds: string[]) => {
if (workpadsState === null) {
return;
}
const removedWorkpads = workpadIds.map(async (id) => {
try {
await workpadService.remove(id);
return { id, err: null };
} catch (err) {
return { id, err };
}
});
return Promise.all(removedWorkpads).then((results) => {
let redirectHome = false;
const [passes, errored] = results.reduce<[string[], string[]]>(
([passesArr, errorsArr], result) => {
if (result.id === fromState.workpadId && !result.err) {
redirectHome = true;
}
if (result.err) {
errorsArr.push(result.id);
} else {
passesArr.push(result.id);
}
return [passesArr, errorsArr];
},
[[], []]
);
const remainingWorkpads = workpadsState.workpads.filter(({ id }) => !passes.includes(id));
const workpadState = {
total: remainingWorkpads.length,
workpads: remainingWorkpads,
};
if (errored.length > 0) {
notifyService.error(errors.getDeleteFailureErrorMessage());
}
setWorkpadsState(workpadState);
if (redirectHome) {
history.push('/');
}
return errored;
});
},
[history, workpadService, fromState.workpadId, workpadsState, notifyService]
);
const formatDate = useCallback(
(date: any) => {
const dateFormat = platformService.getUISetting('dateFormat');
return date && moment(date).format(dateFormat);
},
[platformService]
);
const { workpadId, canUserWrite } = fromState;
return (
<Component
{...{
downloadWorkpad: onDownloadWorkpad,
workpads: workpadsState,
workpadId,
canUserWrite,
cloneWorkpad,
createWorkpad,
findWorkpads,
removeWorkpads,
formatDate,
onClose,
}}
/>
);
};

View file

@ -22,7 +22,7 @@ import {
} from '@elastic/eui';
import { orderBy } from 'lodash';
import { ConfirmModal } from '../confirm_modal';
import { Link } from '../link';
import { RoutingLink } from '../routing';
import { Paginate } from '../paginate';
import { ComponentStrings } from '../../../i18n';
import { WorkpadDropzone } from './workpad_dropzone';
@ -186,14 +186,13 @@ export class WorkpadLoader extends React.PureComponent {
const workpadName = getDisplayName(name, workpad, loadedWorkpad);
return (
<Link
<RoutingLink
data-test-subj="canvasWorkpadLoaderWorkpad"
name="loadWorkpad"
params={{ id: workpad.id }}
to={`/workpad/${workpad.id}`}
aria-label={strings.getLoadWorkpadArialLabel()}
>
{workpadName}
</Link>
</RoutingLink>
);
},
},

View file

@ -5,13 +5,15 @@
* 2.0.
*/
import React, { useContext } from 'react';
import isEqual from 'react-fast-compare';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import { branch, compose, shouldUpdate, withProps } from 'recompose';
import { canUserWrite, getFullscreen } from '../../state/selectors/app';
import { canUserWrite } from '../../state/selectors/app';
import { getNodes, getPageById, isWriteable } from '../../state/selectors/workpad';
import { not } from '../../lib/aeroelastic/functional';
import { WorkpadRoutingContext } from '../../routes/workpad';
import { StaticPage } from './workpad_static_page';
import { InteractivePage } from './workpad_interactive_page';
@ -26,19 +28,25 @@ const animationProps = ({ animation, isSelected }) =>
}
: { className: isSelected ? 'isActive' : 'isInactive', animationStyle: {} };
const mapStateToProps = (state, { isSelected, pageId }) => ({
isInteractive: isSelected && !getFullscreen(state) && isWriteable(state) && canUserWrite(state),
const mapStateToProps = (state, { isSelected, pageId, isFullscreen }) => ({
isInteractive: isSelected && !isFullscreen && isWriteable(state) && canUserWrite(state),
elements: getNodes(state, pageId),
pageStyle: getPageById(state, pageId).style,
});
export const WorkpadPage = compose(
export const ComposedWorkpadPage = compose(
shouldUpdate(not(isEqual)), // this is critical, else random unrelated rerenders in the parent cause glitches here
withProps(animationProps),
connect(mapStateToProps),
branch(({ isInteractive }) => isInteractive, InteractivePage, StaticPage)
)();
export const WorkpadPage = (props) => {
const { isFullscreen } = useContext(WorkpadRoutingContext);
return <ComposedWorkpadPage {...props} isFullscreen={isFullscreen} />;
};
WorkpadPage.propTypes = {
pageId: PropTypes.string.isRequired,
};

View file

@ -7,7 +7,7 @@
import React, { CSSProperties, PureComponent } from 'react';
// @ts-expect-error untyped local
import { WORKPAD_CONTAINER_ID } from '../../../apps/workpad/workpad_app';
import { WORKPAD_CONTAINER_ID } from '../../workpad_app';
interface State {
height: string;

View file

@ -5,9 +5,10 @@
* 2.0.
*/
import React, { useContext, useState, useEffect, FunctionComponent } from 'react';
import React, { useCallback, useState, useEffect, FunctionComponent } from 'react';
import { EuiLoadingSpinner } from '@elastic/eui';
import { RouterContext } from '../router';
import { useHistory } from 'react-router-dom';
import { ComponentStrings } from '../../../i18n/components';
// @ts-expect-error
import * as workpadService from '../../lib/workpad_service';
@ -15,7 +16,7 @@ import { WorkpadTemplates as Component } from './workpad_templates';
import { CanvasTemplate } from '../../../types';
import { list } from '../../lib/template_service';
import { applyTemplateStrings } from '../../../i18n/templates/apply_strings';
import { useNotifyService } from '../../services';
import { useNotifyService, useServices } from '../../services';
interface WorkpadTemplatesProps {
onClose: () => void;
@ -28,7 +29,9 @@ const Creating: FunctionComponent<{ name: string }> = ({ name }) => (
</div>
);
export const WorkpadTemplates: FunctionComponent<WorkpadTemplatesProps> = ({ onClose }) => {
const router = useContext(RouterContext);
const history = useHistory();
const services = useServices();
const [templates, setTemplates] = useState<CanvasTemplate[] | undefined>(undefined);
const [creatingFromTemplateName, setCreatingFromTemplateName] = useState<string | undefined>(
undefined
@ -53,20 +56,21 @@ export const WorkpadTemplates: FunctionComponent<WorkpadTemplatesProps> = ({ onC
}, {});
}
const createFromTemplate = async (template: CanvasTemplate) => {
setCreatingFromTemplateName(template.name);
try {
const result = await workpadService.createFromTemplate(template.id);
if (router) {
router.navigateTo('loadWorkpad', { id: result.data.id, page: 1 });
const createFromTemplate = useCallback(
async (template: CanvasTemplate) => {
setCreatingFromTemplateName(template.name);
try {
const result = await services.workpad.createFromTemplate(template.id);
history.push(`/workpad/${result.id}/page/1`);
} catch (e) {
setCreatingFromTemplateName(undefined);
error(e, {
title: `Couldn't create workpad from template`,
});
}
} catch (e) {
setCreatingFromTemplateName(undefined);
error(e, {
title: `Couldn't create workpad from template`,
});
}
};
},
[services.workpad, error, history]
);
if (creatingFromTemplateName) {
return <Creating name={creatingFromTemplateName} />;

View file

@ -1,124 +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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { parse } from 'query-string';
import { get } from 'lodash';
// @ts-expect-error untyped local
import { getInitialState } from '../state/initial_state';
import { getWindow } from './get_window';
// @ts-expect-error untyped local
import { historyProvider } from './history_provider';
// @ts-expect-error untyped local
import { routerProvider } from './router_provider';
import { createTimeInterval, isValidTimeInterval, getTimeInterval } from './time_interval';
import { AppState, AppStateKeys } from '../../types';
export function getDefaultAppState(): AppState {
const transientState = getInitialState('transient');
const state: AppState = {};
if (transientState.fullscreen) {
state[AppStateKeys.FULLSCREEN] = true;
}
if (transientState.refresh.interval > 0) {
state[AppStateKeys.REFRESH_INTERVAL] = createTimeInterval(transientState.refresh.interval);
}
if (transientState.autoplay.enabled) {
state[AppStateKeys.AUTOPLAY_INTERVAL] = createTimeInterval(transientState.autoplay.interval);
}
return state;
}
export function getCurrentAppState(): AppState {
const history = historyProvider(getWindow());
const { search } = history.getLocation();
const qs = !!search ? parse(search.replace(/^\?/, ''), { sort: false }) : {};
const appState = assignAppState({}, qs);
return appState;
}
export function getAppState(key?: string): AppState {
const appState = { ...getDefaultAppState(), ...getCurrentAppState() };
return key ? get(appState, key) : appState;
}
export function assignAppState(obj: AppState & { [key: string]: any }, appState: AppState) {
const fullscreen = appState[AppStateKeys.FULLSCREEN];
const refreshKey = appState[AppStateKeys.REFRESH_INTERVAL];
const autoplayKey = appState[AppStateKeys.AUTOPLAY_INTERVAL];
if (fullscreen) {
obj[AppStateKeys.FULLSCREEN] = true;
} else {
delete obj[AppStateKeys.FULLSCREEN];
}
const refresh = Array.isArray(refreshKey) ? refreshKey[0] : refreshKey;
if (refresh && isValidTimeInterval(refresh)) {
obj[AppStateKeys.REFRESH_INTERVAL] = refresh;
} else {
delete obj[AppStateKeys.REFRESH_INTERVAL];
}
const autoplay = Array.isArray(autoplayKey) ? autoplayKey[0] : autoplayKey;
if (autoplay && isValidTimeInterval(autoplay)) {
obj[AppStateKeys.AUTOPLAY_INTERVAL] = autoplay;
} else {
delete obj[AppStateKeys.AUTOPLAY_INTERVAL];
}
return obj;
}
export function setFullscreen(payload: boolean) {
const appState = getAppState();
const appValue = appState[AppStateKeys.FULLSCREEN];
if (payload === false && appValue) {
delete appState[AppStateKeys.FULLSCREEN];
routerProvider().updateAppState(appState);
} else if (payload === true && !appValue) {
appState[AppStateKeys.FULLSCREEN] = true;
routerProvider().updateAppState(appState);
}
}
export function setAutoplayInterval(payload: string | null) {
const appState = getAppState();
const appValue = appState[AppStateKeys.AUTOPLAY_INTERVAL];
if (payload !== appValue) {
if (!payload && appValue) {
delete appState[AppStateKeys.AUTOPLAY_INTERVAL];
routerProvider().updateAppState(appState);
} else if (payload) {
appState[AppStateKeys.AUTOPLAY_INTERVAL] = payload;
routerProvider().updateAppState(appState);
}
}
}
export function setRefreshInterval(payload: string) {
const appState = getAppState();
const appValue = appState[AppStateKeys.REFRESH_INTERVAL];
if (payload !== appValue) {
if (getTimeInterval(payload)) {
appState[AppStateKeys.REFRESH_INTERVAL] = payload;
routerProvider().updateAppState(appState);
} else {
delete appState[AppStateKeys.REFRESH_INTERVAL];
routerProvider().updateAppState(appState);
}
}
}

View file

@ -6,7 +6,6 @@
*/
import { ChromeBreadcrumb } from '../../../../../src/core/public';
import { platformService } from '../services';
export const getBaseBreadcrumb = () => ({
text: 'Canvas',
@ -23,7 +22,3 @@ export const getWorkpadBreadcrumb = ({
}
return output;
};
export const setBreadcrumb = (paths: ChromeBreadcrumb | ChromeBreadcrumb[]) => {
platformService.getService().setBreadcrumbs(Array.isArray(paths) ? paths : [paths]);
};

View file

@ -1,173 +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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import lzString from 'lz-string';
import { createMemoryHistory, parsePath, createPath } from 'history';
import createHashStateHistory from 'history-extra/dist/createHashStateHistory';
import { getWindow } from './get_window';
function wrapHistoryInstance(history) {
const historyState = {
onChange: [],
prevLocation: {},
changeUnlisten: null,
};
const locationFormat = (location, action, parser) => ({
pathname: location.pathname,
hash: location.hash,
search: location.search,
state: parser(location.state),
action: action.toLowerCase(),
});
const wrappedHistory = {
undo() {
history.goBack();
},
redo() {
history.goForward();
},
go(idx) {
history.go(idx);
},
parse(payload) {
try {
const stateJSON = lzString.decompress(payload);
return JSON.parse(stateJSON);
} catch (e) {
return null;
}
},
encode(state) {
try {
const stateJSON = JSON.stringify(state);
return lzString.compress(stateJSON);
} catch (e) {
throw new Error('Could not encode state: ', e.message);
}
},
getLocation() {
const location = history.location;
return {
...location,
state: this.parse(location.state),
};
},
getPath(path) {
if (path != null) {
return createPath(parsePath(path));
}
return createPath(this.getLocation());
},
getFullPath(path) {
if (path != null) {
return history.createHref(parsePath(path));
}
return history.createHref(this.getLocation());
},
push(state, path) {
history.push(path || this.getPath(), this.encode(state));
},
replace(state, path) {
history.replace(path || this.getPath(), this.encode(state));
},
onChange(fn) {
// if no handler fn passed, do nothing
if (fn == null) {
return;
}
// push onChange function onto listener stack and return a function to remove it
const pushedIndex = historyState.onChange.push(fn) - 1;
return (() => {
// only allow the unlisten function to be called once
let called = false;
return () => {
if (called) {
return;
}
historyState.onChange.splice(pushedIndex, 1);
called = true;
};
})();
},
resetOnChange() {
// splice to clear the onChange array, and remove listener for each fn
historyState.onChange.splice(0);
},
get historyInstance() {
// getter to get access to the underlying history instance
return history;
},
};
// track the initial history location and create update listener
historyState.prevLocation = wrappedHistory.getLocation();
historyState.changeUnlisten = history.listen((location, action) => {
const { prevLocation } = historyState;
const locationObj = locationFormat(location, action, wrappedHistory.parse);
const prevLocationObj = locationFormat(prevLocation, action, wrappedHistory.parse);
// execute all listeners
historyState.onChange.forEach((fn) => fn.call(null, locationObj, prevLocationObj));
// track the updated location
historyState.prevLocation = wrappedHistory.getLocation();
});
return wrappedHistory;
}
const instances = new WeakMap();
const getHistoryInstance = (win) => {
// if no window object, use memory module
if (typeof win === 'undefined' || !win.history) {
return createMemoryHistory();
}
return createHashStateHistory();
};
export const createHistory = (win = getWindow()) => {
// create and cache wrapped history instance
const historyInstance = getHistoryInstance(win);
const wrappedInstance = wrapHistoryInstance(historyInstance);
instances.set(win, wrappedInstance);
return wrappedInstance;
};
export const historyProvider = (win = getWindow()) => {
// return cached instance if one exists
const instance = instances.get(win);
if (instance) {
return instance;
}
return createHistory(win);
};
export const destroyHistory = (win = getWindow()) => {
const instance = instances.get(win);
if (instance) {
instance.resetOnChange();
}
};

View file

@ -1,129 +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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import createRouter from '@scant/router';
import { getWindow } from './get_window';
import { historyProvider } from './history_provider';
import { getCurrentAppState, assignAppState } from './app_state';
import { modifyUrl } from './modify_url';
// used to make this provider a singleton
let router;
export function routerProvider(routes) {
if (router) {
return router;
}
const baseRouter = createRouter(routes);
const history = historyProvider(getWindow());
const componentListeners = [];
// assume any string starting with a / is a path
const isPath = (str) => typeof str === 'string' && str.substr(0, 1) === '/';
// helper to get the current state in history
const getState = (name, params, state) => {
// given a path, assuming params is the state
if (isPath(name)) {
return params || history.getLocation().state;
}
return state || history.getLocation().state;
};
// helper to append appState to a given url path
const appendAppState = (path, appState = getCurrentAppState()) => {
const newUrl = modifyUrl(path, (parts) => {
parts.query = assignAppState(parts.query, appState);
});
return newUrl;
};
// add or replace history with new url, either from path or derived path via name and params
const updateLocation = (name, params, state, replace = false) => {
const currentState = getState(name, params, state);
const method = replace ? 'replace' : 'push';
// given a path, go there directly
if (isPath(name)) {
return history[method](currentState, appendAppState(name));
}
history[method](currentState, appendAppState(baseRouter.create(name, params)));
};
// our router is an extended version of the imported router
// which mixes in history methods for navigation
router = {
...baseRouter,
execute(path = history.getPath()) {
return this.parse(path);
},
getPath: () => history.getPath(),
getFullPath: () => history.getFullPath(),
navigateTo(name, params, state) {
updateLocation(name, params, state);
},
redirectTo(name, params, state) {
updateLocation(name, params, state, true);
},
updateAppState(appState, replace = true) {
const method = replace ? 'replace' : 'push';
const newPath = appendAppState(this.getPath(), appState);
const currentState = history.getLocation().state;
history[method](currentState, newPath);
},
onPathChange(fn) {
const execOnMatch = (location) => {
const { pathname } = location;
const match = this.match(pathname);
if (!match) {
// TODO: show some kind of error, or redirect somewhere; maybe home?
console.error('No route found for path: ', pathname);
return;
}
fn({ ...match, location });
};
// on path changes, fire the path change handler
const unlisten = history.onChange((locationObj, prevLocationObj) => {
if (
locationObj.pathname !== prevLocationObj.pathname ||
locationObj.search !== prevLocationObj.search
) {
execOnMatch(locationObj);
}
});
// keep track of all change handler removal functions, for cleanup
// TODO: clean up listeners when baseRounter.stop is called
componentListeners.push(unlisten);
// initially fire the path change handler
execOnMatch(history.getLocation());
return unlisten; // return function to remove change handler
},
stop: () => {
for (const listener of componentListeners) {
listener();
}
},
};
return router;
}
export const stopRouter = () => {
if (router) {
router.stop();
router = undefined;
}
};

View file

@ -4,6 +4,12 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { Route } from 'react-router-dom';
import { HomeApp } from '../../components/home_app';
export { routes } from './routes';
export { WorkpadApp } from './workpad_app';
export const HomeRoute = () => (
<Route path="/">
<HomeApp />
</Route>
);

View file

@ -5,4 +5,4 @@
* 2.0.
*/
export { Link } from './link';
export * from './home_route';

View file

@ -0,0 +1,22 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { FC } from 'react';
import { Router, Switch } from 'react-router-dom';
import { History } from 'history';
import { HomeRoute } from './home';
import { WorkpadRoute, ExportWorkpadRoute } from './workpad';
export const CanvasRouter: FC<{ history: History }> = ({ history }) => (
<Router history={history}>
<Switch>
{ExportWorkpadRoute()}
{WorkpadRoute()}
{HomeRoute()}
</Switch>
</Router>
);

View file

@ -0,0 +1,81 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { FC } from 'react';
import { renderHook } from '@testing-library/react-hooks';
import { useAutoplayHelper } from './use_autoplay_helper';
import { WorkpadRoutingContext, WorkpadRoutingContextType } from '../workpad_routing_context';
const getMockedContext = (context: any) =>
({
nextPage: jest.fn(),
isFullscreen: false,
autoplayInterval: 0,
isAutoplayPaused: false,
...context,
} as WorkpadRoutingContextType);
const getContextWrapper: (context: WorkpadRoutingContextType) => FC = (context) => ({
children,
}) => <WorkpadRoutingContext.Provider value={context}>{children}</WorkpadRoutingContext.Provider>;
describe('useAutoplayHelper', () => {
beforeEach(() => jest.useFakeTimers());
test('starts the timer when fullscreen and autoplay is on', () => {
const context = getMockedContext({
isFullscreen: true,
autoplayInterval: 1,
});
renderHook(useAutoplayHelper, { wrapper: getContextWrapper(context) });
jest.runAllTimers();
expect(context.nextPage).toHaveBeenCalled();
});
test('stops the timer when autoplay pauses', () => {
const context = getMockedContext({
isFullscreen: true,
autoplayInterval: 1000,
});
const { rerender } = renderHook(useAutoplayHelper, { wrapper: getContextWrapper(context) });
jest.runTimersToTime(context.autoplayInterval - 1);
context.isAutoplayPaused = true;
rerender();
jest.runAllTimers();
expect(context.nextPage).not.toHaveBeenCalled();
});
test('starts the timer when autoplay unpauses', () => {
const context = getMockedContext({
isFullscreen: true,
autoplayInterval: 1000,
isAutoplayPaused: true,
});
const { rerender } = renderHook(useAutoplayHelper, { wrapper: getContextWrapper(context) });
jest.runAllTimers();
expect(context.nextPage).not.toHaveBeenCalled();
context.isAutoplayPaused = false;
rerender();
jest.runAllTimers();
expect(context.nextPage).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,29 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useContext, useEffect, useRef } from 'react';
import { WorkpadRoutingContext } from '../workpad_routing_context';
export const useAutoplayHelper = () => {
const { nextPage, isFullscreen, autoplayInterval, isAutoplayPaused } = useContext(
WorkpadRoutingContext
);
const timer = useRef<number | undefined>(undefined);
useEffect(() => {
if (timer.current || !isFullscreen || isAutoplayPaused) {
clearTimeout(timer.current);
}
if (isFullscreen && !isAutoplayPaused && autoplayInterval > 0) {
timer.current = window.setTimeout(() => {
nextPage();
}, autoplayInterval);
}
return () => clearTimeout(timer.current);
}, [isFullscreen, nextPage, autoplayInterval, isAutoplayPaused]);
};

View file

@ -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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useContext, useEffect } from 'react';
import { useServices } from '../../../services';
import { WorkpadRoutingContext } from '..';
const fullscreenClass = 'canvas-isFullscreen';
export const useFullscreenPresentationHelper = () => {
const { isFullscreen } = useContext(WorkpadRoutingContext);
const services = useServices();
const { setFullscreen } = services.platform;
useEffect(() => {
const body = document.querySelector('body');
const bodyClassList = body!.classList;
const hasFullscreenClass = bodyClassList.contains(fullscreenClass);
if (isFullscreen && !hasFullscreenClass) {
setFullscreen(false);
bodyClassList.add(fullscreenClass);
} else if (!isFullscreen && hasFullscreenClass) {
bodyClassList.remove(fullscreenClass);
setFullscreen(true);
}
}, [isFullscreen, setFullscreen]);
};

View file

@ -0,0 +1,83 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { renderHook } from '@testing-library/react-hooks';
import { usePageSync } from './use_page_sync';
const mockDispatch = jest.fn();
const mockGetParams = jest.fn();
const mockGetState = jest.fn();
// Mock the hooks and actions used by the UseWorkpad hook
jest.mock('react-redux', () => ({
useDispatch: () => mockDispatch,
useSelector: (selector: any) => selector(mockGetState()),
}));
jest.mock('react-router-dom', () => ({
useParams: () => mockGetParams(),
}));
describe('usePageSync', () => {
beforeEach(() => {
jest.resetAllMocks();
});
test('dispatches page index to match the pagenumber param', () => {
const pageParam = '1';
const state = {
persistent: {
workpad: {
page: 5,
},
},
};
mockGetParams.mockReturnValue({ pageNumber: pageParam });
mockGetState.mockReturnValue(state);
renderHook(() => usePageSync());
expect(mockDispatch).toHaveBeenCalledWith({ type: 'setPage', payload: 0 });
});
test('no dispatch if pageNumber matches page index', () => {
const pageParam = '6'; // Page number 6 is index 5
const state = {
persistent: {
workpad: {
page: 5,
},
},
};
mockGetParams.mockReturnValue({ pageNumber: pageParam });
mockGetState.mockReturnValue(state);
renderHook(() => usePageSync());
expect(mockDispatch).not.toHaveBeenCalled();
});
test('pageNumber that is NaN does not dispatch', () => {
const pageParam = 'A';
const state = {
persistent: {
workpad: {
page: 5,
},
},
};
mockGetParams.mockReturnValue({ pageNumber: pageParam });
mockGetState.mockReturnValue(state);
renderHook(() => usePageSync());
expect(mockDispatch).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,32 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { useParams } from 'react-router-dom';
import { WorkpadPageRouteParams } from '../';
import { getWorkpad } from '../../../state/selectors/workpad';
// @ts-expect-error
import { setPage } from '../../../state/actions/pages';
export const usePageSync = () => {
const params = useParams<WorkpadPageRouteParams>();
const workpad = useSelector(getWorkpad);
const dispatch = useDispatch();
const pageNumber = parseInt(params.pageNumber, 10);
let pageIndex = workpad.page;
if (!isNaN(pageNumber)) {
pageIndex = pageNumber - 1;
}
useEffect(() => {
if (pageIndex !== workpad.page) {
dispatch(setPage(pageIndex));
}
}, [pageIndex, workpad.page, dispatch]);
};

View file

@ -0,0 +1,84 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { FC } from 'react';
import { renderHook } from '@testing-library/react-hooks';
import { useRefreshHelper } from './use_refresh_helper';
import { WorkpadRoutingContext, WorkpadRoutingContextType } from '../workpad_routing_context';
const mockDispatch = jest.fn();
const mockGetState = jest.fn();
const refreshAction = { type: 'fetchAllRenderables' };
jest.mock('react-redux', () => ({
useDispatch: () => mockDispatch,
useSelector: (selector: any) => selector(mockGetState()),
}));
jest.mock('../../../state/actions/elements', () => ({
fetchAllRenderables: () => refreshAction,
}));
const getMockedContext = (context: any) =>
({
refreshInterval: 0,
...context,
} as WorkpadRoutingContextType);
const getContextWrapper: (context: WorkpadRoutingContextType) => FC = (context) => ({
children,
}) => <WorkpadRoutingContext.Provider value={context}>{children}</WorkpadRoutingContext.Provider>;
describe('useRefreshHelper', () => {
beforeEach(() => {
jest.resetAllMocks();
jest.useFakeTimers();
});
test('starts a timer to refresh', () => {
const context = getMockedContext({
refreshInterval: 1,
});
const state = {
transient: {
inFlight: false,
},
};
mockGetState.mockReturnValue(state);
renderHook(useRefreshHelper, { wrapper: getContextWrapper(context) });
expect(mockDispatch).not.toHaveBeenCalledWith(refreshAction);
jest.runAllTimers();
expect(mockDispatch).toHaveBeenCalledWith(refreshAction);
});
test('cancels a timer when inflight is active', () => {
const context = getMockedContext({
refreshInterval: 100,
});
const state = {
transient: {
inFlight: false,
},
};
mockGetState.mockReturnValue(state);
const { rerender } = renderHook(useRefreshHelper, { wrapper: getContextWrapper(context) });
jest.runTimersToTime(context.refreshInterval - 1);
expect(mockDispatch).not.toHaveBeenCalledWith(refreshAction);
state.transient.inFlight = true;
rerender(useRefreshHelper);
jest.runAllTimers();
expect(mockDispatch).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,36 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useEffect, useContext, useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { WorkpadRoutingContext } from '../workpad_routing_context';
import { getInFlight } from '../../../state/selectors/resolved_args';
// @ts-expect-error untyped local
import { fetchAllRenderables } from '../../../state/actions/elements';
export const useRefreshHelper = () => {
const dispatch = useDispatch();
const { refreshInterval } = useContext(WorkpadRoutingContext);
const timer = useRef<number | undefined>(undefined);
const inFlight = useSelector(getInFlight);
useEffect(() => {
// We got here because inFlight or refreshInterval changed.
// Either way, we want to cancel existing refresh timer
clearTimeout(timer.current);
if (refreshInterval > 0 && !inFlight) {
timer.current = window.setTimeout(() => {
dispatch(fetchAllRenderables());
}, refreshInterval);
}
return () => {
clearTimeout(timer.current);
};
}, [inFlight, dispatch, refreshInterval]);
};

View file

@ -0,0 +1,78 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { renderHook } from '@testing-library/react-hooks';
import { useRestoreHistory } from './use_restore_history';
import { encode } from '../route_state';
const mockDispatch = jest.fn();
const mockGetLocation = jest.fn();
const mockGetHistory = jest.fn();
const location = { state: undefined };
const history = { action: 'POP' };
// Mock the hooks and actions
jest.mock('react-redux', () => ({
useDispatch: () => mockDispatch,
}));
jest.mock('react-router-dom', () => ({
useLocation: () => mockGetLocation(),
useHistory: () => mockGetHistory(),
}));
jest.mock('../../../state/actions/workpad', () => ({
initializeWorkpad: () => ({ type: 'initialize' }),
}));
describe('useRestoreHistory', () => {
beforeEach(() => {
jest.resetAllMocks();
});
test('dispatches nothing on initial run', () => {
mockGetLocation.mockReturnValue(location);
mockGetHistory.mockReturnValue(history);
renderHook(() => useRestoreHistory());
expect(mockDispatch).not.toBeCalled();
});
test('dispatches nothing on a non pop event', () => {
mockGetLocation.mockReturnValue(location);
mockGetHistory.mockReturnValue({ action: 'not-pop' });
const { rerender } = renderHook(() => useRestoreHistory());
expect(mockDispatch).not.toBeCalled();
mockGetLocation.mockReturnValue({ state: encode({ some: 'state' }) });
rerender();
expect(mockDispatch).not.toBeCalled();
});
test('dispatches restore history if state changes on a POP action', () => {
const oldState = { a: 'a', b: 'b' };
const newState = { c: 'c', d: 'd' };
mockGetHistory.mockReturnValue(history);
mockGetLocation.mockReturnValue({
state: encode(oldState),
});
const { rerender } = renderHook(() => useRestoreHistory());
mockGetLocation.mockReturnValue({
state: encode(newState),
});
rerender();
expect(mockDispatch).toHaveBeenCalledWith({ type: 'restoreHistory', payload: newState });
});
});

View file

@ -0,0 +1,35 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useRef, useEffect } from 'react';
import { useHistory, useLocation } from 'react-router-dom';
import { useDispatch } from 'react-redux';
// @ts-expect-error
import { restoreHistory } from '../../../state/actions/history';
import { initializeWorkpad } from '../../../state/actions/workpad';
import { decode } from '../route_state';
export const useRestoreHistory = () => {
const history = useHistory();
const location = useLocation();
const dispatch = useDispatch();
const { state: historyState } = location;
const previousState = useRef<string | undefined>(historyState);
const historyAction = history.action.toLowerCase();
useEffect(() => {
const isBrowserNav = historyAction === 'pop' && historyState != null;
if (isBrowserNav && historyState !== previousState.current) {
previousState.current = historyState;
dispatch(restoreHistory(decode(historyState)));
dispatch(initializeWorkpad());
}
previousState.current = historyState;
}, [dispatch, historyAction, historyState]);
};

View file

@ -0,0 +1,160 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useCallback, useMemo, useState } from 'react';
import { useHistory, useParams } from 'react-router-dom';
import { useSelector } from 'react-redux';
import { getWorkpad } from '../../../state/selectors/workpad';
import { WorkpadPageRouteParams, WorkpadRoutingContextType } from '..';
import {
createTimeInterval,
isValidTimeInterval,
getTimeInterval,
} from '../../../lib/time_interval';
export const useRoutingContext: () => WorkpadRoutingContextType = () => {
const [isAutoplayPaused, setIsAutoplayPaused] = useState<boolean>(false);
const history = useHistory();
const { search } = history.location;
const params = useParams<WorkpadPageRouteParams>();
const workpad = useSelector(getWorkpad);
const searchParams = new URLSearchParams(search);
const parsedPage = parseInt(params.pageNumber!, 10);
const pageNumber = isNaN(parsedPage) ? workpad.page + 1 : parsedPage;
const workpadPages = workpad.pages.length;
const getUrl = useCallback(
(page: number) => `/workpad/${params.id}/page/${page}${history.location.search}`,
[params.id, history.location.search]
);
const gotoPage = useCallback(
(page: number) => {
history.push(getUrl(page));
},
[getUrl, history]
);
const nextPage = useCallback(() => {
let newPage = pageNumber + 1;
if (newPage > workpadPages) {
newPage = 1;
}
gotoPage(newPage);
}, [pageNumber, workpadPages, gotoPage]);
const previousPage = useCallback(() => {
let newPage = pageNumber - 1;
if (newPage < 1) {
newPage = workpadPages;
}
gotoPage(newPage);
}, [pageNumber, workpadPages, gotoPage]);
const isFullscreen = searchParams.get('__fullScreen') === 'true';
const autoplayValue = searchParams.get('__autoplayInterval');
const autoplayInterval =
autoplayValue && isValidTimeInterval(autoplayValue) ? getTimeInterval(autoplayValue) || 0 : 0;
const refreshValue = searchParams.get('__refreshInterval');
const refreshInterval =
refreshValue && isValidTimeInterval(refreshValue) ? getTimeInterval(refreshValue) || 0 : 0;
const setFullscreen = useCallback(
(enable: boolean) => {
const newQuery = new URLSearchParams(history.location.search);
if (enable) {
newQuery.set('__fullScreen', 'true');
} else {
setIsAutoplayPaused(false);
newQuery.delete('__fullScreen');
}
history.push(`${history.location.pathname}?${newQuery.toString()}`);
},
[history, setIsAutoplayPaused]
);
const setAutoplayInterval = useCallback(
(interval: number) => {
const newQuery = new URLSearchParams(history.location.search);
if (interval > 0) {
newQuery.set('__autoplayInterval', createTimeInterval(interval));
} else {
newQuery.delete('__autoplayInterval');
}
history.push(`${history.location.pathname}?${newQuery.toString()}`);
},
[history]
);
const setRefreshInterval = useCallback(
(interval: number) => {
const newQuery = new URLSearchParams(history.location.search);
if (interval > 0) {
newQuery.set('__refreshInterval', createTimeInterval(interval));
} else {
newQuery.delete('__refreshInterval');
}
history.push(`${history.location.pathname}?${newQuery.toString()}`);
},
[history]
);
const undo = useCallback(() => {
history.goBack();
}, [history]);
const redo = useCallback(() => {
history.goForward();
}, [history]);
const getRoutingContext = useCallback(
() => ({
gotoPage,
getUrl,
isFullscreen,
setFullscreen,
autoplayInterval,
setAutoplayInterval,
nextPage,
previousPage,
refreshInterval,
setRefreshInterval,
isAutoplayPaused,
setIsAutoplayPaused,
undo,
redo,
}),
[
gotoPage,
getUrl,
isFullscreen,
setFullscreen,
autoplayInterval,
setAutoplayInterval,
nextPage,
previousPage,
refreshInterval,
setRefreshInterval,
isAutoplayPaused,
setIsAutoplayPaused,
undo,
redo,
]
);
return useMemo(() => getRoutingContext(), [getRoutingContext]);
};

View file

@ -0,0 +1,67 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { waitFor } from '@testing-library/react';
import { renderHook } from '@testing-library/react-hooks';
import { useWorkpad } from './use_workpad';
const mockDispatch = jest.fn();
const mockSelector = jest.fn();
const mockGetWorkpad = jest.fn();
const workpad = {
id: 'someworkpad',
pages: [],
};
const assets = [{ id: 'asset-id' }];
const workpadResponse = {
...workpad,
assets,
};
// Mock the hooks and actions used by the UseWorkpad hook
jest.mock('react-redux', () => ({
useDispatch: () => mockDispatch,
useSelector: () => mockSelector,
}));
jest.mock('../../../services', () => ({
useServices: () => ({
workpad: {
get: mockGetWorkpad,
},
}),
}));
jest.mock('../../../state/actions/workpad', () => ({
setWorkpad: (payload: any) => ({
type: 'setWorkpad',
payload,
}),
}));
describe('useWorkpad', () => {
beforeEach(() => {
jest.resetAllMocks();
});
test('fires request to load workpad and dispatches results', async () => {
const workpadId = 'someworkpad';
mockGetWorkpad.mockResolvedValue(workpadResponse);
renderHook(() => useWorkpad(workpadId));
await waitFor(() => expect(mockGetWorkpad).toHaveBeenCalledWith(workpadId));
expect(mockGetWorkpad).toHaveBeenCalledWith(workpadId);
expect(mockDispatch).toHaveBeenCalledWith({ type: 'setAssets', payload: assets });
expect(mockDispatch).toHaveBeenCalledWith({ type: 'setWorkpad', payload: workpad });
expect(mockDispatch).toHaveBeenCalledWith({ type: 'setZoomScale', payload: 1 });
});
});

View file

@ -0,0 +1,42 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useServices } from '../../../services';
import { getWorkpad } from '../../../state/selectors/workpad';
import { setWorkpad } from '../../../state/actions/workpad';
// @ts-expect-error
import { setAssets } from '../../../state/actions/assets';
// @ts-expect-error
import { setZoomScale } from '../../../state/actions/transient';
import { CanvasWorkpad } from '../../../../types';
export const useWorkpad = (
workpadId: string,
loadPages: boolean = true
): [CanvasWorkpad | undefined, string | Error | undefined] => {
const services = useServices();
const dispatch = useDispatch();
const storedWorkpad = useSelector(getWorkpad);
const [error, setError] = useState<string | Error | undefined>(undefined);
useEffect(() => {
(async () => {
try {
const { assets, ...workpad } = await services.workpad.get(workpadId);
dispatch(setAssets(assets));
dispatch(setWorkpad(workpad, { loadPages }));
dispatch(setZoomScale(1));
} catch (e) {
setError(e);
}
})();
}, [workpadId, services.workpad, dispatch, setError, loadPages]);
return [storedWorkpad.id === workpadId ? storedWorkpad : undefined, error];
};

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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { renderHook } from '@testing-library/react-hooks';
import { useWorkpadHistory } from './use_workpad_history';
import { encode } from '../route_state';
const mockGetState = jest.fn();
const mockGetHistory = jest.fn();
// Mock the hooks and actions used by the UseWorkpad hook
jest.mock('react-router-dom', () => ({
useHistory: () => mockGetHistory(),
}));
jest.mock('react-redux', () => ({
useSelector: (selector: any) => selector(mockGetState()),
}));
describe('useRestoreHistory', () => {
beforeEach(() => {
jest.resetAllMocks();
});
test('replaces undefined state with current state', () => {
const history = {
location: {
state: undefined,
pathname: 'somepath',
},
push: jest.fn(),
replace: jest.fn(),
};
const state = {
persistent: { some: 'state' },
};
mockGetState.mockReturnValue(state);
mockGetHistory.mockReturnValue(history);
renderHook(() => useWorkpadHistory());
expect(history.replace).toBeCalledWith(history.location.pathname, encode(state.persistent));
});
test('does not do a push on initial render if states do not match', () => {
const history = {
location: {
state: encode({ old: 'state' }),
pathname: 'somepath',
},
push: jest.fn(),
replace: jest.fn(),
};
const state = {
persistent: { some: 'state' },
};
mockGetState.mockReturnValue(state);
mockGetHistory.mockReturnValue(history);
renderHook(() => useWorkpadHistory());
expect(history.push).not.toBeCalled();
});
test('rerender does a push if location state does not match store state', () => {
const history = {
location: {
state: encode({ old: 'state' }),
pathname: 'somepath',
},
push: jest.fn(),
replace: jest.fn(),
};
const oldState = {
persistent: { some: 'state' },
};
const newState = {
persistent: { new: 'state' },
};
mockGetState.mockReturnValue(oldState);
mockGetHistory.mockReturnValue(history);
const { rerender } = renderHook(() => useWorkpadHistory());
mockGetState.mockReturnValue(newState);
rerender();
expect(history.push).toBeCalledWith(history.location.pathname, encode(newState.persistent));
});
});

View file

@ -0,0 +1,41 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useEffect, useRef } from 'react';
import { useSelector } from 'react-redux';
import { useHistory } from 'react-router-dom';
import { isEqual } from 'lodash';
import { createPath } from 'history';
import { encode, decode } from '../route_state';
import { State } from '../../../../types';
export const useWorkpadHistory = () => {
const history = useHistory();
const historyState = useSelector((state: State) => state.persistent);
const hasRun = useRef<boolean>(false);
useEffect(() => {
const isInitialRun = !hasRun.current;
const locationState = history.location.state;
const decodedState = locationState ? decode(locationState) : {};
const doesStateMatchLocationState = isEqual(historyState, decodedState);
const fullPath = createPath(history.location);
hasRun.current = true;
// If there is no location state, then let's replace the curent route with the location state
// This will happen when navigating directly to a url (there will be no state on that link click)
if (locationState === undefined) {
history.replace(fullPath, encode(historyState));
} else if (!doesStateMatchLocationState && !isInitialRun) {
// There was a state change here
// If the state of the route that we are on does not match this new state, then we are going to push
history.push(fullPath, encode(historyState));
}
}, [history, historyState]);
};

View file

@ -0,0 +1,23 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { RouteComponentProps } from 'react-router-dom';
export { WorkpadRoute, ExportWorkpadRoute } from './workpad_route';
export { WorkpadRoutingContext, WorkpadRoutingContextType } from './workpad_routing_context';
export interface WorkpadRouteParams {
id: string;
}
export interface WorkpadPageRouteParams extends WorkpadRouteParams {
pageNumber: string;
}
export type WorkpadRouteProps = RouteComponentProps<WorkpadRouteParams>;
export type WorkpadPageRouteProps = RouteComponentProps<WorkpadPageRouteParams>;

View file

@ -0,0 +1,27 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
// @ts-expect-error
import lzString from 'lz-string';
export const encode = (state: any) => {
try {
const stateJSON = JSON.stringify(state);
return lzString.compress(stateJSON);
} catch (e) {
throw new Error(`Could not encode state: ${e.message}`);
}
};
export const decode = (payload: string) => {
try {
const stateJSON = lzString.decompress(payload);
return JSON.parse(stateJSON);
} catch (e) {
return null;
}
};

View file

@ -0,0 +1,38 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { FC, useEffect } from 'react';
import { useSelector } from 'react-redux';
import { getBaseBreadcrumb, getWorkpadBreadcrumb } from '../../lib/breadcrumbs';
// @ts-expect-error
import { setDocTitle } from '../../lib/doc_title';
import { getWorkpad } from '../../state/selectors/workpad';
import { useFullscreenPresentationHelper } from './hooks/use_fullscreen_presentation_helper';
import { useAutoplayHelper } from './hooks/use_autoplay_helper';
import { useRefreshHelper } from './hooks/use_refresh_helper';
import { useServices } from '../../services';
export const WorkpadPresentationHelper: FC = ({ children }) => {
const services = useServices();
const workpad = useSelector(getWorkpad);
useFullscreenPresentationHelper();
useAutoplayHelper();
useRefreshHelper();
useEffect(() => {
services.platform.setBreadcrumbs([
getBaseBreadcrumb(),
getWorkpadBreadcrumb({ name: workpad.name, id: workpad.id }),
]);
}, [workpad.name, workpad.id, services.platform]);
useEffect(() => {
setDocTitle(workpad.name);
}, [workpad.name]);
return <>{children}</>;
};

View file

@ -0,0 +1,118 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { FC, useEffect } from 'react';
import { Route, Switch, Redirect, useParams } from 'react-router-dom';
import { useDispatch } from 'react-redux';
import { WorkpadApp } from '../../components/workpad_app';
import { ExportApp } from '../../components/export_app';
import { CanvasLoading } from '../../components/canvas_loading';
// @ts-expect-error
import { fetchAllRenderables } from '../../state/actions/elements';
import { useServices } from '../../services';
import { CanvasWorkpad } from '../../../types';
import { ErrorStrings } from '../../../i18n';
import { useWorkpad } from './hooks/use_workpad';
import { useRestoreHistory } from './hooks/use_restore_history';
import { useWorkpadHistory } from './hooks/use_workpad_history';
import { usePageSync } from './hooks/use_page_sync';
import { WorkpadPageRouteProps, WorkpadRouteProps, WorkpadPageRouteParams } from '.';
import { WorkpadRoutingContextComponent } from './workpad_routing_context';
import { WorkpadPresentationHelper } from './workpad_presentation_helper';
const { workpadRoutes: strings } = ErrorStrings;
export const WorkpadRoute = () => (
<Route
path={'/workpad/:id'}
exact={false}
children={(route: WorkpadRouteProps) => (
<WorkpadLoaderComponent params={route.match.params} key="workpad-loader">
{(workpad: CanvasWorkpad) => (
<Switch>
<Route
path="/workpad/:id/page/:pageNumber"
children={(pageRoute) => (
<WorkpadHistoryManager>
<WorkpadRoutingContextComponent>
<WorkpadPresentationHelper>
<WorkpadApp />
</WorkpadPresentationHelper>
</WorkpadRoutingContextComponent>
</WorkpadHistoryManager>
)}
/>
<Route path="/workpad/:id" strict={false} exact={true}>
<Redirect to={`/workpad/${route.match.params.id}/page/${workpad.page + 1}`} />
</Route>
</Switch>
)}
</WorkpadLoaderComponent>
)}
/>
);
export const ExportWorkpadRoute = () => (
<Route
path={'/export/workpad/pdf/:id/page/:pageNumber'}
children={(route: WorkpadPageRouteProps) => (
<WorkpadLoaderComponent loadPages={false} params={route.match.params}>
{() => (
<ExportRouteManager>
<ExportApp />
</ExportRouteManager>
)}
</WorkpadLoaderComponent>
)}
/>
);
export const ExportRouteManager: FC = ({ children }) => {
const params = useParams<WorkpadPageRouteParams>();
usePageSync();
const dispatch = useDispatch();
useEffect(() => {
dispatch(fetchAllRenderables({ onlyActivePage: true }));
}, [dispatch, params.pageNumber]);
return <>{children}</>;
};
export const WorkpadHistoryManager: FC = ({ children }) => {
useRestoreHistory();
useWorkpadHistory();
usePageSync();
return <>{children}</>;
};
const WorkpadLoaderComponent: FC<{
params: WorkpadRouteProps['match']['params'];
loadPages?: boolean;
children: (workpad: CanvasWorkpad) => JSX.Element;
}> = ({ params, children, loadPages }) => {
const [workpad, error] = useWorkpad(params.id, loadPages);
const services = useServices();
useEffect(() => {
if (error) {
services.notify.error(error, { title: strings.getLoadFailureErrorMessage() });
}
}, [error, services.notify]);
if (error) {
return <Redirect to="/" />;
}
if (!workpad) {
return <CanvasLoading />;
}
return children(workpad);
};

View file

@ -0,0 +1,57 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { FC, createContext } from 'react';
import { useRoutingContext } from './hooks/use_routing_context';
export interface WorkpadRoutingContextType {
gotoPage: (page: number) => void;
getUrl: (page: number) => string;
isFullscreen: boolean;
setFullscreen: (fullscreen: boolean) => void;
autoplayInterval: number;
setAutoplayInterval: (interval: number) => void;
isAutoplayPaused: boolean;
setIsAutoplayPaused: (isPaused: boolean) => void;
nextPage: () => void;
previousPage: () => void;
refreshInterval: number;
setRefreshInterval: (interval: number) => void;
undo: () => void;
redo: () => void;
}
const basicWorkpadRoutingContext = {
gotoPage: (page: number) => undefined,
getUrl: (page: number) => '',
isFullscreen: false,
setFullscreen: (fullscreen: boolean) => undefined,
autoplayInterval: 0,
setAutoplayInterval: (interval: number) => undefined,
isAutoplayPaused: true,
setIsAutoplayPaused: (isPaused: boolean) => undefined,
nextPage: () => undefined,
previousPage: () => undefined,
refreshInterval: 0,
setRefreshInterval: (interval: number) => undefined,
undo: () => undefined,
redo: () => undefined,
};
export const WorkpadRoutingContext = createContext<WorkpadRoutingContextType>(
basicWorkpadRoutingContext
);
export const WorkpadRoutingContextComponent: FC = ({ children }) => {
const routingContext = useRoutingContext();
return (
<WorkpadRoutingContext.Provider value={routingContext}>
{children}
</WorkpadRoutingContext.Provider>
);
};

View file

@ -26,6 +26,7 @@ const defaultContextValue = {
platform: {},
navLink: {},
search: {},
workpad: {},
};
const context = createContext<CanvasServices>(defaultContextValue as CanvasServices);
@ -37,6 +38,7 @@ export const useExpressionsService = () => useServices().expressions;
export const useNotifyService = () => useServices().notify;
export const useNavLinkService = () => useServices().navLink;
export const useLabsService = () => useServices().labs;
export const useWorkpadService = () => useServices().workpad;
export const withServices = <Props extends WithServicesProps>(type: ComponentType<Props>) => {
const EnhancedType: FC<Props> = (props) =>
@ -58,6 +60,7 @@ export const ServicesProvider: FC<{
search: specifiedProviders.search.getService(),
reporting: specifiedProviders.reporting.getService(),
labs: specifiedProviders.labs.getService(),
workpad: specifiedProviders.workpad.getService(),
};
return <context.Provider value={value}>{children}</context.Provider>;
};

View file

@ -16,6 +16,7 @@ import { expressionsServiceFactory } from './expressions';
import { searchServiceFactory } from './search';
import { labsServiceFactory } from './labs';
import { reportingServiceFactory } from './reporting';
import { workpadServiceFactory } from './workpad';
export { NotifyService } from './notify';
export { SearchService } from './search';
@ -85,6 +86,7 @@ export const services = {
search: new CanvasServiceProvider(searchServiceFactory),
reporting: new CanvasServiceProvider(reportingServiceFactory),
labs: new CanvasServiceProvider(labsServiceFactory),
workpad: new CanvasServiceProvider(workpadServiceFactory),
};
export type CanvasServiceProviders = typeof services;
@ -98,6 +100,7 @@ export interface CanvasServices {
search: ServiceFromProvider<typeof services.search>;
reporting: ServiceFromProvider<typeof services.reporting>;
labs: ServiceFromProvider<typeof services.labs>;
workpad: ServiceFromProvider<typeof services.workpad>;
}
export const startServices = async (

View file

@ -12,7 +12,8 @@ import { ToastInputFields } from '../../../../../src/core/public';
const getToast = (err: Error | string, opts: ToastInputFields = {}) => {
const errData = (get(err, 'response') || err) as Error | string;
const errMsg = formatMsg(errData);
const errBody = get(err, 'body', undefined);
const errMsg = formatMsg(errBody !== undefined ? err : errData);
const { title, ...rest } = opts;
let text;

View file

@ -14,6 +14,7 @@ import { notifyService } from './notify';
import { labsService } from './labs';
import { platformService } from './platform';
import { searchService } from './search';
import { workpadService } from './workpad';
export const stubs: CanvasServices = {
embeddables: embeddablesService,
@ -24,6 +25,7 @@ export const stubs: CanvasServices = {
platform: platformService,
search: searchService,
labs: labsService,
workpad: workpadService,
};
export const startServices = async (providedServices: Partial<CanvasServices> = {}) => {

View file

@ -0,0 +1,21 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { WorkpadService } from '../workpad';
import { CanvasWorkpad } from '../../../types';
export const workpadService: WorkpadService = {
get: (id: string) => Promise.resolve({} as CanvasWorkpad),
create: (workpad) => Promise.resolve({} as CanvasWorkpad),
createFromTemplate: (templateId: string) => Promise.resolve({} as CanvasWorkpad),
find: (term: string) =>
Promise.resolve({
total: 0,
workpads: [],
}),
remove: (id: string) => Promise.resolve(undefined),
};

View file

@ -0,0 +1,99 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { API_ROUTE_WORKPAD, DEFAULT_WORKPAD_CSS } from '../../common/lib/constants';
import { CanvasWorkpad } from '../../types';
import { CanvasServiceFactory } from './';
/*
Remove any top level keys from the workpad which will be rejected by validation
*/
const validKeys = [
'@created',
'@timestamp',
'assets',
'colors',
'css',
'variables',
'height',
'id',
'isWriteable',
'name',
'page',
'pages',
'width',
];
const sanitizeWorkpad = function (workpad: CanvasWorkpad) {
const workpadKeys = Object.keys(workpad);
for (const key of workpadKeys) {
if (!validKeys.includes(key)) {
delete (workpad as { [key: string]: any })[key];
}
}
return workpad;
};
interface WorkpadFindResponse {
total: number;
workpads: Array<Pick<CanvasWorkpad, 'name' | 'id' | '@timestamp' | '@created'>>;
}
export interface WorkpadService {
get: (id: string) => Promise<CanvasWorkpad>;
create: (workpad: CanvasWorkpad) => Promise<CanvasWorkpad>;
createFromTemplate: (templateId: string) => Promise<CanvasWorkpad>;
find: (term: string) => Promise<WorkpadFindResponse>;
remove: (id: string) => Promise<void>;
}
export const workpadServiceFactory: CanvasServiceFactory<WorkpadService> = (
_coreSetup,
coreStart,
_setupPlugins,
startPlugins
): WorkpadService => {
const getApiPath = function () {
return `${API_ROUTE_WORKPAD}`;
};
return {
get: async (id: string) => {
const workpad = await coreStart.http.get(`${getApiPath()}/${id}`);
return { css: DEFAULT_WORKPAD_CSS, variables: [], ...workpad };
},
create: (workpad: CanvasWorkpad) => {
return coreStart.http.post(getApiPath(), {
body: JSON.stringify({
...sanitizeWorkpad({ ...workpad }),
assets: workpad.assets || {},
variables: workpad.variables || [],
}),
});
},
createFromTemplate: (templateId: string) => {
return coreStart.http.post(getApiPath(), {
body: JSON.stringify({ templateId }),
});
},
find: (searchTerm: string) => {
const validSearchTerm = typeof searchTerm === 'string' && searchTerm.length > 0;
return coreStart.http.get(`${getApiPath()}/find`, {
query: {
perPage: 10000,
name: validSearchTerm ? searchTerm : '',
},
});
},
remove: (id: string) => {
return coreStart.http.delete(`${getApiPath()}/${id}`);
},
};
};

View file

@ -9,7 +9,11 @@ import { createAction } from 'redux-actions';
export const addPage = createAction('addPage');
export const duplicatePage = createAction('duplicatePage');
export const movePage = createAction('movePage', (id, position) => ({ id, position }));
export const movePage = createAction('movePage', (id, position, gotoPage) => ({
id,
position,
gotoPage,
}));
export const removePage = createAction('removePage');
export const stylePage = createAction('stylePage', (pageId, style) => ({ pageId, style }));
export const setPage = createAction('setPage');

View file

@ -52,7 +52,7 @@ export const setWorkpad = createThunk(
'setWorkpad',
(
{ dispatch, type },
workpad: CanvasWorkpad,
workpad: Omit<CanvasWorkpad, 'assets'>,
{ loadPages = true }: { loadPages?: boolean } = {}
) => {
dispatch(createAction(type)(workpad)); // set the workpad object in state

View file

@ -1,27 +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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { isAppReady } from '../selectors/app';
import { appReady as readyAction } from '../actions/app';
export const appReady = ({ dispatch, getState }) => (next) => (action) => {
// execute the action
next(action);
// read the new state
const state = getState();
// if app is already ready, there's nothing more to do here
if (state.app.ready) {
return;
}
// check for all conditions in the state that indicate that the app is ready
if (isAppReady(state)) {
dispatch(readyAction());
}
};

View file

@ -1,25 +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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { getWorkpad } from '../selectors/workpad';
import { getBaseBreadcrumb, getWorkpadBreadcrumb, setBreadcrumb } from '../../lib/breadcrumbs';
export const breadcrumbs = ({ getState }) => (next) => (action) => {
// capture the current workpad
const currentWorkpad = getWorkpad(getState());
// execute the default action
next(action);
// capture the workpad after the action completes
const updatedWorkpad = getWorkpad(getState());
// if the workpad name changed, update the breadcrumb data
if (currentWorkpad.name !== updatedWorkpad.name) {
setBreadcrumb([getBaseBreadcrumb(), getWorkpadBreadcrumb(updatedWorkpad)]);
}
};

View file

@ -1,23 +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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { setFullscreen } from '../../lib/fullscreen';
import { setFullscreen as setAppStateFullscreen } from '../../lib/app_state';
import { setFullscreen as setFullscreenAction } from '../actions/transient';
import { getFullscreen } from '../selectors/app';
export const fullscreen = ({ getState }) => (next) => (action) => {
// execute the default action
next(action);
// pass current state's fullscreen info to the fullscreen service
if (action.type === setFullscreenAction.toString()) {
const fullscreen = getFullscreen(getState());
setFullscreen(fullscreen);
setAppStateFullscreen(fullscreen);
}
};

View file

@ -1,142 +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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { isEqual } from 'lodash';
import { routes } from '../../apps';
import { historyProvider } from '../../lib/history_provider';
import { routerProvider } from '../../lib/router_provider';
import { get as fetchWorkpad } from '../../lib/workpad_service';
import { restoreHistory, undoHistory, redoHistory } from '../actions/history';
import { initializeWorkpad } from '../actions/workpad';
import { setAssets } from '../actions/assets';
import { isAppReady } from '../selectors/app';
import { getWorkpad } from '../selectors/workpad';
function getHistoryState(state) {
// this is what gets written to browser history
return state.persistent;
}
export const historyMiddleware = ({ dispatch, getState }) => {
// iterate over routes, injecting redux to action handlers
const reduxInject = (routes) => {
return routes.map((route) => {
if (route.children) {
return {
...route,
children: reduxInject(route.children),
};
}
if (!route.action) {
return route;
}
return {
...route,
action: route.action(dispatch, getState),
};
});
};
const handlerState = {
pendingCount: 0,
};
// wrap up the application route actions in redux
const router = routerProvider(reduxInject(routes));
const history = historyProvider();
// wire up history change handler (this only happens once)
const handleHistoryChanges = async (location, prevLocation) => {
const { pathname, state: historyState, action: historyAction } = location;
// pop state will fire on any hash-based url change, but only back/forward will have state
const isBrowserNav = historyAction === 'pop' && historyState != null;
const isUrlChange =
(!isBrowserNav && historyAction === 'pop') ||
((historyAction === 'push' || historyAction === 'replace') &&
prevLocation.pathname !== pathname);
// only restore the history on popState events with state
// this only happens when using back/forward with popState objects
if (isBrowserNav) {
// TODO: oof, this sucks. we can't just shove assets into history state because
// firefox is limited to 640k (wat!). so, when we see that the workpad id is changing,
// we instead just restore the assets, which ensures the overall state is correct.
// there must be a better way to handle history though...
const currentWorkpadId = getWorkpad(getState()).id;
if (currentWorkpadId !== historyState.workpad.id) {
const newWorkpad = await fetchWorkpad(historyState.workpad.id);
dispatch(setAssets(newWorkpad.assets));
}
return dispatch(restoreHistory(historyState));
}
// execute route action on pushState and popState events
if (isUrlChange) {
return await router.parse(pathname);
}
};
history.onChange(async (...args) => {
// use history replace until any async handlers are completed
handlerState.pendingCount += 1;
try {
await handleHistoryChanges(...args);
} catch (e) {
// TODO: handle errors here
} finally {
// restore default history method
handlerState.pendingCount -= 1;
}
});
return (next) => (action) => {
const oldState = getState();
// deal with history actions
switch (action.type) {
case undoHistory.toString():
return history.undo();
case redoHistory.toString():
return history.redo();
case restoreHistory.toString():
// skip state compare, simply execute the action
next(action);
// TODO: we shouldn't need to reset the entire workpad for undo/redo
dispatch(initializeWorkpad());
return;
}
// execute the action like normal
next(action);
const newState = getState();
// if the app is not ready, don't persist anything
if (!isAppReady(newState)) {
return;
}
// if app switched from not ready to ready, replace current state
// this allows the back button to work correctly all the way to first page load
if (!isAppReady(oldState) && isAppReady(newState)) {
history.replace(getHistoryState(newState));
return;
}
// if the persistent state changed, push it into the history
const oldHistoryState = getHistoryState(oldState);
const historyState = getHistoryState(newState);
if (!isEqual(historyState, oldHistoryState)) {
// if there are pending route changes, just replace current route (to avoid extra back/forth history entries)
const useReplaceState = handlerState.pendingCount !== 0;
useReplaceState ? history.replace(historyState) : history.push(historyState);
}
};
};

View file

@ -8,15 +8,9 @@
import { applyMiddleware, compose as reduxCompose } from 'redux';
import thunkMiddleware from 'redux-thunk';
import { getWindow } from '../../lib/get_window';
import { breadcrumbs } from './breadcrumbs';
import { esPersistMiddleware } from './es_persist';
import { fullscreen } from './fullscreen';
import { historyMiddleware } from './history';
import { inFlight } from './in_flight';
import { workpadUpdate } from './workpad_update';
import { workpadRefresh } from './workpad_refresh';
import { workpadAutoplay } from './workpad_autoplay';
import { appReady } from './app_ready';
import { elementStats } from './element_stats';
import { resolvedArgs } from './resolved_args';
@ -26,14 +20,8 @@ const middlewares = [
elementStats,
resolvedArgs,
esPersistMiddleware,
historyMiddleware,
breadcrumbs,
fullscreen,
inFlight,
appReady,
workpadUpdate,
workpadRefresh,
workpadAutoplay
workpadUpdate
),
];

View file

@ -1,146 +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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
jest.mock('../../lib/app_state');
jest.mock('../../lib/router_provider');
import { workpadAutoplay } from './workpad_autoplay';
import { setAutoplayInterval } from '../../lib/app_state';
import { createTimeInterval } from '../../lib/time_interval';
// @ts-expect-error untyped local
import { routerProvider } from '../../lib/router_provider';
const next = jest.fn();
const dispatch = jest.fn();
const getState = jest.fn();
const routerMock = { navigateTo: jest.fn() };
routerProvider.mockReturnValue(routerMock);
const middleware = workpadAutoplay({ dispatch, getState })(next);
const workpadState = {
persistent: {
workpad: {
id: 'workpad-id',
pages: ['page1', 'page2', 'page3'],
page: 0,
},
},
};
const autoplayState = {
...workpadState,
transient: {
autoplay: {
inFlight: false,
enabled: true,
interval: 5000,
},
fullscreen: true,
},
};
const autoplayDisabledState = {
...workpadState,
transient: {
autoplay: {
inFlight: false,
enabled: false,
interval: 5000,
},
},
};
const action = {};
describe('workpad autoplay middleware', () => {
beforeEach(() => {
dispatch.mockClear();
jest.resetAllMocks();
});
describe('app state', () => {
it('sets the app state to the interval from state when enabled', () => {
getState.mockReturnValue(autoplayState);
middleware(action);
expect(setAutoplayInterval).toBeCalledWith(
createTimeInterval(autoplayState.transient.autoplay.interval)
);
});
it('sets the app state to null when not enabled', () => {
getState.mockReturnValue(autoplayDisabledState);
middleware(action);
expect(setAutoplayInterval).toBeCalledWith(null);
});
});
describe('autoplay navigation', () => {
it('navigates forward after interval', () => {
jest.useFakeTimers();
getState.mockReturnValue(autoplayState);
middleware(action);
jest.advanceTimersByTime(autoplayState.transient.autoplay.interval + 1);
expect(routerMock.navigateTo).toBeCalledWith('loadWorkpad', {
id: workpadState.persistent.workpad.id,
page: workpadState.persistent.workpad.page + 2, // (index + 1) + 1 more for 1 indexed page number
});
jest.useRealTimers();
});
it('navigates from last page back to front', () => {
jest.useFakeTimers();
const onLastPageState = { ...autoplayState };
onLastPageState.persistent.workpad.page = onLastPageState.persistent.workpad.pages.length - 1;
getState.mockReturnValue(autoplayState);
middleware(action);
jest.advanceTimersByTime(autoplayState.transient.autoplay.interval + 1);
expect(routerMock.navigateTo).toBeCalledWith('loadWorkpad', {
id: workpadState.persistent.workpad.id,
page: 1,
});
jest.useRealTimers();
});
it('continues autoplaying', () => {
jest.useFakeTimers();
getState.mockReturnValue(autoplayState);
middleware(action);
jest.advanceTimersByTime(autoplayState.transient.autoplay.interval * 2 + 1);
expect(routerMock.navigateTo).toBeCalledTimes(2);
jest.useRealTimers();
});
it('does not reset timer between middleware calls', () => {
jest.useFakeTimers();
getState.mockReturnValue(autoplayState);
middleware(action);
// Advance until right before timeout
jest.advanceTimersByTime(autoplayState.transient.autoplay.interval - 1);
// Run middleware again
middleware(action);
// Advance timer
jest.advanceTimersByTime(1);
expect(routerMock.navigateTo).toBeCalled();
});
});
});

View file

@ -1,94 +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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { Middleware } from 'redux';
import { State } from '../../../types';
import { getFullscreen } from '../selectors/app';
import { getInFlight } from '../selectors/resolved_args';
import { getWorkpad, getPages, getSelectedPageIndex, getAutoplay } from '../selectors/workpad';
// @ts-expect-error untyped local
import { appUnload } from '../actions/app';
// @ts-expect-error untyped local
import { routerProvider } from '../../lib/router_provider';
import { setAutoplayInterval } from '../../lib/app_state';
import { createTimeInterval } from '../../lib/time_interval';
export const workpadAutoplay: Middleware<{}, State> = ({ getState }) => (next) => {
let playTimeout: number | undefined;
let displayInterval = 0;
const router = routerProvider();
function updateWorkpad() {
if (displayInterval === 0) {
return;
}
// check the request in flight status
const inFlightActive = getInFlight(getState());
// only navigate if no requests are in-flight
if (!inFlightActive) {
// update the elements on the workpad
const workpadId = getWorkpad(getState()).id;
const pageIndex = getSelectedPageIndex(getState());
const pageCount = getPages(getState()).length;
const nextPage = Math.min(pageIndex + 1, pageCount - 1);
// go to start if on the last page
if (nextPage === pageIndex) {
router.navigateTo('loadWorkpad', { id: workpadId, page: 1 });
} else {
router.navigateTo('loadWorkpad', { id: workpadId, page: nextPage + 1 });
}
}
stopAutoUpdate();
startDelayedUpdate();
}
function stopAutoUpdate() {
clearTimeout(playTimeout); // cancel any pending update requests
playTimeout = undefined;
}
function startDelayedUpdate() {
if (!playTimeout) {
stopAutoUpdate();
playTimeout = window.setTimeout(() => {
updateWorkpad();
}, displayInterval);
}
}
return (action) => {
next(action);
const isFullscreen = getFullscreen(getState());
const autoplay = getAutoplay(getState());
const shouldPlay = isFullscreen && autoplay.enabled && autoplay.interval > 0;
displayInterval = autoplay.interval;
// update appState
if (autoplay.enabled) {
setAutoplayInterval(createTimeInterval(autoplay.interval));
} else {
setAutoplayInterval(null);
}
// if interval is larger than 0, start the delayed update
if (shouldPlay) {
startDelayedUpdate();
} else {
stopAutoUpdate();
}
if (action.type === appUnload.toString()) {
stopAutoUpdate();
}
};
};

View file

@ -1,159 +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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
jest.mock('../../lib/app_state');
import { workpadRefresh } from './workpad_refresh';
import { inFlightComplete } from '../actions/resolved_args';
import { setRefreshInterval } from '../actions/workpad';
import { setRefreshInterval as setAppStateRefreshInterval } from '../../lib/app_state';
import { createTimeInterval } from '../../lib/time_interval';
const next = jest.fn();
const dispatch = jest.fn();
const getState = jest.fn();
const middleware = workpadRefresh({ dispatch, getState })(next);
const refreshState = {
transient: {
refresh: {
interval: 5000,
},
},
};
const noRefreshState = {
transient: {
refresh: {
interval: 0,
},
},
};
const inFlightState = {
transient: {
refresh: {
interval: 5000,
},
inFlight: true,
},
};
describe('workpad refresh middleware', () => {
beforeEach(() => {
jest.resetAllMocks();
});
describe('onInflightComplete', () => {
it('refreshes if interval gt 0', () => {
jest.useFakeTimers();
getState.mockReturnValue(refreshState);
middleware(inFlightComplete());
jest.runAllTimers();
expect(dispatch).toHaveBeenCalled();
});
it('does not reset interval if another action occurs', () => {
jest.useFakeTimers();
getState.mockReturnValue(refreshState);
middleware(inFlightComplete());
jest.advanceTimersByTime(refreshState.transient.refresh.interval - 1);
expect(dispatch).not.toHaveBeenCalled();
middleware(inFlightComplete());
jest.advanceTimersByTime(1);
expect(dispatch).toHaveBeenCalled();
});
it('does not refresh if interval is 0', () => {
jest.useFakeTimers();
getState.mockReturnValue(noRefreshState);
middleware(inFlightComplete());
jest.runAllTimers();
expect(dispatch).not.toHaveBeenCalled();
});
});
describe('setRefreshInterval', () => {
it('does nothing if refresh interval is unchanged', () => {
getState.mockReturnValue(refreshState);
jest.useFakeTimers();
const interval = 1;
middleware(setRefreshInterval(interval));
jest.runAllTimers();
expect(setAppStateRefreshInterval).not.toBeCalled();
});
it('sets the app refresh interval', () => {
getState.mockReturnValue(noRefreshState);
next.mockImplementation(() => {
getState.mockReturnValue(refreshState);
});
jest.useFakeTimers();
const interval = 1;
middleware(setRefreshInterval(interval));
expect(setAppStateRefreshInterval).toBeCalledWith(createTimeInterval(interval));
jest.runAllTimers();
});
it('starts a refresh for the new interval', () => {
getState.mockReturnValue(refreshState);
jest.useFakeTimers();
const interval = 1000;
middleware(inFlightComplete());
jest.runTimersToTime(refreshState.transient.refresh.interval - 1);
expect(dispatch).not.toBeCalled();
getState.mockReturnValue(noRefreshState);
next.mockImplementation(() => {
getState.mockReturnValue(refreshState);
});
middleware(setRefreshInterval(interval));
jest.runTimersToTime(1);
expect(dispatch).not.toBeCalled();
jest.runTimersToTime(interval);
expect(dispatch).toBeCalled();
});
});
describe('inFlight in progress', () => {
it('requeues the refresh when inflight is active', () => {
jest.useFakeTimers();
getState.mockReturnValue(inFlightState);
middleware(inFlightComplete());
jest.runTimersToTime(refreshState.transient.refresh.interval);
expect(dispatch).not.toBeCalled();
getState.mockReturnValue(refreshState);
jest.runAllTimers();
expect(dispatch).toBeCalled();
});
});
});

Some files were not shown because too many files have changed in this diff Show more