[7.2] Feat: Autoplay pages in fullscreen (#35981) (#37275)

* feat: add autoplay redux boilerplate

WIP auto-play settings

* feat: add page cycle settings

* feat: add cycle toggle hotkey

* chore: add tooltip text to settings icon

* settings layout

* fix: handle invalid input for custom interval

* chore: address nit
This commit is contained in:
Joe Fleming 2019-05-28 14:30:19 -07:00 committed by GitHub
parent f196aaf261
commit 4a2f90fece
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 382 additions and 106 deletions

View file

@ -4,16 +4,13 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Fragment, Component } from 'react';
import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import {
EuiFlexGroup,
EuiFlexGrid,
EuiFlexItem,
EuiFormRow,
EuiButton,
EuiLink,
EuiFieldText,
EuiSpacer,
EuiHorizontalRule,
EuiDescriptionList,
@ -21,32 +18,25 @@ import {
EuiDescriptionListDescription,
EuiFormLabel,
EuiText,
EuiButtonIcon,
EuiToolTip,
} from '@elastic/eui';
import { timeDurationString } from '../../../lib/time_duration';
import { RefreshControl } from '../refresh_control';
import { CustomInterval } from './custom_interval';
const ListGroup = ({ children }) => <ul style={{ listStyle: 'none', margin: 0 }}>{[children]}</ul>;
export class AutoRefreshControls extends Component {
static propTypes = {
refreshInterval: PropTypes.number,
setRefresh: PropTypes.func.isRequired,
disableInterval: PropTypes.func.isRequired,
};
export const AutoRefreshControls = ({ refreshInterval, setRefresh, disableInterval }) => {
const RefreshItem = ({ duration, label }) => (
<li>
<EuiLink onClick={() => setRefresh(duration)}>{label}</EuiLink>
</li>
);
refreshInput = null;
render() {
const { refreshInterval, setRefresh, disableInterval } = this.props;
const RefreshItem = ({ duration, label }) => (
<li>
<EuiLink onClick={() => setRefresh(duration)}>{label}</EuiLink>
</li>
);
return (
<div>
return (
<EuiFlexGroup direction="column" justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiFlexGroup alignItems="center" justifyContent="spaceAround" gutterSize="xs">
<EuiFlexItem>
<EuiDescriptionList textStyle="reverse">
@ -55,11 +45,6 @@ export class AutoRefreshControls extends Component {
{refreshInterval > 0 ? (
<Fragment>
<span>Every {timeDurationString(refreshInterval)}</span>
<div>
<EuiLink size="s" onClick={disableInterval}>
Disable auto-refresh
</EuiLink>
</div>
</Fragment>
) : (
<span>Manually</span>
@ -68,7 +53,22 @@ export class AutoRefreshControls extends Component {
</EuiDescriptionList>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<RefreshControl />
<EuiFlexGroup justifyContent="flexEnd" gutterSize="xs">
{refreshInterval > 0 ? (
<EuiFlexItem grow={false}>
<EuiToolTip position="bottom" content="Disable auto-refresh">
<EuiButtonIcon
iconType="cross"
onClick={disableInterval}
aria-label="Disable auto-refresh"
/>
</EuiToolTip>
</EuiFlexItem>
) : null}
<EuiFlexItem grow={false}>
<RefreshControl />
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
@ -100,35 +100,17 @@ export class AutoRefreshControls extends Component {
</EuiFlexItem>
</EuiFlexGrid>
</EuiText>
</EuiFlexItem>
<EuiSpacer size="m" />
<EuiFlexItem grow={false}>
<CustomInterval onSubmit={value => setRefresh(value)} />
</EuiFlexItem>
</EuiFlexGroup>
);
};
<form
onSubmit={ev => {
ev.preventDefault();
setRefresh(this.refreshInput.value);
}}
>
<EuiFlexGroup gutterSize="s">
<EuiFlexItem>
<EuiFormRow
label="Set a custom interval"
helpText="Use shorthand notation, like 30s, 10m, or 1h"
compressed
>
<EuiFieldText inputRef={i => (this.refreshInput = i)} />
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFormRow label="&nbsp;">
<EuiButton size="s" type="submit" style={{ minWidth: 'auto' }}>
Set
</EuiButton>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
</form>
</div>
);
}
}
AutoRefreshControls.propTypes = {
refreshInterval: PropTypes.number,
setRefresh: PropTypes.func.isRequired,
disableInterval: PropTypes.func.isRequired,
};

View file

@ -6,45 +6,29 @@
import React from 'react';
import PropTypes from 'prop-types';
import { EuiFlexGroup, EuiFlexItem, EuiButtonIcon } from '@elastic/eui';
import { EuiFlexGroup, EuiFlexItem, EuiButtonIcon, EuiToolTip } from '@elastic/eui';
import { Popover } from '../../popover';
import { AutoRefreshControls } from './auto_refresh_controls';
import { KioskControls } from './kiosk_controls';
const getRefreshInterval = (val = '') => {
// if it's a number, just use it directly
if (!isNaN(Number(val))) {
return val;
}
// if it's a string, try to parse out the shorthand duration value
const match = String(val).match(/^([0-9]{1,})([hmsd])$/);
// TODO: do something better with improper input, like show an error...
if (!match) {
return;
}
switch (match[2]) {
case 's':
return match[1] * 1000;
case 'm':
return match[1] * 1000 * 60;
case 'h':
return match[1] * 1000 * 60 * 60;
case 'd':
return match[1] * 1000 * 60 * 60 * 24;
}
};
export const ControlSettings = ({ setRefreshInterval, refreshInterval }) => {
const setRefresh = val => setRefreshInterval(getRefreshInterval(val));
export const ControlSettings = ({
setRefreshInterval,
refreshInterval,
autoplayEnabled,
autoplayInterval,
enableAutoplay,
setAutoplayInterval,
}) => {
const setRefresh = val => setRefreshInterval(val);
const disableInterval = () => {
setRefresh(0);
};
const popoverButton = handleClick => (
<EuiButtonIcon iconType="gear" aria-label="Control settings" onClick={handleClick} />
<EuiToolTip position="bottom" content="Control settings">
<EuiButtonIcon iconType="gear" aria-label="Control settings" onClick={handleClick} />
</EuiToolTip>
);
return (
@ -54,19 +38,21 @@ export const ControlSettings = ({ setRefreshInterval, refreshInterval }) => {
anchorPosition="rightUp"
panelClassName="canvasControlSettings__popover"
>
{({ closePopover }) => (
{() => (
<EuiFlexGroup>
<EuiFlexItem>
<AutoRefreshControls
refreshInterval={refreshInterval}
setRefresh={val => {
setRefresh(val);
closePopover();
}}
disableInterval={() => {
disableInterval();
closePopover();
}}
setRefresh={val => setRefresh(val)}
disableInterval={() => disableInterval()}
/>
</EuiFlexItem>
<EuiFlexItem>
<KioskControls
autoplayEnabled={autoplayEnabled}
autoplayInterval={autoplayInterval}
onSetInterval={setAutoplayInterval}
onSetEnabled={enableAutoplay}
/>
</EuiFlexItem>
</EuiFlexGroup>
@ -78,4 +64,8 @@ export const ControlSettings = ({ setRefreshInterval, refreshInterval }) => {
ControlSettings.propTypes = {
refreshInterval: PropTypes.number,
setRefreshInterval: PropTypes.func.isRequired,
autoplayEnabled: PropTypes.bool,
autoplayInterval: PropTypes.number,
enableAutoplay: PropTypes.func.isRequired,
setAutoplayInterval: PropTypes.func.isRequired,
};

View file

@ -1,3 +1,3 @@
.canvasControlSettings__popover {
width: 300px;
width: 600px;
}

View file

@ -0,0 +1,90 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiButton, EuiFieldText } from '@elastic/eui';
const getRefreshInterval = (val = '') => {
// if it's a number, there is no interval, return undefined
if (!isNaN(Number(val))) {
return;
}
// if it's a string, try to parse out the shorthand duration value
const match = String(val).match(/^([0-9]{1,})([hmsd])$/);
// if it's invalid, there is no interval, return undefined
if (!match) {
return;
}
switch (match[2]) {
case 's':
return match[1] * 1000;
case 'm':
return match[1] * 1000 * 60;
case 'h':
return match[1] * 1000 * 60 * 60;
case 'd':
return match[1] * 1000 * 60 * 60 * 24;
}
};
export const CustomInterval = ({ gutterSize, buttonSize, onSubmit, defaultValue }) => {
const [customInterval, setCustomInterval] = useState(defaultValue);
const refreshInterval = getRefreshInterval(customInterval);
const isInvalid = Boolean(customInterval.length && !refreshInterval);
const handleChange = ev => setCustomInterval(ev.target.value);
return (
<form
onSubmit={ev => {
ev.preventDefault();
onSubmit(refreshInterval);
}}
>
<EuiFlexGroup gutterSize={gutterSize}>
<EuiFlexItem>
<EuiFormRow
label="Set a custom interval"
helpText="Use shorthand notation, like 30s, 10m, or 1h"
compressed
>
<EuiFieldText isInvalid={isInvalid} value={customInterval} onChange={handleChange} />
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFormRow label="&nbsp;">
<EuiButton
disabled={isInvalid}
size={buttonSize}
type="submit"
style={{ minWidth: 'auto' }}
>
Set
</EuiButton>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
</form>
);
};
CustomInterval.propTypes = {
buttonSize: PropTypes.string,
gutterSize: PropTypes.string,
defaultValue: PropTypes.string,
onSubmit: PropTypes.func.isRequired,
};
CustomInterval.defaultProps = {
buttonSize: 's',
gutterSize: 's',
defaultValue: '',
};

View file

@ -5,16 +5,28 @@
*/
import { connect } from 'react-redux';
import { setRefreshInterval } from '../../../state/actions/workpad';
import { getRefreshInterval } from '../../../state/selectors/workpad';
import {
setRefreshInterval,
enableAutoplay,
setAutoplayInterval,
} from '../../../state/actions/workpad';
import { getRefreshInterval, getAutoplay } from '../../../state/selectors/workpad';
import { ControlSettings as Component } from './control_settings';
const mapStateToProps = state => ({
refreshInterval: getRefreshInterval(state),
});
const mapStateToProps = state => {
const { enabled, interval } = getAutoplay(state);
return {
refreshInterval: getRefreshInterval(state),
autoplayEnabled: enabled,
autoplayInterval: interval,
};
};
const mapDispatchToProps = {
setRefreshInterval,
enableAutoplay,
setAutoplayInterval,
};
export const ControlSettings = connect(

View file

@ -0,0 +1,95 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import {
EuiDescriptionList,
EuiDescriptionListDescription,
EuiDescriptionListTitle,
EuiFormLabel,
EuiHorizontalRule,
EuiLink,
EuiSpacer,
EuiSwitch,
EuiText,
EuiFlexGrid,
EuiFlexItem,
EuiFlexGroup,
} from '@elastic/eui';
import { timeDurationString } from '../../../lib/time_duration';
import { CustomInterval } from './custom_interval';
const ListGroup = ({ children }) => <ul style={{ listStyle: 'none', margin: 0 }}>{[children]}</ul>;
export const KioskControls = ({
autoplayEnabled,
autoplayInterval,
onSetEnabled,
onSetInterval,
}) => {
const RefreshItem = ({ duration, label }) => (
<li>
<EuiLink onClick={() => onSetInterval(duration)}>{label}</EuiLink>
</li>
);
return (
<EuiFlexGroup direction="column" justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiDescriptionList textStyle="reverse">
<EuiDescriptionListTitle>Cycle fullscreen pages</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
<span>Every {timeDurationString(autoplayInterval)}</span>
</EuiDescriptionListDescription>
</EuiDescriptionList>
<EuiHorizontalRule margin="m" />
<div>
<EuiSwitch
checked={autoplayEnabled}
label="Cycle slides automatically"
onChange={ev => onSetEnabled(ev.target.checked)}
/>
<EuiSpacer size="m" />
</div>
<EuiFormLabel>Change cycling interval</EuiFormLabel>
<EuiSpacer size="s" />
<EuiText size="s">
<EuiFlexGrid gutterSize="s" columns={2}>
<EuiFlexItem>
<ListGroup>
<RefreshItem duration="5000" label="5 seconds" />
<RefreshItem duration="10000" label="10 seconds" />
<RefreshItem duration="30000" label="30 seconds" />
</ListGroup>
</EuiFlexItem>
<EuiFlexItem>
<ListGroup>
<RefreshItem duration="60000" label="1 minute" />
<RefreshItem duration="300000" label="5 minutes" />
<RefreshItem duration="900000" label="15 minute" />
</ListGroup>
</EuiFlexItem>
</EuiFlexGrid>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<CustomInterval onSubmit={value => onSetInterval(value)} />
</EuiFlexItem>
</EuiFlexGroup>
);
};
KioskControls.propTypes = {
autoplayEnabled: PropTypes.bool.isRequired,
autoplayInterval: PropTypes.number.isRequired,
onSetEnabled: PropTypes.func.isRequired,
onSetInterval: PropTypes.func.isRequired,
};

View file

@ -16,6 +16,10 @@ export class FullscreenControl extends React.PureComponent {
if (enterFullscreen || exitFullscreen) {
this.toggleFullscreen();
}
if (action === 'PAGE_CYCLE_TOGGLE') {
this.props.enableAutoplay(!this.props.autoplayEnabled);
}
};
toggleFullscreen = () => {

View file

@ -6,11 +6,14 @@
import { connect } from 'react-redux';
import { setFullscreen, selectToplevelNodes } from '../../../state/actions/transient';
import { enableAutoplay } from '../../../state/actions/workpad';
import { getFullscreen } from '../../../state/selectors/app';
import { getAutoplay } from '../../../state/selectors/workpad';
import { FullscreenControl as Component } from './fullscreen_control';
const mapStateToProps = state => ({
isFullscreen: getFullscreen(state),
autoplayEnabled: getAutoplay(state).enabled,
});
const mapDispatchToProps = dispatch => ({
@ -18,6 +21,7 @@ const mapDispatchToProps = dispatch => ({
dispatch(setFullscreen(value));
value && dispatch(selectToplevelNodes([]));
},
enableAutoplay: enabled => dispatch(enableAutoplay(enabled)),
});
export const FullscreenControl = connect(

View file

@ -16,6 +16,8 @@ export const setWriteable = createAction('setWriteable');
export const setColors = createAction('setColors');
export const setRefreshInterval = createAction('setRefreshInterval');
export const setWorkpadCSS = createAction('setWorkpadCSS');
export const enableAutoplay = createAction('enableAutoplay');
export const setAutoplayInterval = createAction('setAutoplayInterval');
export const initializeWorkpad = createThunk('initializeWorkpad', ({ dispatch }) => {
dispatch(fetchAllRenderables());

View file

@ -26,6 +26,10 @@ export const getInitialState = path => {
refresh: {
interval: 0,
},
autoplay: {
enabled: false,
interval: 10000,
},
// values in resolvedArgs should live under a unique index so they can be looked up.
// The ID of the element is a great example.
// In there will live an object with a status (string), value (any), and error (Error) property.

View file

@ -14,6 +14,7 @@ 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';
@ -30,7 +31,8 @@ const middlewares = [
inFlight,
appReady,
workpadUpdate,
workpadRefresh
workpadRefresh,
workpadAutoplay
),
];

View file

@ -0,0 +1,79 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { inFlightComplete } from '../actions/resolved_args';
import { getFullscreen } from '../selectors/app';
import { getInFlight } from '../selectors/resolved_args';
import { getWorkpad, getPages, getSelectedPageIndex, getAutoplay } from '../selectors/workpad';
import { routerProvider } from '../../lib/router_provider';
export const workpadAutoplay = ({ getState }) => next => {
let playTimeout;
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 });
}
}
startDelayedUpdate();
}
function stopAutoUpdate() {
clearTimeout(playTimeout); // cancel any pending update requests
}
function startDelayedUpdate() {
stopAutoUpdate();
playTimeout = 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;
// when in-flight requests are finished, update the workpad after a given delay
if (action.type === inFlightComplete.toString() && shouldPlay) {
startDelayedUpdate();
} // create new update request
// This middleware creates or destroys an interval that will cause workpad elements to update
// clear any pending timeout
stopAutoUpdate();
// if interval is larger than 0, start the delayed update
if (shouldPlay) {
startDelayedUpdate();
}
};
};

View file

@ -10,7 +10,7 @@ import { restoreHistory } from '../actions/history';
import * as pageActions from '../actions/pages';
import * as transientActions from '../actions/transient';
import { removeElements } from '../actions/elements';
import { setRefreshInterval } from '../actions/workpad';
import { setRefreshInterval, enableAutoplay, setAutoplayInterval } from '../actions/workpad';
export const transientReducer = handleActions(
{
@ -63,6 +63,14 @@ export const transientReducer = handleActions(
[setRefreshInterval]: (transientState, { payload }) => {
return { ...transientState, refresh: { interval: Number(payload) || 0 } };
},
[enableAutoplay]: (transientState, { payload }) => {
return set(transientState, 'autoplay.enabled', Boolean(payload) || false);
},
[setAutoplayInterval]: (transientState, { payload }) => {
return set(transientState, 'autoplay.interval', Number(payload) || 0);
},
},
{}
);

View file

@ -319,3 +319,7 @@ export function getContextForIndex(state, index) {
export function getRefreshInterval(state) {
return get(state, 'transient.refresh.interval', 0);
}
export function getAutoplay(state) {
return get(state, 'transient.autoplay');
}