Browse Source

Refacto AddAccount modal to use Stepper

...and also prevent passing `setState` from parent component to children
master
meriadec 7 years ago
parent
commit
b8a62764ab
No known key found for this signature in database GPG Key ID: 1D2FC2305E2CB399
  1. 208
      src/components/modals/AddAccounts/index.js
  2. 9
      src/components/modals/AddAccounts/steps/01-step-choose-currency.js
  3. 6
      src/components/modals/AddAccounts/steps/02-step-connect-device.js
  4. 81
      src/components/modals/AddAccounts/steps/03-step-import.js
  5. 4
      src/helpers/promise.js

208
src/components/modals/AddAccounts/index.js

@ -1,6 +1,5 @@
// @flow
import invariant from 'invariant'
import React, { PureComponent } from 'react'
import { compose } from 'redux'
import { connect } from 'react-redux'
@ -13,59 +12,66 @@ import type { Currency, Account } from '@ledgerhq/live-common/lib/types'
import { MODAL_ADD_ACCOUNTS } from 'config/constants'
import type { T, Device } from 'types/common'
import type { StepProps as DefaultStepProps, Step } from 'components/base/Stepper'
import { idleCallback } from 'helpers/promise'
import { getCurrentDevice } from 'reducers/devices'
import { accountsSelector } from 'reducers/accounts'
import { addAccount } from 'actions/accounts'
import { closeModal } from 'reducers/modals'
import Modal, { ModalContent, ModalTitle, ModalFooter, ModalBody } from 'components/base/Modal'
import Box from 'components/base/Box'
import Breadcrumb from 'components/Breadcrumb'
import Modal from 'components/base/Modal'
import Stepper from 'components/base/Stepper'
import StepChooseCurrency, { StepChooseCurrencyFooter } from './steps/01-step-choose-currency'
import StepConnectDevice, { StepConnectDeviceFooter } from './steps/02-step-connect-device'
import StepImport, { StepImportFooter } from './steps/03-step-import'
import StepFinish from './steps/04-step-finish'
const createSteps = ({ t }: { t: T }) => [
{
id: 'chooseCurrency',
label: t('app:addAccounts.breadcrumb.informations'),
component: StepChooseCurrency,
footer: StepChooseCurrencyFooter,
onBack: null,
hideFooter: false,
},
{
id: 'connectDevice',
label: t('app:addAccounts.breadcrumb.connectDevice'),
component: StepConnectDevice,
footer: StepConnectDeviceFooter,
onBack: ({ transitionTo }: StepProps) => transitionTo('chooseCurrency'),
hideFooter: false,
},
{
id: 'import',
label: t('app:addAccounts.breadcrumb.import'),
component: StepImport,
footer: StepImportFooter,
onBack: ({ transitionTo }: StepProps) => transitionTo('chooseCurrency'),
hideFooter: false,
},
{
id: 'finish',
label: t('app:addAccounts.breadcrumb.finish'),
component: StepFinish,
footer: null,
onBack: null,
hideFooter: true,
},
]
const createSteps = ({ t }: { t: T }) => {
const onBack = ({ transitionTo, resetScanState }: StepProps) => {
resetScanState()
transitionTo('chooseCurrency')
}
return [
{
id: 'chooseCurrency',
label: t('app:addAccounts.breadcrumb.informations'),
component: StepChooseCurrency,
footer: StepChooseCurrencyFooter,
onBack: null,
hideFooter: false,
},
{
id: 'connectDevice',
label: t('app:addAccounts.breadcrumb.connectDevice'),
component: StepConnectDevice,
footer: StepConnectDeviceFooter,
onBack,
hideFooter: false,
},
{
id: 'import',
label: t('app:addAccounts.breadcrumb.import'),
component: StepImport,
footer: StepImportFooter,
onBack,
hideFooter: false,
},
{
id: 'finish',
label: t('app:addAccounts.breadcrumb.finish'),
component: StepFinish,
footer: null,
onBack: null,
hideFooter: true,
},
]
}
type Props = {
t: T,
currentDevice: ?Device,
device: ?Device,
existingAccounts: Account[],
closeModal: string => void,
addAccount: Account => void,
@ -75,37 +81,39 @@ type StepId = 'chooseCurrency' | 'connectDevice' | 'import' | 'finish'
type ScanStatus = 'idle' | 'scanning' | 'error' | 'finished'
type State = {
stepId: StepId,
// TODO: I'm sure there will be always StepId and ScanStatus given,
// but I struggle making flow understand it. So I put string as fallback
stepId: StepId | string,
scanStatus: ScanStatus | string,
isAppOpened: boolean,
currency: ?Currency,
// scan process
scannedAccounts: Account[],
checkedAccountsIds: string[],
scanStatus: ScanStatus,
err: ?Error,
}
export type StepProps = {
export type StepProps = DefaultStepProps & {
t: T,
currency: ?Currency,
currentDevice: ?Device,
device: ?Device,
isAppOpened: boolean,
transitionTo: StepId => void,
setState: any => void,
onClickAdd: void => Promise<void>,
onCloseModal: void => void,
// scan process
scannedAccounts: Account[],
existingAccounts: Account[],
checkedAccountsIds: string[],
scanStatus: ScanStatus,
err: ?Error,
onClickAdd: void => Promise<void>,
onCloseModal: void => void,
resetScanState: void => void,
setCurrency: (?Currency) => void,
setAppOpened: boolean => void,
setScanStatus: (ScanStatus, ?Error) => string,
setScannedAccounts: ({ scannedAccounts?: Account[], checkedAccountsIds?: string[] }) => void,
}
const mapStateToProps = createStructuredSelector({
currentDevice: getCurrentDevice,
device: getCurrentDevice,
existingAccounts: accountsSelector,
})
@ -126,18 +134,7 @@ const INITIAL_STATE = {
class AddAccounts extends PureComponent<Props, State> {
state = INITIAL_STATE
STEPS = createSteps({
t: this.props.t,
})
transitionTo = stepId => {
const { currency } = this.state
let nextState = { stepId }
if (stepId === 'chooseCurrency') {
nextState = { ...INITIAL_STATE, currency }
}
this.setState(nextState)
}
STEPS = createSteps({ t: this.props.t })
handleClickAdd = async () => {
const { addAccount } = this.props
@ -151,16 +148,43 @@ class AddAccounts extends PureComponent<Props, State> {
await idleCallback()
addAccount(accountsToAdd[i])
}
this.transitionTo('finish')
}
handleCloseModal = () => {
const { closeModal } = this.props
closeModal(MODAL_ADD_ACCOUNTS)
handleCloseModal = () => this.props.closeModal(MODAL_ADD_ACCOUNTS)
handleStepChange = (step: Step) => this.setState({ stepId: step.id })
handleSetCurrency = (currency: ?Currency) => this.setState({ currency })
handleSetScanStatus = (scanStatus: string, err: ?Error = null) => {
this.setState({ scanStatus, err })
}
handleSetScannedAccounts = ({
checkedAccountsIds,
scannedAccounts,
}: {
checkedAccountsIds: string[],
scannedAccounts: Account[],
}) => {
this.setState({
...(checkedAccountsIds ? { checkedAccountsIds } : {}),
...(scannedAccounts ? { scannedAccounts } : {}),
})
}
handleResetScanState = () => {
this.setState({
scanStatus: 'idle',
err: null,
scannedAccounts: [],
checkedAccountsIds: [],
})
}
handleSetAppOpened = (isAppOpened: boolean) => this.setState({ isAppOpened })
render() {
const { t, currentDevice, existingAccounts } = this.props
const { t, device, existingAccounts } = this.props
const {
stepId,
currency,
@ -171,17 +195,9 @@ class AddAccounts extends PureComponent<Props, State> {
err,
} = this.state
const stepIndex = this.STEPS.findIndex(s => s.id === stepId)
const step = this.STEPS[stepIndex]
invariant(step, `AddAccountsModal: step ${stepId} doesn't exists`)
const { component: StepComponent, footer: StepFooter, hideFooter, onBack } = step
const stepProps: StepProps = {
t,
const addtionnalProps = {
currency,
currentDevice,
device,
existingAccounts,
scannedAccounts,
checkedAccountsIds,
@ -190,8 +206,11 @@ class AddAccounts extends PureComponent<Props, State> {
isAppOpened,
onClickAdd: this.handleClickAdd,
onCloseModal: this.handleCloseModal,
transitionTo: this.transitionTo,
setState: (...args) => this.setState(...args),
setScanStatus: this.handleSetScanStatus,
setCurrency: this.handleSetCurrency,
setScannedAccounts: this.handleSetScannedAccounts,
resetScanState: this.handleResetScanState,
setAppOpened: this.handleSetAppOpened,
}
return (
@ -200,21 +219,16 @@ class AddAccounts extends PureComponent<Props, State> {
refocusWhenChange={stepId}
onHide={() => this.setState({ ...INITIAL_STATE })}
render={({ onClose }) => (
<ModalBody onClose={onClose}>
<Stepper
title={t('app:addAccounts.title')}
initialStepId="chooseCurrency"
onStepChange={this.handleStepChange}
onClose={onClose}
steps={this.STEPS}
{...addtionnalProps}
>
<SyncSkipUnderPriority priority={100} />
<ModalTitle onBack={onBack ? () => onBack(stepProps) : void 0}>
{t('app:addAccounts.title')}
</ModalTitle>
<ModalContent>
<Breadcrumb mb={6} currentStep={stepIndex} items={this.STEPS} />
<StepComponent {...stepProps} />
</ModalContent>
{!hideFooter && (
<ModalFooter horizontal align="center" justify="flex-end" style={{ height: 80 }}>
{StepFooter ? <StepFooter {...stepProps} /> : <Box />}
</ModalFooter>
)}
</ModalBody>
</Stepper>
)}
/>
)
@ -228,7 +242,3 @@ export default compose(
),
translate(),
)(AddAccounts)
function idleCallback() {
return new Promise(resolve => window.requestIdleCallback(resolve))
}

9
src/components/modals/AddAccounts/steps/01-step-choose-currency.js

@ -1,7 +1,6 @@
// @flow
import React, { Fragment } from 'react'
import isArray from 'lodash/isArray'
import SelectCurrency from 'components/SelectCurrency'
import Button from 'components/base/Button'
@ -9,15 +8,11 @@ import CurrencyBadge from 'components/base/CurrencyBadge'
import type { StepProps } from '../index'
function StepChooseCurrency({ currency, setState }: StepProps) {
function StepChooseCurrency({ currency, setCurrency }: StepProps) {
return (
<SelectCurrency
autoFocus
onChange={currency => {
setState({
currency: isArray(currency) && currency.length === 0 ? null : currency,
})
}}
onChange={setCurrency}
value={currency}
/>
)

6
src/components/modals/AddAccounts/steps/02-step-connect-device.js

@ -11,7 +11,7 @@ import { CurrencyCircleIcon } from 'components/base/CurrencyBadge'
import type { StepProps } from '../index'
function StepConnectDevice({ t, currency, currentDevice, setState }: StepProps) {
function StepConnectDevice({ t, currency, device, setAppOpened }: StepProps) {
invariant(currency, 'No currency given')
return (
@ -30,11 +30,11 @@ function StepConnectDevice({ t, currency, currentDevice, setState }: StepProps)
</Box>
<ConnectDevice
t={t}
deviceSelected={currentDevice}
deviceSelected={device}
currency={currency}
onStatusChange={(deviceStatus, appStatus) => {
if (appStatus === 'success') {
setState({ isAppOpened: true })
setAppOpened(true)
}
}}
/>

81
src/components/modals/AddAccounts/steps/03-step-import.js

@ -18,19 +18,23 @@ import type { StepProps } from '../index'
class StepImport extends PureComponent<StepProps> {
componentDidMount() {
this.props.setState({ scanStatus: 'scanning' })
this.props.setScanStatus('scanning')
}
componentDidUpdate(prevProps: StepProps) {
// handle case when we click on stop sync
if (prevProps.scanStatus !== 'finished' && this.props.scanStatus === 'finished') {
this.unsub()
}
const didStartScan = prevProps.scanStatus !== 'scanning' && this.props.scanStatus === 'scanning'
const didFinishScan =
prevProps.scanStatus !== 'finished' && this.props.scanStatus === 'finished'
// handle case when we click on retry sync
if (prevProps.scanStatus !== 'scanning' && this.props.scanStatus === 'scanning') {
if (didStartScan) {
this.startScanAccountsDevice()
}
// handle case when we click on stop sync
if (didFinishScan) {
this.unsub()
}
}
componentWillUnmount() {
@ -63,15 +67,15 @@ class StepImport extends PureComponent<StepProps> {
startScanAccountsDevice() {
this.unsub()
const { currency, currentDevice, setState } = this.props
const { currency, device, setScanStatus, setScannedAccounts } = this.props
try {
invariant(currency, 'No currency to scan')
invariant(currentDevice, 'No device')
invariant(device, 'No device')
const bridge = getBridgeForCurrency(currency)
// TODO: use the real device
const devicePath = currentDevice.path
const devicePath = device.path
this.scanSubscription = bridge.scanAccountsOnDevice(currency, devicePath).subscribe({
next: account => {
@ -80,7 +84,7 @@ class StepImport extends PureComponent<StepProps> {
const hasAlreadyBeenImported = !!existingAccounts.find(a => account.id === a.id)
const isNewAccount = account.operations.length === 0
if (!hasAlreadyBeenScanned) {
setState({
setScannedAccounts({
scannedAccounts: [...scannedAccounts, this.translateName(account)],
checkedAccountsIds:
!hasAlreadyBeenImported && !isNewAccount
@ -89,43 +93,33 @@ class StepImport extends PureComponent<StepProps> {
})
}
},
complete: () => setState({ scanStatus: 'finished' }),
error: err => setState({ scanStatus: 'error', err }),
complete: () => setScanStatus('finished'),
error: err => setScanStatus('error', err),
})
} catch (err) {
setState({ scanStatus: 'error', err })
setScanStatus('error', err)
}
}
handleRetry = () => {
this.unsub()
this.handleResetState()
this.props.resetScanState()
this.startScanAccountsDevice()
}
handleResetState = () => {
const { setState } = this.props
setState({
scanStatus: 'idle',
err: null,
scannedAccounts: [],
checkedAccountsIds: [],
})
}
handleToggleAccount = (account: Account) => {
const { checkedAccountsIds, setState } = this.props
const { checkedAccountsIds, setScannedAccounts } = this.props
const isChecked = checkedAccountsIds.find(id => id === account.id) !== undefined
if (isChecked) {
setState({ checkedAccountsIds: checkedAccountsIds.filter(id => id !== account.id) })
setScannedAccounts({ checkedAccountsIds: checkedAccountsIds.filter(id => id !== account.id) })
} else {
setState({ checkedAccountsIds: [...checkedAccountsIds, account.id] })
setScannedAccounts({ checkedAccountsIds: [...checkedAccountsIds, account.id] })
}
}
handleUpdateAccount = (updatedAccount: Account) => {
const { scannedAccounts, setState } = this.props
setState({
const { scannedAccounts, setScannedAccounts } = this.props
setScannedAccounts({
scannedAccounts: scannedAccounts.map(account => {
if (account.id !== updatedAccount.id) {
return account
@ -136,19 +130,26 @@ class StepImport extends PureComponent<StepProps> {
}
handleSelectAll = () => {
const { scannedAccounts, setState } = this.props
setState({
const { scannedAccounts, setScannedAccounts } = this.props
setScannedAccounts({
checkedAccountsIds: scannedAccounts.filter(a => a.operations.length > 0).map(a => a.id),
})
}
handleUnselectAll = () => this.props.setState({ checkedAccountsIds: [] })
handleUnselectAll = () => this.props.setScannedAccounts({ checkedAccountsIds: [] })
renderError() {
const { err, t } = this.props
invariant(err, 'Trying to render inexisting error')
return (
<Box style={{ height: 200 }} align="center" justify="center" color="alertRed">
<Box
style={{ height: 200 }}
px={5}
textAlign="center"
align="center"
justify="center"
color="alertRed"
>
<IconExclamationCircleThin size={43} />
<Box mt={4}>{t('app:addAccounts.somethingWentWrong')}</Box>
<Box mt={4}>
@ -236,7 +237,8 @@ class StepImport extends PureComponent<StepProps> {
export default StepImport
export const StepImportFooter = ({
setState,
transitionTo,
setScanStatus,
scanStatus,
onClickAdd,
onCloseModal,
@ -274,18 +276,23 @@ export const StepImportFooter = ({
: t('app:common.close')
const willClose = !willCreateAccount && !willAddAccounts
const onClick = willClose ? onCloseModal : onClickAdd
const onClick = willClose
? onCloseModal
: async () => {
await onClickAdd()
transitionTo('finish')
}
return (
<Fragment>
{currency && <CurrencyBadge mr="auto" currency={currency} />}
{scanStatus === 'error' && (
<Button mr={2} onClick={() => setState({ scanStatus: 'scanning', err: null })}>
<Button mr={2} onClick={() => setScanStatus('scanning')}>
{t('app:common.retry')}
</Button>
)}
{scanStatus === 'scanning' && (
<Button mr={2} onClick={() => setState({ scanStatus: 'finished' })}>
<Button mr={2} onClick={() => setScanStatus('finished')}>
{t('app:common.stop')}
</Button>
)}

4
src/helpers/promise.js

@ -27,3 +27,7 @@ export function retry<A>(f: () => Promise<A>, options?: $Shape<typeof defaults>)
})
}
}
export function idleCallback() {
return new Promise(resolve => window.requestIdleCallback(resolve))
}

Loading…
Cancel
Save