From 4b10d545bde9cde3422a78cc280b16b6123ce89e Mon Sep 17 00:00:00 2001 From: meriadec Date: Sun, 24 Jun 2018 19:59:41 +0200 Subject: [PATCH] Create DeviceInteraction component --- .../DeviceInteractionStep.js | 223 ++++++++++++++++++ .../DeviceInteraction/components.js | 153 ++++++++++++ src/components/DeviceInteraction/index.js | 120 ++++++++++ src/components/DeviceInteraction/stories.js | 81 +++++++ 4 files changed, 577 insertions(+) create mode 100644 src/components/DeviceInteraction/DeviceInteractionStep.js create mode 100644 src/components/DeviceInteraction/components.js create mode 100644 src/components/DeviceInteraction/index.js create mode 100644 src/components/DeviceInteraction/stories.js diff --git a/src/components/DeviceInteraction/DeviceInteractionStep.js b/src/components/DeviceInteraction/DeviceInteractionStep.js new file mode 100644 index 00000000..a1ff3a53 --- /dev/null +++ b/src/components/DeviceInteraction/DeviceInteractionStep.js @@ -0,0 +1,223 @@ +// @flow + +import React, { PureComponent } from 'react' + +import Box from 'components/base/Box' +import { delay } from 'helpers/promise' + +import { + DeviceInteractionStepContainer, + SpinnerContainer, + IconContainer, + SuccessContainer, + ErrorDescContainer, + ErrorContainer, +} from './components' + +export type Step = { + id: string, + title?: React$Node | (Object => React$Node), + desc?: React$Node, + icon: React$Node, + run?: Object => Promise | { promise: Promise, unsubscribe: void => any }, + render?: ( + { onSuccess: Object => any, onFail: Error => void, onRetry: void => void }, + any, + ) => React$Node, + minMs?: number, +} + +type Status = 'idle' | 'running' + +type Props = { + isFirst: boolean, + isLast: boolean, + isActive: boolean, + isPrecedentActive: boolean, + isError: boolean, + isSuccess: boolean, + isPassed: boolean, + step: Step, + error: ?Error, + onSuccess: (any, Step) => any, + onFail: (Error, Step) => any, + onRetry: void => any, + data: any, +} + +class DeviceInteractionStep extends PureComponent< + Props, + { + status: Status, + }, +> { + static defaultProps = { + data: {}, + } + + constructor(props: Props) { + super(props) + const { isFirst } = this.props + if (isFirst) { + // cf: __IS_MOUNTED__THX_FOR_REMOVING_COMPONENTWILLMOUNT__ + this.state.status = 'running' + + this.run() + } + } + + state = { + status: 'idle', + } + + componentDidMount() { + this.__IS_MOUNTED__THX_FOR_REMOVING_COMPONENTWILLMOUNT__ = true + } + + componentDidUpdate(prevProps: Props) { + const { isActive, error } = this.props + const { status } = this.state + + const didActivated = isActive && !prevProps.isActive + const didDeactivated = !isActive && prevProps.isActive + const stillActivated = isActive && prevProps.isActive + const didResetError = !error && !!prevProps.error + + if (didActivated && status !== 'running') { + this.run() + } + + if (didResetError && stillActivated) { + this.run() + } + + if (didDeactivated && status === 'running') { + this.cancel() + } + } + + componentWillUnmount() { + if (this._unsubscribe) { + this._unsubscribe() + } + this._unmounted = true + } + + __IS_MOUNTED__THX_FOR_REMOVING_COMPONENTWILLMOUNT__ = false + _unsubscribe = null + _unmounted = false + + handleSuccess = (res: any) => { + const { onSuccess, step, isError } = this.props + if (isError) return + this.setState({ status: 'idle' }) + onSuccess(res, step) + } + + handleFail = (e: Error) => { + const { onFail, step } = this.props + this.setState({ status: 'idle' }) + onFail(e, step) + } + + run = async () => { + const { step, data } = this.props + + if (this.__IS_MOUNTED__THX_FOR_REMOVING_COMPONENTWILLMOUNT__) { + this.setState({ status: 'running' }) + } + + if (!step.run) { + return + } + + try { + const d1 = Date.now() + + // $FlowFixMe JUST TESTED THE `run` 6 LINES BEFORE!!! + const res = (await step.run(data)) || {} + if (this._unmounted) return + + if (step.minMs) { + const d2 = Date.now() + // $FlowFixMe SAME THING, JUST TESTED THE MINMS KEY, BUT EH + if (d2 - d1 < step.minMs) { + // $FlowFixMe nice type checking + await delay(step.minMs - (d2 - d1)) + if (this._unmounted) return + } + } + if (res.promise) { + this._unsubscribe = res.unsubscribe + const realRes = await res.promise + if (this._unmounted) return + this.handleSuccess(realRes) + } else { + this.handleSuccess(res) + } + } catch (e) { + this.handleFail(e) + } + } + + cancel = () => this.setState({ status: 'idle' }) + + render() { + const { + isFirst, + isLast, + isActive, + isPrecedentActive, + isSuccess, + isError, + isPassed, + step, + error, + onRetry, + data, + } = this.props + + const { status } = this.state + const title = typeof step.title === 'function' ? step.title(data) : step.title + const { render: CustomRender } = step + const isRunning = status === 'running' + + return ( + + {step.icon} + + {title && ( + + {title} + + )} + {step.desc && step.desc} + {CustomRender && ( + + )} + {isError && error && } + + +
+ + + +
+
+ ) + } +} + +export default DeviceInteractionStep diff --git a/src/components/DeviceInteraction/components.js b/src/components/DeviceInteraction/components.js new file mode 100644 index 00000000..bd673d9b --- /dev/null +++ b/src/components/DeviceInteraction/components.js @@ -0,0 +1,153 @@ +// @flow + +import React from 'react' +import styled from 'styled-components' +import Tooltip from 'components/base/Tooltip' + +import { radii, colors } from 'styles/theme' +import { rgba } from 'styles/helpers' + +import Box from 'components/base/Box' +import Spinner from 'components/base/Spinner' +import IconCheck from 'icons/Check' +import IconCross from 'icons/Cross' +import IconRecover from 'icons/Recover' + +export const DeviceInteractionStepContainer = styled(Box).attrs({ + horizontal: true, + ff: 'Open Sans', + fontSize: 3, + bg: 'white', + color: 'graphite', +})` + position: relative; + z-index: ${p => (p.isActive ? 1 : '')}; + max-width: 500px; + min-height: 80px; + border: 1px solid ${p => p.theme.colors.fog}; + border-color: ${p => + p.isError ? p.theme.colors.alertRed : p.isActive || p.isSuccess ? p.theme.colors.wallet : ''}; + border-top-color: ${p => (p.isFirst || p.isActive ? '' : 'transparent')}; + border-bottom-color: ${p => (p.isPrecedentActive ? 'transparent' : '')}; + border-bottom-left-radius: ${p => (p.isLast ? `${radii[1]}px` : 0)}; + border-bottom-right-radius: ${p => (p.isLast ? `${radii[1]}px` : 0)}; + border-top-left-radius: ${p => (p.isFirst ? `${radii[1]}px` : 0)}; + border-top-right-radius: ${p => (p.isFirst ? `${radii[1]}px` : 0)}; + box-shadow: ${p => + p.isActive && !p.isSuccess + ? ` + ${rgba(p.isError ? p.theme.colors.alertRed : p.theme.colors.wallet, 0.2)} 0 0 3px 2px + ` + : 'none'}; + + &:after { + content: ''; + position: absolute; + left: -2px; + top: 0; + bottom: 0; + width: 2px; + box-shadow: ${p => + p.isActive && !p.isSuccess + ? `${p.theme.colors[p.isError ? 'alertRed' : 'wallet']} 2px 0 0` + : 'none'}; + } +` + +export const IconContainer = ({ children }: { children: any }) => ( + + {children} + +) + +const SpinnerContainerWrapper = styled.div` + color: ${p => p.theme.colors.grey}; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: center; + transition: 350ms cubic-bezier(0.62, 0.28, 0.39, 0.94); + transition-property: transform opacity; + opacity: ${p => (p.isVisible ? 1 : 0)}; + transform: translate3d(0, ${p => (!p.isVisible ? -40 : 0)}px, 0); +` + +export const SpinnerContainer = ({ isVisible }: { isVisible: boolean }) => ( + + + +) + +const SuccessContainerWrapper = styled(SpinnerContainerWrapper)` + color: ${p => p.theme.colors.wallet}; + transform: translate3d(0, ${p => (!p.isVisible ? 40 : 0)}px, 0); +` + +export const SuccessContainer = ({ isVisible }: { isVisible: boolean }) => ( + + + +) + +const ErrorContainerWrapper = styled(SpinnerContainerWrapper)` + color: ${p => p.theme.colors.alertRed}; + transform: translate3d(0, ${p => (!p.isVisible ? 40 : 0)}px, 0); +` + +export const ErrorContainer = ({ isVisible }: { isVisible: boolean }) => ( + + + +) + +const ErrorRetryContainer = styled(Box).attrs({ + grow: 1, + color: 'alertRed', + cursor: 'pointer', + p: 1, + align: 'center', + justify: 'center', + overflow: 'hidden', +})` + &:hover { + background-color: ${() => rgba(colors.alertRed, 0.1)}; + } + &:active { + background-color: ${() => rgba(colors.alertRed, 0.15)}; + } +` + +export const ErrorDescContainer = ({ + error, + onRetry, + ...p +}: { + error: Error, + onRetry: void => void, +}) => ( + + + + {error.message || 'Failed'} + + 'Retry'} style={{ display: 'flex', alignItems: 'center' }}> + + + + + + +) diff --git a/src/components/DeviceInteraction/index.js b/src/components/DeviceInteraction/index.js new file mode 100644 index 00000000..2375ebbf --- /dev/null +++ b/src/components/DeviceInteraction/index.js @@ -0,0 +1,120 @@ +// @flow + +import React, { PureComponent } from 'react' +import styled from 'styled-components' + +import { delay } from 'helpers/promise' + +import Box from 'components/base/Box' +import DeviceInteractionStep from './DeviceInteractionStep' + +import type { Step } from './DeviceInteractionStep' + +const INITIAL_STATE = { + stepIndex: 0, + isSuccess: false, + showSuccess: false, + error: null, + data: {}, +} + +class DeviceInteraction extends PureComponent< + { + steps: Step[], + onSuccess?: any => void, + onFail?: any => void, + renderSuccess?: any => any, + waitBeforeSuccess?: number, + }, + { + stepIndex: number, + isSuccess: boolean, + // used to be able to display the last check for a small amount of time + showSuccess: boolean, + error: ?Error, + data: Object, + }, +> { + state = INITIAL_STATE + + componentWillUnmount() { + this._unmounted = true + } + + _unmounted = false + + reset = () => this.setState(INITIAL_STATE) + + handleSuccess = async (res: any, step: Step) => { + const { onSuccess, steps, waitBeforeSuccess } = this.props + const { stepIndex, data: prevData } = this.state + const isCurrentStep = step.id === steps[stepIndex].id + if (!isCurrentStep) { + return + } + const data = { ...prevData, [step.id]: res || true } + const isLast = stepIndex === steps.length - 1 + if (isLast) { + if (!waitBeforeSuccess) { + onSuccess && onSuccess(data) + } + this.setState({ isSuccess: true, data, showSuccess: !waitBeforeSuccess }) + if (waitBeforeSuccess) { + await delay(waitBeforeSuccess) + if (this._unmounted) return + onSuccess && onSuccess(data) + this.setState({ showSuccess: true }) + } + } else { + this.setState({ stepIndex: stepIndex + 1, data }) + } + } + + handleFail = (error: Error, step: Step) => { + const { steps, onFail } = this.props + const { stepIndex } = this.state + const isCurrentStep = step === steps[stepIndex] + if (!isCurrentStep) { + return + } + this.setState({ error }) + onFail && onFail(error) + } + + render() { + const { steps, renderSuccess, waitBeforeSuccess: _waitBeforeSuccess, ...props } = this.props + const { stepIndex, error, isSuccess, data, showSuccess } = this.state + + return ( + + {isSuccess && showSuccess && renderSuccess + ? renderSuccess(data) + : steps.map((step, i) => { + const isError = !!error && i === stepIndex + return ( + + ) + })} + + ) + } +} + +const DeviceInteractionContainer = styled(Box).attrs({})`` + +export default DeviceInteraction diff --git a/src/components/DeviceInteraction/stories.js b/src/components/DeviceInteraction/stories.js new file mode 100644 index 00000000..e111336b --- /dev/null +++ b/src/components/DeviceInteraction/stories.js @@ -0,0 +1,81 @@ +// @flow + +import React, { Fragment } from 'react' +import styled from 'styled-components' +import { storiesOf } from '@storybook/react' +import { action } from '@storybook/addon-actions' + +import DeviceInteraction from 'components/DeviceInteraction' +import Box from 'components/base/Box' +import Button from 'components/base/Button' + +import IconUsb from 'icons/Usb' + +const stories = storiesOf('Components', module) + +stories.add('DeviceInteraction', () => ) + +const MockIcon = styled.div` + width: ${p => p.size}px; + height: ${p => p.size}px; + background: ${p => p.theme.colors.lightFog}; + border-radius: 50%; +` + +const mockIcon = + +class Wrapper extends React.Component { + _ref = null + handleReset = () => this._ref && this._ref.reset() + render() { + return ( + + + (this._ref = n)} + steps={[ + { + id: 'deviceConnect', + title: 'Connect your device', + icon: , + desc: 'If you dont connect your device, we wont be able to read on it', + render: ({ onSuccess, onFail }) => ( + + + + + + + + ), + }, + { + id: 'deviceOpen', + title: ({ deviceConnect: device }) => + `Open the Bitcoin application on your ${device ? `${device.name} ` : ''}device`, + desc: 'To be able to retriev your Bitcoins', + icon: mockIcon, + run: () => new Promise(resolve => setTimeout(resolve, 1 * 1000)), + }, + { + id: 'check', + title: 'Checking if all is alright...', + desc: 'This should take only 1 second...', + icon: mockIcon, + run: () => new Promise(resolve => setTimeout(resolve, 1 * 1000)), + }, + ]} + onSuccess={action('onSuccess')} + /> + + ) + } +}