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