Browse Source

Create DeviceInteraction component

master
meriadec 7 years ago
parent
commit
4b10d545bd
No known key found for this signature in database GPG Key ID: 1D2FC2305E2CB399
  1. 223
      src/components/DeviceInteraction/DeviceInteractionStep.js
  2. 153
      src/components/DeviceInteraction/components.js
  3. 120
      src/components/DeviceInteraction/index.js
  4. 81
      src/components/DeviceInteraction/stories.js

223
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<any> | { promise: Promise<any>, 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 (
<DeviceInteractionStepContainer
isFirst={isFirst}
isLast={isLast}
isSuccess={isSuccess}
isActive={isActive}
isPrecedentActive={isPrecedentActive}
isError={isError}
>
<IconContainer>{step.icon}</IconContainer>
<Box py={4} justify="center" grow shrink>
{title && (
<Box color={isActive && !isSuccess ? 'dark' : ''} ff="Open Sans|SemiBold">
{title}
</Box>
)}
{step.desc && step.desc}
{CustomRender && (
<CustomRender
onSuccess={this.handleSuccess}
onFail={this.handleFail}
onRetry={onRetry}
data={data}
/>
)}
{isError && error && <ErrorDescContainer error={error} onRetry={onRetry} mt={2} />}
</Box>
<div style={{ width: 70, position: 'relative', overflow: 'hidden', pointerEvents: 'none' }}>
<SpinnerContainer isVisible={isRunning} isPassed={isPassed} isError={isError} />
<ErrorContainer isVisible={isError} />
<SuccessContainer isVisible={isSuccess} />
</div>
</DeviceInteractionStepContainer>
)
}
}
export default DeviceInteractionStep

153
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 }) => (
<Box align="center" justify="center" style={{ width: 70 }}>
{children}
</Box>
)
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 }) => (
<SpinnerContainerWrapper isVisible={isVisible}>
<Spinner size={16} />
</SpinnerContainerWrapper>
)
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 }) => (
<SuccessContainerWrapper isVisible={isVisible}>
<IconCheck size={16} />
</SuccessContainerWrapper>
)
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 }) => (
<ErrorContainerWrapper isVisible={isVisible}>
<IconCross size={16} />
</ErrorContainerWrapper>
)
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,
}) => (
<Box
pl={0}
color="alertRed"
align="flex-start"
justify="center"
style={{
cursor: 'text',
}}
{...p}
>
<Box horizontal bg={rgba(colors.alertRed, 0.1)} borderRadius={1}>
<Box p={1} pl={2}>
{error.message || 'Failed'}
</Box>
<Tooltip render={() => 'Retry'} style={{ display: 'flex', alignItems: 'center' }}>
<ErrorRetryContainer onClick={onRetry}>
<IconRecover size={12} />
</ErrorRetryContainer>
</Tooltip>
</Box>
</Box>
)

120
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 (
<DeviceInteractionContainer {...props}>
{isSuccess && showSuccess && renderSuccess
? renderSuccess(data)
: steps.map((step, i) => {
const isError = !!error && i === stepIndex
return (
<DeviceInteractionStep
key={step.id}
step={step}
error={isError ? error : null}
isError={isError}
isFirst={i === 0}
isLast={i === steps.length - 1}
isPrecedentActive={i === stepIndex - 1}
isActive={i === stepIndex}
isPassed={i < stepIndex}
isSuccess={i < stepIndex || (i === stepIndex && isSuccess)}
onSuccess={this.handleSuccess}
onFail={this.handleFail}
onRetry={this.reset}
data={data}
/>
)
})}
</DeviceInteractionContainer>
)
}
}
const DeviceInteractionContainer = styled(Box).attrs({})``
export default DeviceInteraction

81
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', () => <Wrapper />)
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 = <MockIcon size={36} />
class Wrapper extends React.Component<any> {
_ref = null
handleReset = () => this._ref && this._ref.reset()
render() {
return (
<Fragment>
<button style={{ marginBottom: 40 }} onClick={this.handleReset}>
{'reset'}
</button>
<DeviceInteraction
ref={n => (this._ref = n)}
steps={[
{
id: 'deviceConnect',
title: 'Connect your device',
icon: <IconUsb size={36} />,
desc: 'If you dont connect your device, we wont be able to read on it',
render: ({ onSuccess, onFail }) => (
<Box p={2} bg="lightGrey" mt={2} borderRadius={1}>
<Box horizontal flow={2}>
<Button small primary onClick={() => onSuccess({ name: 'Nano S' })}>
{'Nano S'}
</Button>
<Button small primary onClick={() => onSuccess({ name: 'Blue' })}>
{'Blue'}
</Button>
<Button small danger onClick={onFail}>
{'make it fail'}
</Button>
</Box>
</Box>
),
},
{
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')}
/>
</Fragment>
)
}
}
Loading…
Cancel
Save