meriadec
7 years ago
4 changed files with 577 additions and 0 deletions
@ -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 |
@ -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> |
||||
|
) |
@ -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 |
@ -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…
Reference in new issue