From e273c1c31932bf25992d1966d576b04ed9b7f84c Mon Sep 17 00:00:00 2001 From: "Matt Condon (shrugs)" Date: Tue, 30 Jul 2019 15:13:58 +0200 Subject: [PATCH 01/43] checkpoint: using why-did-you-render --- package-lock.json | 42 ++++++ package.json | 3 + src/Bridge.js | 6 +- src/form/Autosaver.js | 20 +++ src/form/BridgeForm.js | 20 +++ src/form/FormError.js | 9 ++ src/form/Inputs.js | 56 ++++++++ src/form/SubmitButton.js | 35 +++++ src/indigo-react/components/Input.js | 72 ++++++----- src/views/Activate.js | 8 +- src/views/Activate/ActivateCode.js | 185 +++++++++++++-------------- 11 files changed, 319 insertions(+), 137 deletions(-) create mode 100644 src/form/Autosaver.js create mode 100644 src/form/BridgeForm.js create mode 100644 src/form/FormError.js create mode 100644 src/form/Inputs.js create mode 100644 src/form/SubmitButton.js diff --git a/package-lock.json b/package-lock.json index 2eaf67948..b1e8cdc97 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6918,6 +6918,19 @@ } } }, + "final-form": { + "version": "4.18.2", + "resolved": "https://registry.npmjs.org/final-form/-/final-form-4.18.2.tgz", + "integrity": "sha512-VQx/5x9M4CiC8fG678Dm1IS3mXvBl7ZNIUx5tUZCk00lFImJzQix4KO0+eGtl49sha2bYOxuYn8jRJiq6sazXA==", + "requires": { + "@babel/runtime": "^7.3.1" + } + }, + "final-form-set-field-data": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/final-form-set-field-data/-/final-form-set-field-data-1.0.2.tgz", + "integrity": "sha512-gAnENimyQ5GW3OEGca5pbwm4lYshW2orzfBlPUYqzcm7ZxkQrVO8FqCAgEcCM+Rq9U1OU0q+D+UkqETvvDY6jw==" + }, "finalhandler": { "version": "1.1.1", "resolved": "http://registry.npmjs.org/finalhandler/-/finalhandler-1.1.1.tgz", @@ -14511,6 +14524,25 @@ "resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-5.1.6.tgz", "integrity": "sha512-X1Y+0jR47ImDVr54Ab6V9eGk0Hnu7fVWGeHQSOXHf/C2pF9c6uy3gef8QUeuUiWlNb0i08InPSE5a/KJzNzw1Q==" }, + "react-final-form": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/react-final-form/-/react-final-form-6.3.0.tgz", + "integrity": "sha512-jijhXR1fFGUBQwNOSqF4MK8XJO7Ynl1p8vcFsnQS0INSkGI52+4IagjUgtHj3w8EviIHPFK/Eflji6FELUl07w==", + "requires": { + "@babel/runtime": "^7.4.5", + "ts-essentials": "^2.0.8" + }, + "dependencies": { + "@babel/runtime": { + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.5.5.tgz", + "integrity": "sha512-28QvEGyQyNkB0/m2B4FU7IEZGK2NUrcMtT6BZEFALTguLk+AUT6ofsHtPk5QyjAdUkpMJ+/Em+quwz4HOt30AQ==", + "requires": { + "regenerator-runtime": "^0.13.2" + } + } + } + }, "react-hot-loader": { "version": "4.12.0", "resolved": "https://registry.npmjs.org/react-hot-loader/-/react-hot-loader-4.12.0.tgz", @@ -14721,6 +14753,11 @@ "regenerate": "^1.4.0" } }, + "regenerator-runtime": { + "version": "0.13.3", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.3.tgz", + "integrity": "sha512-naKIZz2GQ8JWh///G7L3X6LaQUAMp2lvb1rvwwsURe/VXwD6VMfr+/1NuNw3ag8v2kY1aQ/go5SNn79O9JU7yw==" + }, "regenerator-transform": { "version": "0.14.0", "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.14.0.tgz", @@ -17199,6 +17236,11 @@ "solc": "0.4.24" } }, + "ts-essentials": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/ts-essentials/-/ts-essentials-2.0.12.tgz", + "integrity": "sha512-3IVX4nI6B5cc31/GFFE+i8ey/N2eA0CZDbo6n0yrz0zDX8ZJ8djmU1p+XRz7G3is0F3bB3pu2pAroFdAWQKU3w==" + }, "ts-pnp": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/ts-pnp/-/ts-pnp-1.1.2.tgz", diff --git a/package.json b/package.json index dfc5b7990..7b011a90a 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,8 @@ "ethereum-blockies-base64": "^1.0.2", "ethereumjs-tx": "^1.3.7", "file-saver": "^2.0.0", + "final-form": "^4.18.2", + "final-form-set-field-data": "^1.0.2", "folktale": "^2.3.1", "jszip": "^3.1.5", "keccak": "^1.4.0", @@ -38,6 +40,7 @@ "react": "^16.8.6", "react-app-rewire-hot-loader": "^2.0.1", "react-dom": "^16.8.6", + "react-final-form": "^6.3.0", "react-hot-loader": "^4.12.0", "react-scripts": "3.0.1", "react-teleporter": "^1.1.0", diff --git a/src/Bridge.js b/src/Bridge.js index e43d2be09..883f800eb 100644 --- a/src/Bridge.js +++ b/src/Bridge.js @@ -41,11 +41,7 @@ function useInitialRoutes() { const hasImpliedTicket = !!useImpliedTicket(); if (IS_STUBBED) { - return [ - { key: ROUTE_NAMES.LOGIN }, - { key: ROUTE_NAMES.POINTS }, - { key: ROUTE_NAMES.POINT }, - ]; + return [{ key: ROUTE_NAMES.ACTIVATE }]; } return hasImpliedTicket diff --git a/src/form/Autosaver.js b/src/form/Autosaver.js new file mode 100644 index 000000000..5d15e384b --- /dev/null +++ b/src/form/Autosaver.js @@ -0,0 +1,20 @@ +import { useEffect } from 'react'; +import { useFormState } from 'react-final-form'; + +export default function Autosaver({ onValues }) { + const { valid, validating, values } = useFormState({ + subscription: { + valid: true, + validating: true, + values: true, + }, + }); + + useEffect(() => { + if (valid && !validating) { + onValues && onValues(values); + } + }, [onValues, valid, validating, values]); + + return null; +} diff --git a/src/form/BridgeForm.js b/src/form/BridgeForm.js new file mode 100644 index 000000000..0d50e3dba --- /dev/null +++ b/src/form/BridgeForm.js @@ -0,0 +1,20 @@ +import React from 'react'; +import { Form } from 'react-final-form'; +import setFieldData from 'final-form-set-field-data'; + +import FormError from './FormError'; +import Autosaver from './Autosaver'; + +export default function BridgeForm({ children, onValues, ...rest }) { + return ( +
+ {formProps => ( + <> + {children(formProps)} + {onValues && } + + + )} + + ); +} diff --git a/src/form/FormError.js b/src/form/FormError.js new file mode 100644 index 000000000..1d2b16d8c --- /dev/null +++ b/src/form/FormError.js @@ -0,0 +1,9 @@ +import React from 'react'; +import { useFormState } from 'react-final-form'; +import { ErrorText } from 'indigo-react'; + +export default function FormError() { + const { submitError } = useFormState({ subscription: { submitError: true } }); + + return submitError ? {submitError} : null; +} diff --git a/src/form/Inputs.js b/src/form/Inputs.js new file mode 100644 index 000000000..b0525c319 --- /dev/null +++ b/src/form/Inputs.js @@ -0,0 +1,56 @@ +import React, { useMemo } from 'react'; +import { Input, AccessoryIcon } from 'indigo-react'; +import { useField } from 'react-final-form'; +import { + validateNotEmpty, + validateTicket, + kDefaultValidator, +} from 'lib/validators'; +import { compose } from 'lib/lib'; + +const PLACEHOLDER_TICKET = '~sampel-ticlyt-migfun-falmel'; + +const buildValidator = ( + validators = [], + fn = x => undefined +) => async value => { + console.log('validating', value); + return ( + compose( + ...validators, + kDefaultValidator + )(value).error || (await fn(value)) + ); +}; + +const kTicketValidators = [validateTicket, validateNotEmpty]; +export function TicketInput({ name, validators = [], config = {}, ...rest }) { + const { valid, error, validating } = useField(name, { + subscription: { error: true, validating: true }, + }); + + const validate = useMemo( + () => + buildValidator([...validators, ...kTicketValidators], config.validate), + [config.validate, validators] + ); + + return ( + + ) : validating ? ( + + ) : valid ? ( + + ) : null + } + config={{ validate }} + mono + {...rest} + /> + ); +} diff --git a/src/form/SubmitButton.js b/src/form/SubmitButton.js new file mode 100644 index 000000000..7055d2982 --- /dev/null +++ b/src/form/SubmitButton.js @@ -0,0 +1,35 @@ +import React from 'react'; +import cn from 'classnames'; + +import { ForwardButton } from 'components/Buttons'; + +import { blinkIf } from 'components/Blinky'; +import { useFormState } from 'react-final-form'; + +export default function SubmitButton({ + as: As = ForwardButton, + className, + children, + handleSubmit, + ...rest +}) { + const { valid, validating, submitting } = useFormState({ + subscription: { + valid: true, + validating: true, + submitting: true, + }, + }); + + return ( + + {children} + + ); +} diff --git a/src/indigo-react/components/Input.js b/src/indigo-react/components/Input.js index fe48eba13..b9944ec82 100644 --- a/src/indigo-react/components/Input.js +++ b/src/indigo-react/components/Input.js @@ -3,50 +3,53 @@ import cn from 'classnames'; import Flex from './Flex'; import { ErrorText } from './Typography'; +import { useField } from 'react-final-form'; -export default React.memo(function Input({ +export default function Input({ // visuals type, name, label, className, accessory, + disabled = false, mono = false, // callbacks onEnter, - // state from hook - focused, - pass, - syncPass, - visiblyPassed, - error, - hintError, - data, - bind, - autoFocus, - disabled, - touched, - - // ignored - initialValue, - validators, - transformers, + // state + config, // extra - textarea = false, ...rest }) { - // NB(shrugs): we disable exhaustive deps because we don't want the callbacks - // (whose identity might change between renders) to trigger a re-notify of - // state that we already know - // this also happens to prevent render loops when rendering a map of inputs + const { + input, + meta: { + active, + data, + dirty, + error, + modified, + pristine, + submitError, + submitFailed, + submitSucceeded, + submitting, + touched, + valid, + validating, + visited, + }, + } = useField(name, config); + + disabled = disabled || submitting; // notify parent of enter keypress iff not disabled and passing const onKeyPress = useCallback( - e => !disabled && pass && e.key === 'Enter' && onEnter && onEnter(), - [disabled, pass] // eslint-disable-line react-hooks/exhaustive-deps + e => !disabled && valid && e.key === 'Enter' && onEnter && onEnter(), + [disabled, valid] // eslint-disable-line react-hooks/exhaustive-deps ); return ( @@ -83,21 +86,20 @@ export default React.memo(function Input({ 'bg-gray1': disabled, }, { - gray4: !focused && !touched, - black: focused || touched, + gray4: !active && !touched, + black: active || touched, }, { - 'b-green3': visiblyPassed, - 'b-black': focused && !visiblyPassed, - 'b-yellow3': !focused && hintError, - 'b-gray2': !focused && !hintError && !visiblyPassed, + 'b-green3': valid, + 'b-black': active && !valid, + 'b-yellow3': !active && touched && error, + 'b-gray2': !active && !error && !valid, } - // TODO: inputClassName ? )} id={name} name={name} onKeyPress={onKeyPress} - {...bind} + {...input} flex /> {accessory && ( @@ -114,11 +116,11 @@ export default React.memo(function Input({ )} - {error && ( + {touched && error && ( {error} )} ); -}); +} diff --git a/src/views/Activate.js b/src/views/Activate.js index 6ce7bf165..e662fc01f 100644 --- a/src/views/Activate.js +++ b/src/views/Activate.js @@ -1,11 +1,12 @@ import React from 'react'; import useRouter from 'lib/useRouter'; +import { LocalRouterProvider } from 'lib/LocalRouter'; +import { useSyncKnownPoints } from 'lib/useSyncPoints'; import { ActivateFlowProvider } from './Activate/ActivateFlow'; import useActivateFlowState from './Activate/useActivateFlowState'; import ActivateCode from './Activate/ActivateCode'; -import { LocalRouterProvider } from 'lib/LocalRouter'; import ActivatePassport from './Activate/ActivatePassport'; import Disclaimer from './Disclaimer'; @@ -29,6 +30,11 @@ export default function Activate() { initialRoutes: [{ key: NAMES.CODE }], }); + // when we know the derived point, ensure we have the data to display it + useSyncKnownPoints( + [state.derivedPoint.getOrElse(null)].filter(p => p !== null) + ); + return ( diff --git a/src/views/Activate/ActivateCode.js b/src/views/Activate/ActivateCode.js index e4a0f0fbf..7b3d66892 100644 --- a/src/views/Activate/ActivateCode.js +++ b/src/views/Activate/ActivateCode.js @@ -1,7 +1,7 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useRef } from 'react'; import { Just, Nothing } from 'folktale/maybe'; import * as azimuth from 'azimuth-js'; -import { Grid, Input, H4, ErrorText } from 'indigo-react'; +import { Grid, H4 } from 'indigo-react'; import View from 'components/View'; import { ForwardButton } from 'components/Buttons'; @@ -9,12 +9,9 @@ import Passport from 'components/Passport'; import { useHistory } from 'store/history'; -import { useTicketInput } from 'lib/useInputs'; import * as need from 'lib/need'; import { ROUTE_NAMES } from 'lib/routeNames'; -import { useSyncKnownPoints } from 'lib/useSyncPoints'; import FooterButton from 'components/FooterButton'; -import { blinkIf } from 'components/Blinky'; import { DEFAULT_HD_PATH, walletFromMnemonic } from 'lib/wallet'; import { useNetwork } from 'store/network'; import { generateWallet } from 'lib/invite'; @@ -22,111 +19,96 @@ import { generateTemporaryOwnershipWallet } from 'lib/walletgen'; import { useActivateFlow } from './ActivateFlow'; import { useLocalRouter } from 'lib/LocalRouter'; import useImpliedTicket from 'lib/useImpliedTicket'; +import timeout from 'lib/timeout'; import useHasDisclaimed from 'lib/useHasDisclaimed'; +import BridgeForm from 'form/BridgeForm'; +import SubmitButton from 'form/SubmitButton'; +import { TicketInput } from 'form/Inputs'; + export default function ActivateCode() { const history = useHistory(); const { names, push } = useLocalRouter(); const { contracts } = useNetwork(); const impliedTicket = useImpliedTicket(); const [hasDisclaimed] = useHasDisclaimed(); - const [generalError, setGeneralError] = useState(); - const [deriving, setDeriving] = useState(false); + + const cachedInviteWallet = useRef(Nothing()); + const cachedPoint = useRef(); + const { - derivedWallet, setDerivedWallet, setInviteWallet, derivedPoint, setDerivedPoint, } = useActivateFlow(); - const [ticketInput, { pass: validTicket, data: ticket }] = useTicketInput({ - name: 'ticket', - label: 'Activation Code', - initialValue: impliedTicket || '', - autoFocus: true, - }); - const goToLogin = useCallback(() => history.popAndPush(ROUTE_NAMES.LOGIN), [ history, ]); const goToPassport = useCallback(() => { push(names.PASSPORT); + if (!hasDisclaimed) { push(names.DISCLAIMER); } }, [names, push, hasDisclaimed]); - const pass = derivedWallet.matchWith({ - Nothing: () => false, - Just: () => true, - }); + const validate = useCallback( + async ticket => { + await timeout(100); // allow the ui changes to flush before we lag it out - useEffect(() => { - if (validTicket) { const _contracts = need.contracts(contracts); + const { seed } = await generateTemporaryOwnershipWallet(ticket); + + // TODO(fang): isn't all this accessible in the ownership object? + const inviteWallet = walletFromMnemonic(seed, DEFAULT_HD_PATH); + cachedInviteWallet.current = inviteWallet; + + const _inviteWallet = need.wallet(inviteWallet); + + const owned = await azimuth.azimuth.getOwnedPoints( + _contracts, + _inviteWallet.address + ); + const transferring = await azimuth.azimuth.getTransferringFor( + _contracts, + _inviteWallet.address + ); + const incoming = [...owned, ...transferring]; + + if (incoming.length > 0) { + if (incoming.length > 1) { + // TODO: warnings + // setGeneralError( + // 'This invite code has multiple points available.\n' + + // "Once you've activated this point, activate the next with the same process." + // ); + } - setDeriving(true); - // when the ticket becomes valid, derive the point - (async () => { - const { seed } = await generateTemporaryOwnershipWallet(ticket); - - //TODO isn't all this accessible in the ownership object? - const inviteWallet = walletFromMnemonic(seed, DEFAULT_HD_PATH); - const _inviteWallet = need.wallet(inviteWallet); - - const owned = await azimuth.azimuth.getOwnedPoints( - _contracts, - _inviteWallet.address - ); - const transferring = await azimuth.azimuth.getTransferringFor( - _contracts, - _inviteWallet.address + const point = parseInt(incoming[0], 10); + // setDerivedPoint(Just(point)); + cachedPoint.current = point; + } else { + return ( + 'Invite code has no claimable point.\n' + + 'Check your invite code and try again?' ); - const incoming = [...owned, ...transferring]; - - let realPoint = Nothing(); - let wallet = Nothing(); - - if (incoming.length > 0) { - const pointNum = parseInt(incoming[0], 10); - realPoint = Just(pointNum); - wallet = Just(await generateWallet(pointNum)); - - if (incoming.length > 1) { - setGeneralError( - 'This invite code has multiple points available.\n' + - "Once you've activated this point, activate the next with the same process." - ); - } else { - setGeneralError(false); - } - } else { - setGeneralError( - 'Invite code has no claimable point.\n' + - 'Check your invite code and try again?' - ); - } + } + }, + [contracts] + ); - setDerivedPoint(realPoint); - setDerivedWallet(wallet); - setInviteWallet(inviteWallet); - setDeriving(false); - })(); - } else { - setGeneralError(false); - } - }, [ - validTicket, - contracts, - ticket, - setDerivedPoint, - setDerivedWallet, - setInviteWallet, - ]); + const onSubmit = useCallback( + async values => { + setInviteWallet(cachedInviteWallet.current); + setDerivedPoint(Just(cachedPoint.current)); + setDerivedWallet(Just(await generateWallet(cachedPoint))); - // when we know the derived point, ensure we have the data to display it - useSyncKnownPoints([derivedPoint.getOrElse(null)].filter(p => p !== null)); + goToPassport(); + }, + [goToPassport, setDerivedPoint, setDerivedWallet, setInviteWallet] + ); return ( @@ -135,23 +117,34 @@ export default function ActivateCode() { Activate - - {generalError && ( - - {generalError}{' '} - - )} - - {deriving && 'Deriving...'} - {!deriving && 'Go'} - + + {({ validating, submitting, handleSubmit }) => ( + <> + + + + {validating + ? 'Deriving...' + : submitting + ? 'Generating...' + : 'Go'} + + + )} + Login From f231080d837f07268e95caadc0b98016c190b142 Mon Sep 17 00:00:00 2001 From: "Matt Condon (shrugs)" Date: Tue, 30 Jul 2019 16:21:58 +0200 Subject: [PATCH 02/43] checkpoint: test final-form on mnemonic page --- package-lock.json | 8 ++ package.json | 1 + src/Bridge.js | 8 +- src/form/Autosaver.js | 4 +- src/form/Condition.js | 11 ++ src/form/Inputs.js | 101 ++++++++++++++-- src/form/SubmitButton.js | 5 +- src/index.js | 4 + src/indigo-react/components/CheckboxInput.js | 44 +++---- src/lib/transformers.js | 3 +- src/store/wallet.js | 7 +- src/views/Activate/ActivateCode.js | 9 +- src/views/Activate/PassportVerify.js | 52 +++++---- src/views/Login/Mnemonic.js | 114 ++++++++++--------- 14 files changed, 251 insertions(+), 120 deletions(-) create mode 100644 src/form/Condition.js diff --git a/package-lock.json b/package-lock.json index b1e8cdc97..bda0b4c74 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2143,6 +2143,14 @@ "@xtuc/long": "4.2.2" } }, + "@welldone-software/why-did-you-render": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/@welldone-software/why-did-you-render/-/why-did-you-render-3.2.3.tgz", + "integrity": "sha512-dUMkjsVsCfIo+IEmiMyb/FGKVbXrzbGqLvDTCn0ad5drNmD+BGoU/7Z+Nc7ckkV0F1G4vzs1XQrAcIxyPa8Ssw==", + "requires": { + "lodash": "^4" + } + }, "@xtuc/ieee754": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", diff --git a/package.json b/package.json index 7b011a90a..1cb36f4c9 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@hot-loader/react-dom": "^16.8.6", "@ledgerhq/hw-app-eth": "^4.61.0", "@ledgerhq/hw-transport-u2f": "^4.61.0", + "@welldone-software/why-did-you-render": "^3.2.3", "PaperCollateralRenderer": "github:urbit/PaperCollateralRenderer#rc-immediate", "async-retry": "^1.2.3", "azimuth-js": "^0.15.1", diff --git a/src/Bridge.js b/src/Bridge.js index 883f800eb..203aac504 100644 --- a/src/Bridge.js +++ b/src/Bridge.js @@ -10,7 +10,7 @@ import Provider from 'store/Provider'; import { ROUTE_NAMES } from 'lib/routeNames'; import { ROUTES } from 'lib/router'; import { NETWORK_TYPES } from 'lib/network'; -import { walletFromMnemonic } from 'lib/wallet'; +import { walletFromMnemonic, WALLET_TYPES } from 'lib/wallet'; import { isDevelopment } from 'lib/flags'; import useImpliedTicket from 'lib/useImpliedTicket'; import useHasDisclaimed from 'lib/useHasDisclaimed'; @@ -41,7 +41,11 @@ function useInitialRoutes() { const hasImpliedTicket = !!useImpliedTicket(); if (IS_STUBBED) { - return [{ key: ROUTE_NAMES.ACTIVATE }]; + return [ + { key: ROUTE_NAMES.LOGIN }, + // { key: ROUTE_NAMES.POINTS }, + // { key: ROUTE_NAMES.POINT }, + ]; } return hasImpliedTicket diff --git a/src/form/Autosaver.js b/src/form/Autosaver.js index 5d15e384b..7ce434806 100644 --- a/src/form/Autosaver.js +++ b/src/form/Autosaver.js @@ -11,8 +11,8 @@ export default function Autosaver({ onValues }) { }); useEffect(() => { - if (valid && !validating) { - onValues && onValues(values); + if (!validating) { + onValues && onValues({ valid: valid && !validating, values }); } }, [onValues, valid, validating, values]); diff --git a/src/form/Condition.js b/src/form/Condition.js new file mode 100644 index 000000000..11dc798cf --- /dev/null +++ b/src/form/Condition.js @@ -0,0 +1,11 @@ +import { useField } from 'react-final-form'; + +// inspired by: https://codesandbox.io/s/lm4p3m92q +// renders childen if the relevant field's value is the test value +export default function Condition({ when, is, children }) { + const { + input: { value }, + } = useField(when, { subscription: { value: true } }); + + return value === is ? children : null; +} diff --git a/src/form/Inputs.js b/src/form/Inputs.js index b0525c319..f436163e1 100644 --- a/src/form/Inputs.js +++ b/src/form/Inputs.js @@ -1,20 +1,37 @@ import React, { useMemo } from 'react'; import { Input, AccessoryIcon } from 'indigo-react'; import { useField } from 'react-final-form'; + import { validateNotEmpty, validateTicket, kDefaultValidator, + validateMnemonic, } from 'lib/validators'; import { compose } from 'lib/lib'; +import { prependSig } from 'lib/transformers'; +import { DEFAULT_HD_PATH } from 'lib/wallet'; + +// TODO: why is `validate` called when routing away? new props? +// active not true: https://github.com/final-form/react-final-form/issues/558 +const kEmptyValidators = []; // necessary for referential stability +const kEmptyTransformers = []; // also for referential stability + +const PLACEHOLDER_POINT = '~sampel-ponnym'; +const PLACEHOLDER_HD_PATH = DEFAULT_HD_PATH; +const PLACEHOLDER_MNEMONIC = + 'example crew supreme gesture quantum web media hazard theory mercy wing kitten'; const PLACEHOLDER_TICKET = '~sampel-ticlyt-migfun-falmel'; +const PLACEHOLDER_ADDRESS = '0x12345abcdeDB11D175F123F6891AA64F01c24F7d'; +const PLACEHOLDER_PRIVATE_KEY = + '0x12345abcdee6beb2f323fab48b432925c9785808d33a6ca6d7ba00b45e9370c3'; const buildValidator = ( validators = [], fn = x => undefined ) => async value => { - console.log('validating', value); + // console.log('validating:', value); return ( compose( ...validators, @@ -24,19 +41,27 @@ const buildValidator = ( }; const kTicketValidators = [validateTicket, validateNotEmpty]; -export function TicketInput({ name, validators = [], config = {}, ...rest }) { +export function TicketInput({ + name, + validators = kEmptyValidators, + transformers = kEmptyTransformers, + validate, + ...rest +}) { const { valid, error, validating } = useField(name, { subscription: { error: true, validating: true }, }); - const validate = useMemo( - () => - buildValidator([...validators, ...kTicketValidators], config.validate), - [config.validate, validators] + const _validate = useMemo( + () => buildValidator([...validators, ...kTicketValidators], validate), + [validate, validators] ); + const _format = (value, name) => prependSig(value); + return ( ) : null } - config={{ validate }} + config={{ validate: _validate, format: _format }} + mono + {...rest} + /> + ); +} + +const kMnemonicValidators = [validateMnemonic, validateNotEmpty]; +export function MnemonicInput({ + name, + validators = kEmptyValidators, + transformers = kEmptyTransformers, + validate, + ...rest +}) { + const _validate = useMemo( + () => buildValidator([...validators, ...kMnemonicValidators], validate), + [validate, validators] + ); + + return ( + ); } + +const kHdPathValidators = [validateNotEmpty]; +export function HdPathInput({ + name, + validators = kEmptyValidators, + transformers = kEmptyTransformers, + validate, + ...rest +}) { + const _validate = useMemo( + () => buildValidator([...validators, ...kHdPathValidators], validate), + [validate, validators] + ); + + return ( + + ); +} + +export function PassphraseInput({ ...rest }) { + return ( + + ); +} diff --git a/src/form/SubmitButton.js b/src/form/SubmitButton.js index 7055d2982..46f3e5c9e 100644 --- a/src/form/SubmitButton.js +++ b/src/form/SubmitButton.js @@ -13,11 +13,12 @@ export default function SubmitButton({ handleSubmit, ...rest }) { - const { valid, validating, submitting } = useFormState({ + const { valid, validating, submitting, submitError } = useFormState({ subscription: { valid: true, validating: true, submitting: true, + submitError: true, }, }); @@ -29,7 +30,7 @@ export default function SubmitButton({ onClick={handleSubmit} solid {...rest}> - {children} + {submitError ? 'Error submitting' : children} ); } diff --git a/src/index.js b/src/index.js index 96b5d4962..71255c0ce 100644 --- a/src/index.js +++ b/src/index.js @@ -3,4 +3,8 @@ import ReactDOM from 'react-dom'; import Bridge from './Bridge'; +if (process.env.NODE_ENV !== 'production') { + require('@welldone-software/why-did-you-render')(React); +} + ReactDOM.render(, document.getElementById('root')); diff --git a/src/indigo-react/components/CheckboxInput.js b/src/indigo-react/components/CheckboxInput.js index ff1ce49ee..6447ba6e2 100644 --- a/src/indigo-react/components/CheckboxInput.js +++ b/src/indigo-react/components/CheckboxInput.js @@ -2,6 +2,7 @@ import React from 'react'; import cn from 'classnames'; import Flex from './Flex'; +import { useField } from 'react-final-form'; export default function CheckboxInput({ // visuals @@ -9,30 +10,15 @@ export default function CheckboxInput({ label, className, - // callbacks - onEnter, - - // state from hook - focused, - pass, - syncPass, - visiblyPassed, - error, - hintError, - data, - bind, - autoFocus, disabled, - touched, +}) { + const { + input, + meta: { submitting }, + } = useField(name); - // ignored - initialValue, - validators, - transformers, + disabled = disabled || submitting; - // extra - ...rest -}) { return ( input.onChange(!input.value)} /> {/* and then display a prettier one in its stead */} - {data && '✓'} + {input.value && '✓'} {label} diff --git a/src/lib/transformers.js b/src/lib/transformers.js index 0df6389bd..b7217f04e 100644 --- a/src/lib/transformers.js +++ b/src/lib/transformers.js @@ -1,6 +1,7 @@ // import { fill } from './lib' -export const prependSig = s => (s.charAt(0) !== '~' ? `~${s}` : s); +export const prependSig = (s = '') => + s.length && s.charAt(0) !== '~' ? `~${s}` : s; export const convertToNumber = s => { try { diff --git a/src/store/wallet.js b/src/store/wallet.js index ead08cf15..9533e2ffa 100644 --- a/src/store/wallet.js +++ b/src/store/wallet.js @@ -33,7 +33,12 @@ const DEFAULT_WALLET_TYPE = WALLET_TYPES.TICKET; // JSON keystore files, and Metamask authentication, it wraps an // 'EthereumWallet'. function _useWallet(initialWallet = Nothing(), initialMnemonic = Nothing()) { - const [walletType, _setWalletType] = useState(DEFAULT_WALLET_TYPE); + const [walletType, _setWalletType] = useState(() => + initialMnemonic.matchWith({ + Nothing: () => DEFAULT_WALLET_TYPE, + Just: () => WALLET_TYPES.MNEMONIC, + }) + ); const [walletHdPath, setWalletHdPath] = useState(DEFAULT_HD_PATH); const [wallet, _setWallet] = useState(initialWallet); const [urbitWallet, _setUrbitWallet] = useState(Nothing()); diff --git a/src/views/Activate/ActivateCode.js b/src/views/Activate/ActivateCode.js index 7b3d66892..ff85f53cc 100644 --- a/src/views/Activate/ActivateCode.js +++ b/src/views/Activate/ActivateCode.js @@ -87,7 +87,7 @@ export default function ActivateCode() { } const point = parseInt(incoming[0], 10); - // setDerivedPoint(Just(point)); + setDerivedPoint(Just(point)); cachedPoint.current = point; } else { return ( @@ -96,18 +96,17 @@ export default function ActivateCode() { ); } }, - [contracts] + [contracts, setDerivedPoint] ); const onSubmit = useCallback( async values => { setInviteWallet(cachedInviteWallet.current); - setDerivedPoint(Just(cachedPoint.current)); setDerivedWallet(Just(await generateWallet(cachedPoint))); goToPassport(); }, - [goToPassport, setDerivedPoint, setDerivedWallet, setInviteWallet] + [goToPassport, setDerivedWallet, setInviteWallet] ); return ( @@ -127,7 +126,7 @@ export default function ActivateCode() { as={TicketInput} name="ticket" label="Activation Code" - config={{ validate }} + validate={validate} autoFocus /> diff --git a/src/views/Activate/PassportVerify.js b/src/views/Activate/PassportVerify.js index d0c514f9b..386132316 100644 --- a/src/views/Activate/PassportVerify.js +++ b/src/views/Activate/PassportVerify.js @@ -1,13 +1,14 @@ import React, { useCallback, useMemo } from 'react'; -import { Grid, Input, P } from 'indigo-react'; +import { Grid, P } from 'indigo-react'; import * as need from 'lib/need'; import { useLocalRouter } from 'lib/LocalRouter'; -import { useTicketInput } from 'lib/useInputs'; import { validateExactly } from 'lib/validators'; import { isDevelopment } from 'lib/flags'; -import { ForwardButton } from 'components/Buttons'; +import SubmitButton from 'form/SubmitButton'; +import { TicketInput } from 'form/Inputs'; +import BridgeForm from 'form/BridgeForm'; import { useActivateFlow } from './ActivateFlow'; import PassportView from './PassportView'; @@ -24,13 +25,8 @@ export default function PassportVerify({ className }) { () => [validateExactly(ticket, 'Does not match expected master ticket.')], [ticket] ); - const [ticketInput, { pass }] = useTicketInput({ - name: 'ticket', - label: 'Master Ticket', - initialValue: STUB_VERIFY_TICKET ? ticket : undefined, - autoFocus: true, - validators, - }); + + const onSubmit = useCallback(() => goToTransfer(), [goToTransfer]); return ( @@ -40,16 +36,32 @@ export default function PassportVerify({ className }) { should be a folder of image files. One of them is your Master Ticket. Open it and enter the 4 word phrase below (with hyphens). - - - Verify - + + {({ handleSubmit }) => ( + <> + + + + Verify + + + )} + ); diff --git a/src/views/Login/Mnemonic.js b/src/views/Login/Mnemonic.js index b16fc0614..54d7def6a 100644 --- a/src/views/Login/Mnemonic.js +++ b/src/views/Login/Mnemonic.js @@ -1,18 +1,15 @@ -import React, { useEffect } from 'react'; +import React, { useCallback } from 'react'; import cn from 'classnames'; import { Just, Nothing } from 'folktale/maybe'; -import { Grid, Input, CheckboxInput } from 'indigo-react'; +import { Grid, CheckboxInput } from 'indigo-react'; import { useWallet } from 'store/wallet'; -import { - usePassphraseInput, - useMnemonicInput, - useHdPathInput, - useCheckboxInput, -} from 'lib/useInputs'; import { walletFromMnemonic, WALLET_TYPES } from 'lib/wallet'; import useLoginView from 'lib/useLoginView'; +import { MnemonicInput, HdPathInput, PassphraseInput } from 'form/Inputs'; +import BridgeForm from 'form/BridgeForm'; +import Condition from 'form/Condition'; export default function Mnemonic({ className }) { useLoginView(WALLET_TYPES.MNEMONIC); @@ -24,61 +21,74 @@ export default function Mnemonic({ className }) { setWalletHdPath, } = useWallet(); - const [advancedInput, { data: useAdvanced }] = useCheckboxInput({ - name: 'advanced', - label: 'Passphrase & HD Path', - initialValue: false, - }); - - const [mnemonicInput, { pass, data: mnemonic }] = useMnemonicInput({ - name: 'mnemonic', - label: 'BIP39 Mnemonic', - autoFocus: true, - }); - - const [passphraseInput, { data: passphrase }] = usePassphraseInput({ - name: 'passphrase', - label: 'Wallet Passphrase', - }); - - const [hdPathInput, { data: hdPath }] = useHdPathInput({ - name: 'hdpath', - label: 'HD Path', - initialValue: walletHdPath, - }); - // when the properties change, re-derive wallet and set global state - useEffect(() => { - if (pass) { - setWalletHdPath(hdPath); - setAuthMnemonic(Just(mnemonic)); - setWallet(walletFromMnemonic(mnemonic, hdPath, passphrase)); - } else { - setAuthMnemonic(Nothing()); - setWallet(Nothing()); - } - }, [ - pass, - mnemonic, - passphrase, - hdPath, - setWallet, - setAuthMnemonic, - setWalletHdPath, - ]); + const onValues = useCallback( + ({ valid, values }) => { + console.log(valid, values); + if (valid) { + setWalletHdPath(values.hdpath); + setAuthMnemonic(Just(values.mnemonic)); + setWallet( + walletFromMnemonic(values.mnemonic, values.hdpath, values.passphrase) + ); + } else { + setAuthMnemonic(Nothing()); + setWallet(Nothing()); + } + }, + [setAuthMnemonic, setWallet, setWalletHdPath] + ); return ( - + undefined} + initialValues={{ hdpath: walletHdPath, advanced: false }}> + {() => ( + <> + + + + + + + + + + + )} + - {useAdvanced && ( + {/* {useAdvanced && ( <> )} - + */} ); } From 6b3335e88a7cd7bc6f4277f0745ab343563886ae Mon Sep 17 00:00:00 2001 From: "Matt Condon (shrugs)" Date: Wed, 31 Jul 2019 11:41:02 +0200 Subject: [PATCH 03/43] chore: minor updates to comments, code re-org --- src/indigo-react/components/Input.js | 14 +++++++------- src/views/Activate/ActivateCode.js | 8 ++++++-- src/views/Login/Mnemonic.js | 3 +-- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/src/indigo-react/components/Input.js b/src/indigo-react/components/Input.js index b9944ec82..1bc1adf47 100644 --- a/src/indigo-react/components/Input.js +++ b/src/indigo-react/components/Input.js @@ -54,8 +54,8 @@ export default function Input({ return ( {label} - + {accessory && (
{ await timeout(100); // allow the ui changes to flush before we lag it out @@ -99,6 +102,7 @@ export default function ActivateCode() { [contracts, setDerivedPoint] ); + // set our state on submission const onSubmit = useCallback( async values => { setInviteWallet(cachedInviteWallet.current); @@ -112,8 +116,8 @@ export default function ActivateCode() { return ( - - + + Activate { - console.log(valid, values); if (valid) { setWalletHdPath(values.hdpath); setAuthMnemonic(Just(values.mnemonic)); @@ -44,7 +43,7 @@ export default function Mnemonic({ className }) { undefined} - initialValues={{ hdpath: walletHdPath, advanced: false }}> + initialValues={{ hdpath: walletHdPath }}> {() => ( <> Date: Wed, 31 Jul 2019 21:33:31 +0200 Subject: [PATCH 04/43] feat: initial implementation of final-form on login --- src/Bridge.js | 6 +- src/components/Accordion.js | 6 +- src/form/Autosaver.js | 7 +- src/form/BridgeForm.js | 4 +- src/form/FormError.js | 13 +- src/form/Inputs.js | 182 ++++++---- src/form/UploadInput.js | 51 +++ src/form/ValidationPauser.js | 27 ++ src/indigo-react/components/AccessoryIcon.js | 2 +- src/indigo-react/components/CheckboxInput.js | 13 +- src/indigo-react/components/Input.js | 33 +- src/indigo-react/components/SelectInput.js | 74 ++-- src/indigo-react/components/ToggleInput.js | 34 +- src/lib/useInputs.js | 6 +- src/lib/validators.js | 13 + src/views/Activate/ActivateCode.js | 47 ++- src/views/Activate/PassportVerify.js | 21 +- src/views/Login.js | 22 +- src/views/Login/Advanced.js | 4 +- src/views/Login/ContinueButton.js | 26 ++ src/views/Login/Hardware.js | 4 +- src/views/Login/Keystore.js | 139 ++++---- src/views/Login/Ledger.js | 217 ++++++------ src/views/Login/Mnemonic.js | 60 ++-- src/views/Login/PrivateKey.js | 64 ++-- src/views/Login/Ticket.js | 340 ++++++++++--------- src/views/Login/Trezor.js | 170 ++++++---- 27 files changed, 911 insertions(+), 674 deletions(-) create mode 100644 src/form/UploadInput.js create mode 100644 src/form/ValidationPauser.js create mode 100644 src/views/Login/ContinueButton.js diff --git a/src/Bridge.js b/src/Bridge.js index 203aac504..e43d2be09 100644 --- a/src/Bridge.js +++ b/src/Bridge.js @@ -10,7 +10,7 @@ import Provider from 'store/Provider'; import { ROUTE_NAMES } from 'lib/routeNames'; import { ROUTES } from 'lib/router'; import { NETWORK_TYPES } from 'lib/network'; -import { walletFromMnemonic, WALLET_TYPES } from 'lib/wallet'; +import { walletFromMnemonic } from 'lib/wallet'; import { isDevelopment } from 'lib/flags'; import useImpliedTicket from 'lib/useImpliedTicket'; import useHasDisclaimed from 'lib/useHasDisclaimed'; @@ -43,8 +43,8 @@ function useInitialRoutes() { if (IS_STUBBED) { return [ { key: ROUTE_NAMES.LOGIN }, - // { key: ROUTE_NAMES.POINTS }, - // { key: ROUTE_NAMES.POINT }, + { key: ROUTE_NAMES.POINTS }, + { key: ROUTE_NAMES.POINT }, ]; } diff --git a/src/components/Accordion.js b/src/components/Accordion.js index b16741dd1..da165e06f 100644 --- a/src/components/Accordion.js +++ b/src/components/Accordion.js @@ -2,11 +2,13 @@ import React from 'react'; import { Grid, AccessoryIcon } from 'indigo-react'; export default function Accordion({ + className, views, options, currentTab, onTabChange, - className, + + // Tab props ...rest }) { const Tab = views[currentTab]; @@ -37,7 +39,7 @@ export default function Accordion({
- {isActive && } + {isActive && } ); diff --git a/src/form/Autosaver.js b/src/form/Autosaver.js index 7ce434806..30ec72024 100644 --- a/src/form/Autosaver.js +++ b/src/form/Autosaver.js @@ -1,7 +1,8 @@ import { useEffect } from 'react'; -import { useFormState } from 'react-final-form'; +import { useFormState, useForm } from 'react-final-form'; export default function Autosaver({ onValues }) { + const form = useForm(); const { valid, validating, values } = useFormState({ subscription: { valid: true, @@ -12,9 +13,9 @@ export default function Autosaver({ onValues }) { useEffect(() => { if (!validating) { - onValues && onValues({ valid: valid && !validating, values }); + onValues && onValues({ valid: valid && !validating, values, form }); } - }, [onValues, valid, validating, values]); + }, [form, onValues, valid, validating, values]); return null; } diff --git a/src/form/BridgeForm.js b/src/form/BridgeForm.js index 0d50e3dba..25274b128 100644 --- a/src/form/BridgeForm.js +++ b/src/form/BridgeForm.js @@ -2,17 +2,17 @@ import React from 'react'; import { Form } from 'react-final-form'; import setFieldData from 'final-form-set-field-data'; -import FormError from './FormError'; import Autosaver from './Autosaver'; +import ValidationPauser from './ValidationPauser'; export default function BridgeForm({ children, onValues, ...rest }) { return (
{formProps => ( <> + {children(formProps)} {onValues && } - )} diff --git a/src/form/FormError.js b/src/form/FormError.js index 1d2b16d8c..e59e65971 100644 --- a/src/form/FormError.js +++ b/src/form/FormError.js @@ -1,9 +1,16 @@ import React from 'react'; import { useFormState } from 'react-final-form'; import { ErrorText } from 'indigo-react'; +import { FORM_ERROR } from 'final-form'; -export default function FormError() { - const { submitError } = useFormState({ subscription: { submitError: true } }); +export default function FormError(props) { + const { submitError, errors } = useFormState({ + subscription: { submitError: true, errors: true }, + }); - return submitError ? {submitError} : null; + const formError = errors[FORM_ERROR]; + + return submitError || formError ? ( + {submitError || formError} + ) : null; } diff --git a/src/form/Inputs.js b/src/form/Inputs.js index f436163e1..e33fc8b36 100644 --- a/src/form/Inputs.js +++ b/src/form/Inputs.js @@ -1,22 +1,23 @@ -import React, { useMemo } from 'react'; +import React from 'react'; import { Input, AccessoryIcon } from 'indigo-react'; import { useField } from 'react-final-form'; +import { some } from 'lodash'; import { validateNotEmpty, validateTicket, kDefaultValidator, validateMnemonic, + validatePoint, + validateMaximumPatpByteLength, + validateOneOf, + validateHexString, + validateHexLength, } from 'lib/validators'; import { compose } from 'lib/lib'; import { prependSig } from 'lib/transformers'; import { DEFAULT_HD_PATH } from 'lib/wallet'; - -// TODO: why is `validate` called when routing away? new props? -// active not true: https://github.com/final-form/react-final-form/issues/558 - -const kEmptyValidators = []; // necessary for referential stability -const kEmptyTransformers = []; // also for referential stability +import InputSigil from 'components/InputSigil'; const PLACEHOLDER_POINT = '~sampel-ponnym'; const PLACEHOLDER_HD_PATH = DEFAULT_HD_PATH; @@ -29,9 +30,8 @@ const PLACEHOLDER_PRIVATE_KEY = const buildValidator = ( validators = [], - fn = x => undefined + fn = () => undefined ) => async value => { - // console.log('validating:', value); return ( compose( ...validators, @@ -40,24 +40,78 @@ const buildValidator = ( ); }; -const kTicketValidators = [validateTicket, validateNotEmpty]; -export function TicketInput({ - name, - validators = kEmptyValidators, - transformers = kEmptyTransformers, - validate, - ...rest -}) { - const { valid, error, validating } = useField(name, { - subscription: { error: true, validating: true }, - }); +export const hasErrors = obj => some(obj, v => v !== undefined); + +export const buildTicketValidator = (validators = []) => + buildValidator([...validators, validateTicket, validateNotEmpty]); +export const buildMnemonicValidator = () => + buildValidator([validateMnemonic, validateNotEmpty]); +export const buildCheckboxValidator = () => + buildValidator([validateOneOf([true, false])]); +export const buildPassphraseValidator = () => buildValidator([]); +// TODO: validate hdpath format +export const buildHdPathValidator = () => buildValidator([validateNotEmpty]); +export const buildPointValidator = (size = 4) => + buildValidator([ + validatePoint, + validateMaximumPatpByteLength(size), + validateNotEmpty, + ]); +export const buildSelectValidator = options => + buildValidator([validateOneOf(options.map(option => option.value))]); +export const buildHexValidator = length => + buildValidator([ + validateHexLength(length), + validateHexString, + validateNotEmpty, + ]); +export const buildUploadValidator = () => buildValidator([validateNotEmpty]); + +// the default form validator just returns field-level validations +const kDefaultFormValidator = (values, errors) => errors; +export const composeValidator = ( + fieldValidators = {}, + formValidator = kDefaultFormValidator +) => { + const names = Object.keys(fieldValidators); - const _validate = useMemo( - () => buildValidator([...validators, ...kTicketValidators], validate), - [validate, validators] + const fieldLevelValidators = names.map(name => value => + fieldValidators[name](value) ); - const _format = (value, name) => prependSig(value); + const fieldLevelValidator = async values => { + const errors = await Promise.all( + names.map((name, i) => fieldLevelValidators[i](values[name])) + ); + + return names.reduce( + (memo, name, i) => ({ + ...memo, + [name]: errors[i], + }), + {} + ); + }; + + return async values => { + console.log('validating...'); + const errors = await fieldLevelValidator(values); + return await formValidator(values, errors); + }; +}; + +export function TicketInput({ name, ...rest }) { + const { + meta: { valid, error, validating, touched, active }, + } = useField(name, { + subscription: { + valid: true, + error: true, + validating: true, + touched: true, + active: true, + }, + }); return ( ) : validating ? ( @@ -73,69 +127,79 @@ export function TicketInput({ ) : null } - config={{ validate: _validate, format: _format }} + config={{ format: prependSig }} mono {...rest} /> ); } -const kMnemonicValidators = [validateMnemonic, validateNotEmpty]; -export function MnemonicInput({ - name, - validators = kEmptyValidators, - transformers = kEmptyTransformers, - validate, - ...rest -}) { - const _validate = useMemo( - () => buildValidator([...validators, ...kMnemonicValidators], validate), - [validate, validators] +export function MnemonicInput({ ...rest }) { + return ( + ); +} +export function HdPathInput({ ...rest }) { return ( ); } -const kHdPathValidators = [validateNotEmpty]; -export function HdPathInput({ - name, - validators = kEmptyValidators, - transformers = kEmptyTransformers, - validate, - ...rest -}) { - const _validate = useMemo( - () => buildValidator([...validators, ...kHdPathValidators], validate), - [validate, validators] +export function PassphraseInput({ ...rest }) { + return ( + ); +} + +export function PointInput({ name, size = 4, ...rest }) { + const { + input: { value }, + meta: { active, valid, error }, + } = useField(name, { + subscription: { value: true, active: true, valid: true, error: true }, + }); return ( + ) : null + } + config={{ format: prependSig }} + mono {...rest} /> ); } -export function PassphraseInput({ ...rest }) { +export function PrivateKeyInput({ ...rest }) { return ( diff --git a/src/form/UploadInput.js b/src/form/UploadInput.js new file mode 100644 index 000000000..247df045b --- /dev/null +++ b/src/form/UploadInput.js @@ -0,0 +1,51 @@ +import React, { useCallback } from 'react'; +import { useField } from 'react-final-form'; +import UploadButton from 'components/UploadButton'; +import { AccessoryIcon } from 'indigo-react'; + +export default function UploadInput({ name, label, ...rest }) { + const { + input, + meta: { validating, submitting, touched, active, error }, + } = useField(name, { type: 'text' }); + + const handleUpload = useCallback( + element => { + input.onFocus(); + + const file = element.files.item(0); + const reader = new FileReader(); + + reader.onload = e => { + input.onChange({ target: { value: e.target.result } }); + }; + + const failure = _ => { + input.onBlur(); + }; + + reader.onerror = failure; + reader.onabort = failure; + + reader.readAsText(file); + }, + [input] + ); + + return ( + + ) : validating || submitting ? ( + + ) : ( + undefined + ) + } + {...rest}> + {label} + + ); +} diff --git a/src/form/ValidationPauser.js b/src/form/ValidationPauser.js new file mode 100644 index 000000000..cdb7e6b58 --- /dev/null +++ b/src/form/ValidationPauser.js @@ -0,0 +1,27 @@ +import { useEffect } from 'react'; +import { useForm } from 'react-final-form'; + +/** + * pauses validation when it becomes unmounted + * + * NOTE: this is useful because for some reason, final-form will + * re-run validation when a field is unregistered (even post submission !?), + * which react-final-form does on unmount. So when we redirect away from + * a page with a form on it, all of the validation is re-triggered. + * + * Normally, for boring sync-validation-only situations, this is ok, but for + * long-running validators like ours (deriving seeds, checking chain, etc) + * having them re-triggered when a form leaves the page is hilariously bad. + * + * So here we disable validation when unmounting, saving ourselves + * from the footgun. + * + * see: https://github.com/final-form/react-final-form/issues/408 + */ +export default function ValidationPauser() { + const form = useForm(); + + useEffect(() => () => form.pauseValidation(), [form]); + + return null; +} diff --git a/src/indigo-react/components/AccessoryIcon.js b/src/indigo-react/components/AccessoryIcon.js index 452e59981..a29557d9b 100644 --- a/src/indigo-react/components/AccessoryIcon.js +++ b/src/indigo-react/components/AccessoryIcon.js @@ -4,7 +4,7 @@ import Flex from './Flex'; const PENDING_ACCESSORY = '⋯'; const SUCCESS_ACCESSORY = '✓'; -const FAILURE_ACCESSORY = '✗'; +const FAILURE_ACCESSORY = '!'; function AccessoryIcon({ ...props }) { return ( diff --git a/src/indigo-react/components/CheckboxInput.js b/src/indigo-react/components/CheckboxInput.js index 6447ba6e2..352e17bee 100644 --- a/src/indigo-react/components/CheckboxInput.js +++ b/src/indigo-react/components/CheckboxInput.js @@ -15,7 +15,7 @@ export default function CheckboxInput({ const { input, meta: { submitting }, - } = useField(name); + } = useField(name, { type: 'checkbox' }); disabled = disabled || submitting; @@ -34,16 +34,7 @@ export default function CheckboxInput({ }), }}> {/* we totally hide the checkbox itself */} - input.onChange(!input.value)} - /> + {/* and then display a prettier one in its stead */} !disabled && valid && e.key === 'Enter' && onEnter && onEnter(), - [disabled, valid] // eslint-disable-line react-hooks/exhaustive-deps - ); + // TODO: integrate this into react-final-form submission + // const onKeyPress = useCallback( + // e => !disabled && valid && e.key === 'Enter' && onEnter && onEnter(), + // [disabled, valid] // eslint-disable-line react-hooks/exhaustive-deps + // ); return ( {accessory && (
)} - {touched && error && ( + {touched && !active && error && ( {error} diff --git a/src/indigo-react/components/SelectInput.js b/src/indigo-react/components/SelectInput.js index 064b7cfdb..54e099512 100644 --- a/src/indigo-react/components/SelectInput.js +++ b/src/indigo-react/components/SelectInput.js @@ -5,65 +5,47 @@ import Flex from './Flex'; import { ErrorText } from './Typography'; import useOnClickOutside from 'indigo-react/lib/useOnClickOutside'; import AccessoryIcon from './AccessoryIcon'; +import { useField } from 'react-final-form'; // NOTE: if we really care about accessibility, we should pull in a dependency export default function SelectInput({ - // visuals name, label, + placeholder, className, - accessory, mono = false, - placeholder, - - // callbacks - onEnter, - - // state from hook - focused, - pass, - syncPass, - visiblyPassed, - error, - hintError, - data, - bind, - autoFocus, + options = [], disabled, - options, - touched, +}) { + const { + input, + meta: { active, error, submitting, touched, valid }, + } = useField(name, { + type: 'select', + }); - // ignored - initialValue, - validators, - transformers, + disabled = disabled || submitting; - // extra - ...rest -}) { const [isOpen, setIsOpen] = useState(false); const ref = useRef(); // close select on outside clicks useOnClickOutside(ref, useCallback(() => setIsOpen(false), [setIsOpen])); - const toggleOpen = useCallback(() => setIsOpen(isOpen => !isOpen), [ - setIsOpen, - ]); + const toggleOpen = useCallback(() => { + input.onFocus(); + setIsOpen(isOpen => !isOpen); + }, [input]); const onChange = value => { - // TODO: provide setValue here? // construct a pseudo event that sets the value correctly - bind.onChange({ target: { value } }); + input.onChange({ target: { value } }); + input.onBlur(); setIsOpen(false); }; - // redefine accessory because we still want to ignore it from the ..rest above - accessory = ( - {isOpen ? '▲' : '▼'} - ); - - const text = options.find(o => o.value === data).text; + console.log(input); + const text = options.find(o => o.value === input.value).text; return ( + name={name}> {isOpen ? placeholder : text}
- {accessory} + {isOpen ? '▲' : '▼'}
{isOpen && ( )}
- {error && ( + {touched && !active && error && ( {error} diff --git a/src/indigo-react/components/ToggleInput.js b/src/indigo-react/components/ToggleInput.js index 6c5048c88..25fb75464 100644 --- a/src/indigo-react/components/ToggleInput.js +++ b/src/indigo-react/components/ToggleInput.js @@ -3,6 +3,7 @@ import cn from 'classnames'; import Flex from './Flex'; import LinkButton from './LinkButton'; +import { useField } from 'react-final-form'; export default function ToggleInput({ // visuals @@ -11,32 +12,21 @@ export default function ToggleInput({ inverseLabel, className, - // callbacks - onEnter, - - // state from hook - focused, - pass, - syncPass, - visiblyPassed, - error, - hintError, - data, - bind, - autoFocus, + // disabled, - touched, - - // ignored - initialValue, - validators, - transformers, ...rest }) { + const { + input, + meta: { submitting }, + } = useField(name, { type: 'checkbox' }); + + disabled = disabled || submitting; + return ( {/* and then display a prettier one in its stead */} - {bind.checked ? inverseLabel : label} + {input.checked ? inverseLabel : label} diff --git a/src/lib/useInputs.js b/src/lib/useInputs.js index bfa10c506..c62726c00 100644 --- a/src/lib/useInputs.js +++ b/src/lib/useInputs.js @@ -214,10 +214,8 @@ export function useSelectInput({ initialValue, options, ...rest }) { type: 'select', validators: useMemo( () => [ - validateOneOf( - options.map(option => option.value), - validateNotEmpty - ), + validateOneOf(options.map(option => option.value)), + validateNotEmpty, ], [options] ), diff --git a/src/lib/validators.js b/src/lib/validators.js index b9a14223e..b851a2e0d 100644 --- a/src/lib/validators.js +++ b/src/lib/validators.js @@ -225,6 +225,19 @@ export const validateLength = l => m => error: `Must be exactly ${l} characters.`, }); +export const validateHexLength = l => m => + simpleValidatorWrapper({ + prevMessage: m, + validator: d => { + try { + return d.length === l + 2; + } catch { + return false; + } + }, + error: `Must be exactly ${l} hex characters.`, + }); + export const validateMaximumLength = l => m => simpleValidatorWrapper({ prevMessage: m, diff --git a/src/views/Activate/ActivateCode.js b/src/views/Activate/ActivateCode.js index 12d4b56cb..10bd42049 100644 --- a/src/views/Activate/ActivateCode.js +++ b/src/views/Activate/ActivateCode.js @@ -1,4 +1,4 @@ -import React, { useCallback, useRef } from 'react'; +import React, { useCallback, useMemo, useRef } from 'react'; import { Just, Nothing } from 'folktale/maybe'; import * as azimuth from 'azimuth-js'; import { Grid, H4 } from 'indigo-react'; @@ -24,7 +24,14 @@ import useHasDisclaimed from 'lib/useHasDisclaimed'; import BridgeForm from 'form/BridgeForm'; import SubmitButton from 'form/SubmitButton'; -import { TicketInput } from 'form/Inputs'; +import { + TicketInput, + hasErrors, + composeValidator, + buildTicketValidator, +} from 'form/Inputs'; +import FormError from 'form/FormError'; +import { FORM_ERROR } from 'final-form'; export default function ActivateCode() { const history = useHistory(); @@ -57,8 +64,14 @@ export default function ActivateCode() { // validate should be a pure function but we don't want to have to recompute // all of this information on submit, so cache the invite wallet and avoid // re-renders that may trigger re-validations (causing infinite loop) - const validate = useCallback( - async ticket => { + const validateForm = useCallback( + async ({ ticket }, errors) => { + if (hasErrors(errors)) { + return errors; + } + + console.log('FUCK — deriving seed'); + await timeout(100); // allow the ui changes to flush before we lag it out const _contracts = need.contracts(contracts); @@ -93,26 +106,32 @@ export default function ActivateCode() { setDerivedPoint(Just(point)); cachedPoint.current = point; } else { - return ( - 'Invite code has no claimable point.\n' + - 'Check your invite code and try again?' - ); + return { + [FORM_ERROR]: + 'Invite code has no claimable point.\n' + + 'Check your invite code and try again?', + }; } }, [contracts, setDerivedPoint] ); + const validate = useMemo( + () => composeValidator({ ticket: buildTicketValidator() }, validateForm), + [validateForm] + ); + // set our state on submission const onSubmit = useCallback( async values => { setInviteWallet(cachedInviteWallet.current); setDerivedWallet(Just(await generateWallet(cachedPoint))); - - goToPassport(); }, - [goToPassport, setDerivedWallet, setInviteWallet] + [setDerivedWallet, setInviteWallet] ); + const afterSubmit = useCallback(async () => goToPassport(), [goToPassport]); + return ( @@ -121,7 +140,9 @@ export default function ActivateCode() { Activate {({ validating, submitting, handleSubmit }) => ( <> @@ -130,10 +151,10 @@ export default function ActivateCode() { as={TicketInput} name="ticket" label="Activation Code" - validate={validate} - autoFocus /> + + push(names.TRANSFER), [push, names]); const { ticket } = need.wallet(derivedWallet); - const validators = useMemo( - () => [validateExactly(ticket, 'Does not match expected master ticket.')], + const validate = useMemo( + () => + composeValidator({ + ticket: buildTicketValidator([ + validateExactly(ticket, 'Does not match expected master ticket.'), + ]), + }), [ticket] ); @@ -37,6 +47,7 @@ export default function PassportVerify({ className }) { Open it and enter the 4 word phrase below (with hyphens). + + { + const goHome = useCallback(async () => { const _wallet = need.wallet(wallet); const _contracts = need.contracts(contracts); - setDeducing(true); - let deduced = pointCursor; // if no point cursor set by login logic, try to deduce it if (Nothing.hasInstance(deduced)) { @@ -107,8 +103,6 @@ export default function Login() { } } - setDeducing(false); - // if we have a deduced point or one in the global context, // navigate to that specific point, otherwise navigate to list of points if (Just.hasInstance(deduced)) { @@ -131,22 +125,14 @@ export default function Login() { full as={Tabs} className="mt1" + // Tabs views={VIEWS} options={OPTIONS} currentTab={currentTab} onTabChange={setCurrentTab} + // Tab extra + goHome={goHome} /> - - - Continue -
diff --git a/src/views/Login/Advanced.js b/src/views/Login/Advanced.js index fe37194a5..9db458d03 100644 --- a/src/views/Login/Advanced.js +++ b/src/views/Login/Advanced.js @@ -20,7 +20,7 @@ const OPTIONS = [ { text: 'Ethereum Keystore', value: NAMES.KEYSTORE }, ]; -export default function Advanced({ loginCompleted, className }) { +export default function Advanced({ className, ...rest }) { const [currentTab, setCurrentTab] = useState(undefined); return ( @@ -31,7 +31,7 @@ export default function Advanced({ loginCompleted, className }) { currentTab={currentTab} onTabChange={setCurrentTab} // - loginCompleted={loginCompleted} + {...rest} /> ); } diff --git a/src/views/Login/ContinueButton.js b/src/views/Login/ContinueButton.js new file mode 100644 index 000000000..8626f310f --- /dev/null +++ b/src/views/Login/ContinueButton.js @@ -0,0 +1,26 @@ +import React from 'react'; +import cn from 'classnames'; +import { useFormState } from 'react-final-form'; + +import { ForwardButton } from 'components/Buttons'; +import Blinky from 'components/Blinky'; + +export default function ContinueButton({ className, children, handleSubmit }) { + const { valid, validating, submitting } = useFormState({ + subscription: { valid: true, validating: true, submitting: true }, + }); + + const loading = validating || submitting; + + return ( + : undefined} + onClick={handleSubmit}> + {children || 'Continue'} + + ); +} diff --git a/src/views/Login/Hardware.js b/src/views/Login/Hardware.js index 4c654471e..cc668b8dc 100644 --- a/src/views/Login/Hardware.js +++ b/src/views/Login/Hardware.js @@ -20,7 +20,7 @@ const OPTIONS = [ { text: 'Trezor', value: NAMES.TREZOR }, ]; -export default function Hardware({ loginCompleted, className }) { +export default function Hardware({ className, ...rest }) { const [currentTab, setCurrentTab] = useState(undefined); return ( @@ -31,7 +31,7 @@ export default function Hardware({ loginCompleted, className }) { currentTab={currentTab} onTabChange={setCurrentTab} // - loginCompleted={loginCompleted} + {...rest} /> ); } diff --git a/src/views/Login/Keystore.js b/src/views/Login/Keystore.js index ec3901d1f..05b8b3917 100644 --- a/src/views/Login/Keystore.js +++ b/src/views/Login/Keystore.js @@ -1,17 +1,24 @@ -import React, { useState } from 'react'; -import { Just, Nothing } from 'folktale/maybe'; -import { P, Grid, Input, ErrorText } from 'indigo-react'; +import React, { useCallback, useMemo } from 'react'; +import { Just } from 'folktale/maybe'; +import { P, Grid } from 'indigo-react'; import * as keythereum from 'keythereum'; import { useWallet } from 'store/wallet'; -import { usePassphraseInput } from 'lib/useInputs'; -import * as need from 'lib/need'; import { EthereumWallet, WALLET_TYPES } from 'lib/wallet'; import useLoginView from 'lib/useLoginView'; -import { ForwardButton } from 'components/Buttons'; -import UploadButton from 'components/UploadButton'; +import { + composeValidator, + buildPassphraseValidator, + PassphraseInput, + buildUploadValidator, +} from 'form/Inputs'; +import UploadInput from 'form/UploadInput'; +import ContinueButton from './ContinueButton'; +import BridgeForm from 'form/BridgeForm'; +import { FORM_ERROR } from 'final-form'; +import FormError from 'form/FormError'; export default function Keystore({ className }) { useLoginView(WALLET_TYPES.KEYSTORE); @@ -19,52 +26,33 @@ export default function Keystore({ className }) { // globals const { setWallet } = useWallet(); - const [error, setError] = useState(); - // inputs - // keystore: Maybe - const [keystore, setKeystore] = useState(Nothing()); - const [passphraseInput, { data: passphrase }] = usePassphraseInput({ - name: 'password', - label: 'Keystore password', - autoFocus: true, - }); - - const constructWallet = () => { - try { - const text = need.keystore(keystore); - - const json = JSON.parse(text); - const privateKey = keythereum.recover(passphrase, json); - - const newWallet = new EthereumWallet(privateKey); - setError(); - setWallet(Just(newWallet)); - } catch (err) { - setError( - "Couldn't decrypt wallet. You may have entered an incorrect password." - ); - setWallet(Nothing()); - } - }; - - const handleKeystoreUpload = element => { - const file = element.files.item(0); - const reader = new FileReader(); - - reader.onload = e => { - const keystore = e.target.result; - setKeystore(Just(keystore)); - }; - - const failure = _ => { - setError('There was a problem uploading your Keystore file'); - }; - - reader.onerror = failure; - reader.onabort = failure; + const validate = useMemo( + () => + composeValidator({ + passphrase: buildPassphraseValidator(), + keystore: buildUploadValidator(), + }), + [] + ); - reader.readAsText(file); - }; + const onSubmit = useCallback( + async values => { + try { + const json = JSON.parse(values.keystore); + const privateKey = keythereum.recover(values.passphrase, json); + + const wallet = new EthereumWallet(privateKey); + setWallet(Just(wallet)); + } catch (error) { + console.error(error); + return { + [FORM_ERROR]: + "Couldn't decrypt wallet. You may have entered an incorrect password.", + }; + } + }, + [setWallet] + ); return ( @@ -73,27 +61,32 @@ export default function Keystore({ className }) { encrypted with a password, you'll also need to enter that below. - - Upload Keystore file - - - {error && ( - - {error} - - )} - - - - - Decrypt - + + {({ handleSubmit }) => ( + <> + + + + + + + + Decrypt + + + )} + ); } diff --git a/src/views/Login/Ledger.js b/src/views/Login/Ledger.js index 9d4b754a5..26ddaf01e 100644 --- a/src/views/Login/Ledger.js +++ b/src/views/Login/Ledger.js @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect } from 'react'; +import React, { useCallback, useMemo } from 'react'; import cn from 'classnames'; import { Just, Nothing } from 'folktale/maybe'; import { @@ -18,14 +18,6 @@ import * as secp256k1 from 'secp256k1'; import { useWallet } from 'store/wallet'; -import { ForwardButton } from 'components/Buttons'; - -import { - useCheckboxInput, - useHdPathInput, - useSelectInput, -} from 'lib/useInputs'; - import { LEDGER_LIVE_PATH, LEDGER_LEGACY_PATH, @@ -35,6 +27,16 @@ import { import { WALLET_TYPES } from 'lib/wallet'; import useLoginView from 'lib/useLoginView'; import useBreakpoints from 'lib/useBreakpoints'; +import { + buildCheckboxValidator, + buildHdPathValidator, + composeValidator, + buildSelectValidator, +} from 'form/Inputs'; +import BridgeForm from 'form/BridgeForm'; +import FormError from 'form/FormError'; +import ContinueButton from './ContinueButton'; +import Condition from 'form/Condition'; const PATH_OPTIONS = [ { text: 'Ledger Live', value: LEDGER_LIVE_PATH }, @@ -46,74 +48,60 @@ const ACCOUNT_OPTIONS = times(20, i => ({ value: i, })); -export default function Ledger({ className }) { +export default function Ledger({ className, goHome }) { useLoginView(WALLET_TYPES.LEDGER); const { setWallet, setWalletHdPath } = useWallet(); - // derivation path input - const [derivationPathInput, { data: basePathPattern }] = useSelectInput({ - name: 'derivationpath', - label: 'Derivation Path', - placeholder: 'Choose path pattern...', - options: PATH_OPTIONS, - }); - - // account input - const [accountInput, { data: accountIndex }] = useSelectInput({ - name: 'account', - label: 'Account', - placeholder: 'Choose account...', - options: ACCOUNT_OPTIONS, - }); - - // custom toggle - const [customPathInput, { data: useCustomPath }] = useCheckboxInput({ - name: 'customPath', - label: 'Custom HD Path', - autoComplete: 'off', - initialValue: false, - }); - - // hd path input - const [ - hdPathInput, - { data: hdPath }, - { setValue: setHdPath }, - ] = useHdPathInput({ - name: 'hdpath', - label: 'HD Path', - initialValue: basePathPattern.replace(/x/g, 0), - }); - - const pollDevice = useCallback(async () => { - const transport = await Transport.create(); - const eth = new Eth(transport); - const path = chopHdPrefix(hdPath); - - try { - const info = await eth.getAddress(path, false, true); - const publicKey = Buffer.from(info.publicKey, 'hex'); - const chainCode = Buffer.from(info.chainCode, 'hex'); - const pub = secp256k1.publicKeyConvert(publicKey, true); - const hd = bip32.fromPublicKey(pub, chainCode); - setWallet(Just(hd)); - setWalletHdPath(addHdPrefix(hdPath)); - } catch (error) { - console.error(error); - setWallet(Nothing()); + const validate = useMemo( + () => + composeValidator({ + useCustomPath: buildCheckboxValidator(), + derivationpath: buildSelectValidator(PATH_OPTIONS), + account: buildSelectValidator(ACCOUNT_OPTIONS), + hdpath: buildHdPathValidator(), + }), + [] + ); + + const onSubmit = useCallback( + async values => { + const transport = await Transport.create(); + const eth = new Eth(transport); + const path = chopHdPrefix(values.hdpath); + + try { + const info = await eth.getAddress(path, false, true); + const publicKey = Buffer.from(info.publicKey, 'hex'); + const chainCode = Buffer.from(info.chainCode, 'hex'); + const pub = secp256k1.publicKeyConvert(publicKey, true); + const hd = bip32.fromPublicKey(pub, chainCode); + setWallet(Just(hd)); + setWalletHdPath(addHdPrefix(values.hdpath)); + } catch (error) { + console.error(error); + setWallet(Nothing()); + } + + return await goHome(); + }, + [goHome, setWallet, setWalletHdPath] + ); + + const onValues = useCallback(({ valid, values, form }) => { + if (!valid) { + return; } - }, [hdPath, setWallet, setWalletHdPath]); - - // when the base path pattern or the account index changes - // update the hd path in our input - useEffect(() => { - if (useCustomPath) { - // updated by useForm - } else { - setHdPath(basePathPattern.replace(/x/g, accountIndex)); + + // when the base path pattern or the account index changes + // update the hd path in our input + if (!values.useCustomPath) { + form.change( + 'hdpath', + values.derivationpath.replace(/x/g, values.account) + ); } - }, [useCustomPath, setHdPath, basePathPattern, accountIndex]); + }, []); const full = useBreakpoints([true, true, false]); const half = useBreakpoints([false, false, true]); @@ -170,37 +158,62 @@ export default function Ledger({ className }) { enable the "contract data" option. - {useCustomPath && } - - {!useCustomPath && ( - <> - - - - )} - - - - - Authenticate - + + {({ handleSubmit }) => ( + <> + + + + + + + + + + + + + + + + Authenticate + + + )} + ); diff --git a/src/views/Login/Mnemonic.js b/src/views/Login/Mnemonic.js index 1a319dc4b..42bafdc5f 100644 --- a/src/views/Login/Mnemonic.js +++ b/src/views/Login/Mnemonic.js @@ -1,4 +1,4 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useMemo } from 'react'; import cn from 'classnames'; import { Just, Nothing } from 'folktale/maybe'; import { Grid, CheckboxInput } from 'indigo-react'; @@ -7,11 +7,22 @@ import { useWallet } from 'store/wallet'; import { walletFromMnemonic, WALLET_TYPES } from 'lib/wallet'; import useLoginView from 'lib/useLoginView'; -import { MnemonicInput, HdPathInput, PassphraseInput } from 'form/Inputs'; +import { + MnemonicInput, + HdPathInput, + PassphraseInput, + composeValidator, + buildMnemonicValidator, + buildCheckboxValidator, + buildPassphraseValidator, + buildHdPathValidator, +} from 'form/Inputs'; import BridgeForm from 'form/BridgeForm'; import Condition from 'form/Condition'; +import FormError from 'form/FormError'; +import ContinueButton from './ContinueButton'; -export default function Mnemonic({ className }) { +export default function Mnemonic({ className, goHome }) { useLoginView(WALLET_TYPES.MNEMONIC); const { @@ -21,6 +32,17 @@ export default function Mnemonic({ className }) { setWalletHdPath, } = useWallet(); + const validate = useMemo( + () => + composeValidator({ + useAdvanced: buildCheckboxValidator(), + mnemonic: buildMnemonicValidator(), + passphrase: buildPassphraseValidator(), + hdpath: buildHdPathValidator(), + }), + [] + ); + // when the properties change, re-derive wallet and set global state const onValues = useCallback( ({ valid, values }) => { @@ -41,10 +63,11 @@ export default function Mnemonic({ className }) { return ( undefined} - initialValues={{ hdpath: walletHdPath }}> - {() => ( + onSubmit={goHome} + initialValues={{ hdpath: walletHdPath, useAdvanced: false }}> + {({ handleSubmit }) => ( <> - + - + + + + + )} - - {/* {useAdvanced && ( - <> - - - - )} - - */} ); } diff --git a/src/views/Login/PrivateKey.js b/src/views/Login/PrivateKey.js index 1550e2691..7af3fd7cb 100644 --- a/src/views/Login/PrivateKey.js +++ b/src/views/Login/PrivateKey.js @@ -1,38 +1,62 @@ -import React, { useEffect } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { Just, Nothing } from 'folktale/maybe'; -import { Grid, Input } from 'indigo-react'; +import { Grid } from 'indigo-react'; import { useWallet } from 'store/wallet'; -import { useHexInput } from 'lib/useInputs'; import { EthereumWallet, WALLET_TYPES, stripHexPrefix } from 'lib/wallet'; import useLoginView from 'lib/useLoginView'; +import FormError from 'form/FormError'; +import ContinueButton from './ContinueButton'; +import BridgeForm from 'form/BridgeForm'; +import { + buildHexValidator, + composeValidator, + PrivateKeyInput, +} from 'form/Inputs'; -export default function PrivateKey({ className }) { +export default function PrivateKey({ className, goHome }) { useLoginView(WALLET_TYPES.PRIVATE_KEY); const { setWallet } = useWallet(); - const [privateKeyInput, { pass, data: privateKey }] = useHexInput({ - length: 64, - name: 'privateKey', - label: 'Private key', - autoFocus: true, - }); + const validate = useMemo( + () => + composeValidator({ + privatekey: buildHexValidator(64), + }), + [] + ); - useEffect(() => { - if (pass) { - const sec = Buffer.from(stripHexPrefix(privateKey), 'hex'); - const newWallet = new EthereumWallet(sec); - setWallet(Just(newWallet)); - } else { - setWallet(Nothing()); - } - }, [pass, privateKey, setWallet]); + const onValues = useCallback( + ({ valid, values }) => { + if (valid) { + const sec = Buffer.from(stripHexPrefix(values.privatekey), 'hex'); + const newWallet = new EthereumWallet(sec); + setWallet(Just(newWallet)); + } else { + setWallet(Nothing()); + } + }, + [setWallet] + ); return ( - + + {({ handleSubmit }) => ( + <> + + + + + )} + ); } diff --git a/src/views/Login/Ticket.js b/src/views/Login/Ticket.js index 8d54645ac..55c70f135 100644 --- a/src/views/Login/Ticket.js +++ b/src/views/Login/Ticket.js @@ -1,28 +1,37 @@ +import React, { useCallback, useMemo, useRef } from 'react'; import { Just, Nothing } from 'folktale/maybe'; import cn from 'classnames'; -import React, { useCallback, useState, useEffect } from 'react'; import * as azimuth from 'azimuth-js'; -import * as ob from 'urbit-ob'; import * as kg from 'urbit-key-generation/dist/index'; -import { Input, Grid, CheckboxInput } from 'indigo-react'; +import { Grid, CheckboxInput } from 'indigo-react'; +import { FORM_ERROR } from 'final-form'; import { useNetwork } from 'store/network'; import { useWallet } from 'store/wallet'; import { usePointCursor } from 'store/pointCursor'; -import { - usePointInput, - useTicketInput, - usePassphraseInput, - useCheckboxInput, -} from 'lib/useInputs'; import * as need from 'lib/need'; import { WALLET_TYPES, urbitWalletFromTicket } from 'lib/wallet'; import useImpliedPoint from 'lib/useImpliedPoint'; import useLoginView from 'lib/useLoginView'; import patp2dec from 'lib/patp2dec'; - -export default function Ticket({ className }) { +import BridgeForm from 'form/BridgeForm'; +import Condition from 'form/Condition'; +import { + TicketInput, + PassphraseInput, + PointInput, + composeValidator, + buildCheckboxValidator, + buildTicketValidator, + buildPassphraseValidator, + buildPointValidator, +} from 'form/Inputs'; +import timeout from 'lib/timeout'; +import FormError from 'form/FormError'; +import ContinueButton from './ContinueButton'; + +export default function Ticket({ className, goHome }) { useLoginView(WALLET_TYPES.TICKET); const { contracts } = useNetwork(); @@ -30,158 +39,171 @@ export default function Ticket({ className }) { const { setPointCursor } = usePointCursor(); const impliedPoint = useImpliedPoint(); - // point - const [pointInput, { data: pointName }] = usePointInput({ - name: 'point', - initialValue: impliedPoint || '', - autoFocus: true, - }); - - // passphrase - const [passphraseInput, { data: passphrase }] = usePassphraseInput({ - name: 'passphrase', - label: 'Wallet Passphrase', - }); - - const [hasPassphraseInput, { data: hasPassphrase }] = useCheckboxInput({ - name: 'has-passphrase', - label: 'Passphrase', - initialValue: false, - }); - - // ticket - const [error, setError] = useState(); - const [deriving, setDeriving] = useState(false); - const [ticketInput, { data: ticket, pass: validTicket }] = useTicketInput({ - name: 'ticket', - label: 'Master Ticket', - error, - deriving, - }); - - // shards - const [shardsInput, { data: isUsingShards }] = useCheckboxInput({ - name: 'shards', - label: 'Shards', - initialValue: false, - }); - - const [shard1Input, { data: shard1, pass: shard1Pass }] = useTicketInput({ - name: 'shard1', - label: 'Shard 1', - }); - - const [shard2Input, { data: shard2, pass: shard2Pass }] = useTicketInput({ - name: 'shard2', - label: 'Shard 2', - }); - - const [shard3Input, { data: shard3, pass: shard3Pass }] = useTicketInput({ - name: 'shard3', - label: 'Shard 3', - }); - - const shardsReady = shard1Pass && shard2Pass && shard3Pass; - - // TODO: maybe want to do this only on-go, because wallet derivation is slow... - const deriveWalletFromTicket = useCallback(async () => { - // clear states - setError(); - setDeriving(true); - setUrbitWallet(Nothing()); - - if ( - !ticket || - !pointName || - !ob.isValidPatq(ticket) || - !ob.isValidPatp(pointName) - ) { - setDeriving(false); - return; - } - - const _contracts = need.contracts(contracts); - const point = patp2dec(pointName); - const urbitWallet = await urbitWalletFromTicket(ticket, point, passphrase); - const [isOwner, isTransferProxy] = await Promise.all([ - azimuth.azimuth.isOwner( - _contracts, - point, - urbitWallet.ownership.keys.address - ), - azimuth.azimuth.isTransferProxy( - _contracts, - point, - urbitWallet.ownership.keys.address + const cachedUrbitWallet = useRef(Nothing()); + + const validateForm = useCallback( + async (values, errors) => { + if (errors.point) { + return errors; + } + + if (values.useShards) { + if (errors.shard1 || errors.shard2 || errors.shard3) { + return errors; + } + + // shards + try { + const ticket = kg.combine([ + values.shard1, + values.shard2, + values.shard3, + ]); + const point = patp2dec(values.point); + cachedUrbitWallet.current = await urbitWalletFromTicket( + ticket, + point, + values.passphrase + ); + } catch (error) { + console.error(error); + return { [FORM_ERROR]: 'Unable to derive wallet from shards.' }; + } + } else { + if (errors.ticket) { + return errors; + } + + await timeout(100); // allow ui events to flush + try { + // ticket + const _contracts = need.contracts(contracts); + const point = patp2dec(values.point); + + console.log('computing...'); + const urbitWallet = await urbitWalletFromTicket( + values.ticket, + point, + values.passphrase + ); + + cachedUrbitWallet.current = urbitWallet; + + const [isOwner, isTransferProxy] = await Promise.all([ + azimuth.azimuth.isOwner( + _contracts, + point, + urbitWallet.ownership.keys.address + ), + azimuth.azimuth.isTransferProxy( + _contracts, + point, + urbitWallet.ownership.keys.address + ), + ]); + + if (!isOwner && !isTransferProxy) { + // notify the user, but allow login regardless + // TODO: warnings + // 'This ticket is not the owner of or transfer proxy for this point.' + } + } catch (error) { + console.error(error); + return { ticket: 'Unable to derive wallet from ticket.' }; + } + } + }, + [contracts] + ); + + const validate = useMemo( + () => + composeValidator( + { + usePassphrase: buildCheckboxValidator(), + useShards: buildCheckboxValidator(), + point: buildPointValidator(4), + ticket: buildTicketValidator(), + shard1: buildTicketValidator(), + shard2: buildTicketValidator(), + shard3: buildTicketValidator(), + passphrase: buildPassphraseValidator(), + }, + validateForm ), - ]); - - if (!isOwner && !isTransferProxy) { - // notify the user, but allow login regardless - setError( - 'This ticket is not the owner of or transfer proxy for this point.' - ); - } - - setUrbitWallet(Just(urbitWallet)); - setPointCursor(Just(point)); - setDeriving(false); - }, [ - pointName, - ticket, - passphrase, - contracts, - setUrbitWallet, - setPointCursor, - setDeriving, - ]); - - const deriveWalletFromShards = useCallback(async () => { - const s1 = shard1 || undefined; - const s2 = shard2 || undefined; - const s3 = shard3 || undefined; - - try { - const ticket = kg.combine([s1, s2, s3]); - const point = patp2dec(pointName); - const uhdw = await urbitWalletFromTicket(ticket, point, passphrase); - setUrbitWallet(Just(uhdw)); - } catch (_) { - // do nothing - } - }, [passphrase, pointName, setUrbitWallet, shard1, shard2, shard3]); - - // derive wallet on change - useEffect(() => { - if (isUsingShards && shardsReady) { - deriveWalletFromShards(); - } else if (validTicket) { - deriveWalletFromTicket(); - } - }, [ - isUsingShards, - validTicket, - shardsReady, - deriveWalletFromShards, - deriveWalletFromTicket, - ]); + [validateForm] + ); + + const onValues = useCallback( + ({ valid, values }) => { + if (valid) { + setUrbitWallet(Just(cachedUrbitWallet.current)); + setPointCursor(Just(patp2dec(values.point))); + } else { + setUrbitWallet(Nothing()); + } + }, + [setPointCursor, setUrbitWallet] + ); return ( - - - {!isUsingShards && } - {isUsingShards && ( - <> - - - - - )} - {hasPassphrase && } - - - + + {({ handleSubmit }) => ( + <> + + + + + + + + + + + + + + + + + + + + + + + + )} + ); } diff --git a/src/views/Login/Trezor.js b/src/views/Login/Trezor.js index 6e1224d1a..2c681a7d7 100644 --- a/src/views/Login/Trezor.js +++ b/src/views/Login/Trezor.js @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { Just, Nothing } from 'folktale/maybe'; import * as bip32 from 'bip32'; import { times } from 'lodash'; @@ -18,13 +18,17 @@ import { useWallet } from 'store/wallet'; import { TREZOR_PATH } from 'lib/trezor'; import { WALLET_TYPES } from 'lib/wallet'; import useLoginView from 'lib/useLoginView'; -import { - useHdPathInput, - useCheckboxInput, - useSelectInput, -} from 'lib/useInputs'; -import { ForwardButton } from 'components/Buttons'; +import { + composeValidator, + buildCheckboxValidator, + buildSelectValidator, + buildHdPathValidator, +} from 'form/Inputs'; +import BridgeForm from 'form/BridgeForm'; +import Condition from 'form/Condition'; +import ContinueButton from './ContinueButton'; +import FormError from 'form/FormError'; const ACCOUNT_OPTIONS = times(20, i => ({ text: `Account #${i + 1}`, @@ -37,63 +41,51 @@ export default function Trezor({ className }) { const { setWallet, setWalletHdPath } = useWallet(); - // custom toggle - const [customPathInput, { data: useCustomPath }] = useCheckboxInput({ - name: 'customPath', - label: 'Custom HD Path', - autoComplete: 'off', - initialValue: false, - }); - - // account input - const [accountInput, { data: accountIndex }] = useSelectInput({ - name: 'account', - label: 'Account', - placeholder: 'Choose account...', - options: ACCOUNT_OPTIONS, - }); - - // hd path input - const [ - hdPathInput, - { data: hdPath }, - { setValue: setHdPath }, - ] = useHdPathInput({ - name: 'hdpath', - label: 'HD Path', - initialValue: TREZOR_PATH.replace(/x/g, 0), - }); - - const pollDevice = async () => { - TrezorConnect.manifest({ - email: 'bridge-trezor@urbit.org', - appUrl: 'https://github.com/urbit/bridge', - }); - - const info = await TrezorConnect.getPublicKey({ - path: hdPath, - }); - - if (info.success === true) { - const payload = info.payload; - const publicKey = Buffer.from(payload.publicKey, 'hex'); - const chainCode = Buffer.from(payload.chainCode, 'hex'); - const pub = secp256k1.publicKeyConvert(publicKey, true); - const hd = bip32.fromPublicKey(pub, chainCode); - setWallet(Just(hd)); - setWalletHdPath(hdPath); - } else { - setWallet(Nothing()); + const validate = useMemo( + () => + composeValidator({ + useCustomPath: buildCheckboxValidator(), + account: buildSelectValidator(ACCOUNT_OPTIONS), + hdpath: buildHdPathValidator(), + }), + [] + ); + + const onSubmit = useCallback( + async values => { + TrezorConnect.manifest({ + email: 'bridge-trezor@urbit.org', + appUrl: 'https://github.com/urbit/bridge', + }); + + const info = await TrezorConnect.getPublicKey({ + path: values.hdpath, + }); + + if (info.success === true) { + const payload = info.payload; + const publicKey = Buffer.from(payload.publicKey, 'hex'); + const chainCode = Buffer.from(payload.chainCode, 'hex'); + const pub = secp256k1.publicKeyConvert(publicKey, true); + const hd = bip32.fromPublicKey(pub, chainCode); + setWallet(Just(hd)); + setWalletHdPath(values.hdPath); + } else { + setWallet(Nothing()); + } + }, + [setWallet, setWalletHdPath] + ); + + const onValues = useCallback(({ valid, values, form }) => { + if (!valid) { + return; } - }; - useEffect(() => { - if (useCustomPath) { - // updated by useForm - } else { - setHdPath(TREZOR_PATH.replace(/x/g, accountIndex)); + if (!values.useCustomPath) { + form.change('hdpath', TREZOR_PATH.replace(/x/g, values.account)); } - }, [useCustomPath, setHdPath, accountIndex]); + }, []); return ( @@ -106,20 +98,48 @@ export default function Trezor({ className }) { derivation path, you may enter it below. - {useCustomPath && } - - {!useCustomPath && } - - - - - Authenticate - + + {({ handleSubmit }) => ( + <> + + + + + + + + + + + + + + Authenticate + + + )} + ); } From c6220651f3eb47ce3c3d6fb4f8c2df6844b02d37 Mon Sep 17 00:00:00 2001 From: "Matt Condon (shrugs)" Date: Mon, 5 Aug 2019 11:50:26 +0200 Subject: [PATCH 05/43] fix: clean up logic for login/ticket --- src/views/Activate/ActivateCode.js | 6 +- src/views/Login/Ticket.js | 91 +++++++++++++----------------- 2 files changed, 42 insertions(+), 55 deletions(-) diff --git a/src/views/Activate/ActivateCode.js b/src/views/Activate/ActivateCode.js index 10bd42049..4e1255b6f 100644 --- a/src/views/Activate/ActivateCode.js +++ b/src/views/Activate/ActivateCode.js @@ -77,11 +77,9 @@ export default function ActivateCode() { const _contracts = need.contracts(contracts); const { seed } = await generateTemporaryOwnershipWallet(ticket); - // TODO(fang): isn't all this accessible in the ownership object? - const inviteWallet = walletFromMnemonic(seed, DEFAULT_HD_PATH); - cachedInviteWallet.current = inviteWallet; + cachedInviteWallet.current = walletFromMnemonic(seed, DEFAULT_HD_PATH); - const _inviteWallet = need.wallet(inviteWallet); + const _inviteWallet = need.wallet(cachedInviteWallet.current); const owned = await azimuth.azimuth.getOwnedPoints( _contracts, diff --git a/src/views/Login/Ticket.js b/src/views/Login/Ticket.js index 55c70f135..ca813828c 100644 --- a/src/views/Login/Ticket.js +++ b/src/views/Login/Ticket.js @@ -47,70 +47,59 @@ export default function Ticket({ className, goHome }) { return errors; } + await timeout(100); // allow ui events to flush + + let ticket; if (values.useShards) { if (errors.shard1 || errors.shard2 || errors.shard3) { return errors; } - // shards - try { - const ticket = kg.combine([ - values.shard1, - values.shard2, - values.shard3, - ]); - const point = patp2dec(values.point); - cachedUrbitWallet.current = await urbitWalletFromTicket( - ticket, - point, - values.passphrase - ); - } catch (error) { - console.error(error); - return { [FORM_ERROR]: 'Unable to derive wallet from shards.' }; - } + ticket = kg.combine([values.shard1, values.shard2, values.shard3]); } else { if (errors.ticket) { return errors; } - await timeout(100); // allow ui events to flush - try { - // ticket - const _contracts = need.contracts(contracts); - const point = patp2dec(values.point); + ticket = values.ticket; + } - console.log('computing...'); - const urbitWallet = await urbitWalletFromTicket( - values.ticket, + try { + // ticket + const _contracts = need.contracts(contracts); + const point = patp2dec(values.point); + + cachedUrbitWallet.current = await urbitWalletFromTicket( + ticket, + point, + values.passphrase + ); + + const [isOwner, isTransferProxy] = await Promise.all([ + azimuth.azimuth.isOwner( + _contracts, + point, + cachedUrbitWallet.current.ownership.keys.address + ), + azimuth.azimuth.isTransferProxy( + _contracts, point, - values.passphrase - ); - - cachedUrbitWallet.current = urbitWallet; - - const [isOwner, isTransferProxy] = await Promise.all([ - azimuth.azimuth.isOwner( - _contracts, - point, - urbitWallet.ownership.keys.address - ), - azimuth.azimuth.isTransferProxy( - _contracts, - point, - urbitWallet.ownership.keys.address - ), - ]); - - if (!isOwner && !isTransferProxy) { - // notify the user, but allow login regardless - // TODO: warnings - // 'This ticket is not the owner of or transfer proxy for this point.' - } - } catch (error) { - console.error(error); - return { ticket: 'Unable to derive wallet from ticket.' }; + cachedUrbitWallet.current.ownership.keys.address + ), + ]); + + if (!isOwner && !isTransferProxy) { + // notify the user, but allow login regardless + // TODO: warnings + // 'This ticket is not the owner of or transfer proxy for this point.' } + } catch (error) { + console.error(error); + return { + [FORM_ERROR]: `Unable to derive wallet from ${ + values.useShards ? 'shards' : 'ticket' + }.`, + }; } }, [contracts] From 26f6d3eaa0e8092b4398a457662c11bfaade0d0f Mon Sep 17 00:00:00 2001 From: "Matt Condon (shrugs)" Date: Mon, 5 Aug 2019 11:52:07 +0200 Subject: [PATCH 06/43] fix: rename autosaver to valueshandler --- src/form/BridgeForm.js | 4 ++-- src/form/{Autosaver.js => ValuesHandler.js} | 2 +- src/views/Activate/ActivateCode.js | 2 -- 3 files changed, 3 insertions(+), 5 deletions(-) rename src/form/{Autosaver.js => ValuesHandler.js} (89%) diff --git a/src/form/BridgeForm.js b/src/form/BridgeForm.js index 25274b128..33df62af2 100644 --- a/src/form/BridgeForm.js +++ b/src/form/BridgeForm.js @@ -2,7 +2,7 @@ import React from 'react'; import { Form } from 'react-final-form'; import setFieldData from 'final-form-set-field-data'; -import Autosaver from './Autosaver'; +import ValuesHandler from './ValuesHandler'; import ValidationPauser from './ValidationPauser'; export default function BridgeForm({ children, onValues, ...rest }) { @@ -12,7 +12,7 @@ export default function BridgeForm({ children, onValues, ...rest }) { <> {children(formProps)} - {onValues && } + {onValues && } )} diff --git a/src/form/Autosaver.js b/src/form/ValuesHandler.js similarity index 89% rename from src/form/Autosaver.js rename to src/form/ValuesHandler.js index 30ec72024..2109a4215 100644 --- a/src/form/Autosaver.js +++ b/src/form/ValuesHandler.js @@ -1,7 +1,7 @@ import { useEffect } from 'react'; import { useFormState, useForm } from 'react-final-form'; -export default function Autosaver({ onValues }) { +export default function ValuesHandler({ onValues }) { const form = useForm(); const { valid, validating, values } = useFormState({ subscription: { diff --git a/src/views/Activate/ActivateCode.js b/src/views/Activate/ActivateCode.js index 4e1255b6f..b56450064 100644 --- a/src/views/Activate/ActivateCode.js +++ b/src/views/Activate/ActivateCode.js @@ -70,8 +70,6 @@ export default function ActivateCode() { return errors; } - console.log('FUCK — deriving seed'); - await timeout(100); // allow the ui changes to flush before we lag it out const _contracts = need.contracts(contracts); From 329f1a925ec78a0cc09a550c34886f0a086c2aa0 Mon Sep 17 00:00:00 2001 From: "Matt Condon (shrugs)" Date: Mon, 5 Aug 2019 12:24:51 +0200 Subject: [PATCH 07/43] chore: form-ify inlinethtransction and netowrking keys --- src/components/InlineEthereumTransaction.js | 233 +++++++++++--------- src/form/Inputs.js | 4 +- src/lib/useInputs.js | 34 --- src/views/Admin/AdminNetworkingKeys.js | 156 +++++++------ src/views/Login/PrivateKey.js | 8 +- 5 files changed, 216 insertions(+), 219 deletions(-) diff --git a/src/components/InlineEthereumTransaction.js b/src/components/InlineEthereumTransaction.js index 2021908cf..4c7df4818 100644 --- a/src/components/InlineEthereumTransaction.js +++ b/src/components/InlineEthereumTransaction.js @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react'; +import React, { useMemo, useCallback } from 'react'; import cn from 'classnames'; import { Grid, @@ -12,12 +12,14 @@ import { } from 'indigo-react'; import { fromWei } from 'web3-utils'; -import { useCheckboxInput } from 'lib/useInputs'; import { useExploreTxUrl } from 'lib/explorer'; import { hexify } from 'lib/txn'; import { GenerateButton, ForwardButton, RestartButton } from './Buttons'; import WarningBox from './WarningBox'; +import { composeValidator, buildCheckboxValidator } from 'form/Inputs'; +import BridgeForm from 'form/BridgeForm'; +import Condition from 'form/Condition'; export default function InlineEthereumTransaction({ // from useEthereumTransaction.bind @@ -57,29 +59,24 @@ export default function InlineEthereumTransaction({ const showSignedTx = signed; const exploreTxUrl = useExploreTxUrl(txHash); - const [advancedInput, { data: advancedOpen }] = useCheckboxInput({ - name: 'advanced', - label: 'Advanced Configuration', - inverseLabel: 'Cancel Advanced Configuration', - disabled: !showConfigureInput, - }); - const [ - viewSignedTransaction, - { data: signedTransactionOpen }, - ] = useCheckboxInput({ - name: 'viewsigned', - label: 'View Signed Transaction', - inverseLabel: 'Hide Signed Transaction', - disabled: !showSignedTx, - }); + const validate = useMemo( + () => + composeValidator({ + useAdvanced: buildCheckboxValidator(), + viewSigned: buildCheckboxValidator(), + }), + [] + ); - // reset gas price when closing advanced configuration - useEffect(() => { - if (!advancedOpen) { - resetGasPrice(); - } - }, [advancedOpen, resetGasPrice]); + const onValues = useCallback( + ({ valid, values, form }) => { + if (!values.useAdvanced) { + resetGasPrice(); + } + }, + [resetGasPrice] + ); const renderPrimaryButton = () => { if (error) { @@ -126,94 +123,118 @@ export default function InlineEthereumTransaction({ return ( - {renderPrimaryButton()} + {}}> + {({ handleSubmit }) => ( + <> + {renderPrimaryButton()} - {error && ( - - {error.message} - - )} - - {needFunds && ( - - The address {needFunds.address} needs at least{' '} - {fromWei(needFunds.minBalance)} ETH and currently has{' '} - {fromWei(needFunds.balance)} ETH. Waiting until the account has enough - funds. - - )} - - {showConfigureInput && ( - <> - - {advancedOpen && ( - <> - - Gas Price - {gasPrice} Gwei - - {/* TODO(shrugs): move to indigo/RangeInput */} - setGasPrice(parseInt(e.target.value, 10))} - /> - - Cheap - Fast + {error && ( + + {error.message} - - Nonce: {nonce} - - - Chain ID: {chainId} + )} + + {needFunds && ( + + The address {needFunds.address} needs at least{' '} + {fromWei(needFunds.minBalance)} ETH and currently has{' '} + {fromWei(needFunds.balance)} ETH. Waiting until the account has + enough funds. - - - )} - - )} + )} - {showSignedTx && ( - <> - - {signedTransactionOpen && ( - - {hexify(signedTransaction.serialize())} - - )} - - )} + {showConfigureInput && ( + <> + - {showReceipt && ( - <> - - - - Transaction Hash - - Etherscan↗ - - - - - {txHash} - - - - - - - )} + + + Gas Price + {gasPrice} Gwei + + {/* TODO(shrugs): move to indigo/RangeInput */} + setGasPrice(parseInt(e.target.value, 10))} + /> + + Cheap + Fast + + + Nonce: {nonce} + + + Chain ID: {chainId} + + + + + )} + + {showSignedTx && ( + <> + + + + {hexify(signedTransaction.serialize())} + + + + )} + + {showReceipt && ( + <> + + + + Transaction Hash + + Etherscan↗ + + + + + {txHash} + + + + + + + )} + + )} + ); } diff --git a/src/form/Inputs.js b/src/form/Inputs.js index e33fc8b36..88159e97f 100644 --- a/src/form/Inputs.js +++ b/src/form/Inputs.js @@ -94,7 +94,6 @@ export const composeValidator = ( }; return async values => { - console.log('validating...'); const errors = await fieldLevelValidator(values); return await formValidator(values, errors); }; @@ -195,12 +194,13 @@ export function PointInput({ name, size = 4, ...rest }) { ); } -export function PrivateKeyInput({ ...rest }) { +export function HexInput({ ...rest }) { return ( ); diff --git a/src/lib/useInputs.js b/src/lib/useInputs.js index c62726c00..7136902e2 100644 --- a/src/lib/useInputs.js +++ b/src/lib/useInputs.js @@ -57,40 +57,6 @@ function useFirstOf({ inputs, setValue, ...rest }, mapper = identity) { ]; } -export function usePassphraseInput(props) { - return useFirstOf( - useForm([ - { - type: 'password', - autoComplete: 'off', - placeholder: 'Passphrase', - ...props, - }, - ]) - ); -} - -export function useHexInput({ length, ...rest }) { - return useFirstOf( - useForm([ - { - type: 'text', // or password - autoComplete: 'off', - placeholder: PLACEHOLDER_PRIVATE_KEY, - validators: useMemo( - () => [ - validateHexString, - validateLength(length + 2), - validateNotEmpty, - ], - [length] - ), - ...rest, - }, - ]) - ); -} - const kMnemonicValidators = [validateMnemonic, validateNotEmpty]; export function useMnemonicInput(props) { return useFirstOf( diff --git a/src/views/Admin/AdminNetworkingKeys.js b/src/views/Admin/AdminNetworkingKeys.js index 2cb3a0027..75eb2b1a6 100644 --- a/src/views/Admin/AdminNetworkingKeys.js +++ b/src/views/Admin/AdminNetworkingKeys.js @@ -1,14 +1,6 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { useCallback, useMemo, useRef, useState } from 'react'; import { Just, Nothing } from 'folktale/maybe'; -import { - Grid, - Text, - H5, - Flex, - ToggleInput, - Input, - CheckboxInput, -} from 'indigo-react'; +import { Grid, Text, H5, Flex, ToggleInput, CheckboxInput } from 'indigo-react'; import * as azimuth from 'azimuth-js'; import { randomHex } from 'web3-utils'; @@ -30,7 +22,6 @@ import { formatDotsWithTime } from 'lib/dateFormat'; import useEthereumTransaction from 'lib/useEthereumTransaction'; import { GAS_LIMITS } from 'lib/constants'; import { addHexPrefix } from 'lib/wallet'; -import { useHexInput, useCheckboxInput } from 'lib/useInputs'; import useKeyfileGenerator from 'lib/useKeyfileGenerator'; import ViewHeader from 'components/ViewHeader'; @@ -40,6 +31,15 @@ import DownloadKeyfileButton from 'components/DownloadKeyfileButton'; import InlineEthereumTransaction from 'components/InlineEthereumTransaction'; import NoticeBox from 'components/NoticeBox'; +import { + composeValidator, + buildCheckboxValidator, + buildHexValidator, + HexInput, +} from 'form/Inputs'; +import BridgeForm from 'form/BridgeForm'; +import Condition from 'form/Condition'; + const chainKeyProp = name => d => d[name] === CURVE_ZERO_ADDR ? Nothing() : Just(d[name]); @@ -163,58 +163,44 @@ export default function AdminNetworkingKeys() { keyfileBind, } = useSetKeys(); - const [showNetworkSeedInput, { data: showNetworkSeed }] = useCheckboxInput({ - name: 'shownetworkseed', - label: 'Use Custom Network Seed', - inverseLabel: 'Back to Derived Network Seed', - initialValue: false, - disabled: inputsLocked, - }); + const validateForm = useCallback((values, errors) => { + if (values.useNetworkSeed && errors.networkSeed) { + return errors; + } - const [ - networkSeedInput, - { pass: validNetworkSeed, data: networkSeed }, - { reset: resetNetworkSeed }, - ] = useHexInput({ - name: 'networkseed', - label: 'Network Seed (64 bytes)', - length: 32, // 64 bytes - disabled: inputsLocked, - }); + return {}; + }, []); + + const validate = useMemo( + () => + composeValidator( + { + useNetworkSeed: buildCheckboxValidator(), + networkSeed: buildHexValidator(32), + useDiscontinuity: buildCheckboxValidator(), + }, + validateForm + ), + [validateForm] + ); - const [ - discontinuityInput, - { pass: validDiscontinuity, data: isDiscontinuity }, - ] = useCheckboxInput({ - name: 'discontinuity', - label: 'Trigger New Continuity Era', - initialValue: false, - disabled: inputsLocked, - }); + const onValues = useCallback( + ({ valid, values, form }) => { + if (valid) { + construct( + values.useNetworkSeed ? values.networkSeed : undefined, + values.useDiscontinuity + ); + } else { + unconstruct(); + } - useEffect(() => { - const nothingOrValidSeed = - !showNetworkSeed || (showNetworkSeed && validNetworkSeed); - if (nothingOrValidSeed && validDiscontinuity) { - construct(networkSeed, isDiscontinuity); - } else { - unconstruct(); - } - }, [ - construct, - unconstruct, - isDiscontinuity, - networkSeed, - showNetworkSeed, - validDiscontinuity, - validNetworkSeed, - ]); - - useEffect(() => { - if (!showNetworkSeed) { - resetNetworkSeed(); - } - }, [resetNetworkSeed, showNetworkSeed]); + if (!values.useNetworkSeed && values.networkSeed) { + form.change('networkSeed', ''); + } + }, + [construct, unconstruct] + ); const goRelocate = useCallback(() => push(names.RELOCATE), [push, names]); @@ -335,20 +321,48 @@ export default function AdminNetworkingKeys() { )} {!completed && ( - <> - - {showNetworkSeed && ( + {}} + initialValues={{ + useNetworkSeed: false, + useDiscontinuity: false, + }}> + {({ handleSubmit }) => ( <> - - When using a custom network seed, you'll need to download your - Arvo keyfile immediately after this transaction is - completed—Multipass does not store your seed. - - + + + + When using a custom network seed, you'll need to download + your Arvo keyfile immediately after this transaction is + completed—Multipass does not store your seed. + + + + )} - - + )} From fdeb086cf085a40f3308493bd99d9849aceb200e Mon Sep 17 00:00:00 2001 From: "Matt Condon (shrugs)" Date: Mon, 5 Aug 2019 12:45:52 +0200 Subject: [PATCH 08/43] chore: untested: update more form components --- src/form/Inputs.js | 26 +++- src/lib/useInputs.js | 89 ------------- src/views/Admin/AdminSetProxy.js | 143 +++++++++++++-------- src/views/Admin/Reticket/ReticketVerify.js | 56 +++++--- src/views/Disclaimer.js | 41 +++--- 5 files changed, 170 insertions(+), 185 deletions(-) diff --git a/src/form/Inputs.js b/src/form/Inputs.js index 88159e97f..627820e2a 100644 --- a/src/form/Inputs.js +++ b/src/form/Inputs.js @@ -13,6 +13,7 @@ import { validateOneOf, validateHexString, validateHexLength, + validateEthereumAddress, } from 'lib/validators'; import { compose } from 'lib/lib'; import { prependSig } from 'lib/transformers'; @@ -46,8 +47,10 @@ export const buildTicketValidator = (validators = []) => buildValidator([...validators, validateTicket, validateNotEmpty]); export const buildMnemonicValidator = () => buildValidator([validateMnemonic, validateNotEmpty]); -export const buildCheckboxValidator = () => - buildValidator([validateOneOf([true, false])]); +export const buildCheckboxValidator = mustBe => + buildValidator([ + validateOneOf(mustBe !== undefined ? [mustBe] : [true, false]), + ]); export const buildPassphraseValidator = () => buildValidator([]); // TODO: validate hdpath format export const buildHdPathValidator = () => buildValidator([validateNotEmpty]); @@ -66,6 +69,13 @@ export const buildHexValidator = length => validateNotEmpty, ]); export const buildUploadValidator = () => buildValidator([validateNotEmpty]); +export const buildAddressValidator = () => + buildValidator([ + validateEthereumAddress, + validateHexLength(40), + validateHexString, + validateNotEmpty, + ]); // the default form validator just returns field-level validations const kDefaultFormValidator = (values, errors) => errors; @@ -205,3 +215,15 @@ export function HexInput({ ...rest }) { /> ); } + +export function AddressInput({ ...rest }) { + return ( + + ); +} diff --git a/src/lib/useInputs.js b/src/lib/useInputs.js index 7136902e2..2db0fb565 100644 --- a/src/lib/useInputs.js +++ b/src/lib/useInputs.js @@ -57,64 +57,6 @@ function useFirstOf({ inputs, setValue, ...rest }, mapper = identity) { ]; } -const kMnemonicValidators = [validateMnemonic, validateNotEmpty]; -export function useMnemonicInput(props) { - return useFirstOf( - useForm([ - { - type: 'textarea', - autoComplete: 'off', - placeholder: PLACEHOLDER_MNEMONIC, - validators: kMnemonicValidators, - ...props, - }, - ]) - ); -} - -export function useHdPathInput(props) { - return useFirstOf( - useForm([ - { - type: 'text', - autoComplete: 'off', - placeholder: PLACEHOLDER_HD_PATH, - ...props, - }, - ]) - ); -} - -const kTicketValidators = [validateTicket, validateNotEmpty]; -//TODO needs to be fancier, displaying sig and dashes instead of •ing all -const kTicketTransformers = [prependSig]; -export function useTicketInput({ validators = [], deriving = false, ...rest }) { - return useFirstOf( - useForm([ - { - type: 'password', - label: 'Ticket', - placeholder: PLACEHOLDER_TICKET, - validators: useMemo(() => [...validators, ...kTicketValidators], [ - validators, - ]), - transformers: kTicketTransformers, - mono: true, - ...rest, - }, - ]), - ({ error, pass }) => ({ - accessory: error ? ( - - ) : deriving ? ( - - ) : pass ? ( - - ) : null, - }) - ); -} - const kPointTransformers = [prependSig]; export function usePointInput({ size = 4, validators = [], ...rest }) { const _validators = useMemo( @@ -162,37 +104,6 @@ export function useGalaxyInput(props) { }); } -export function useCheckboxInput({ initialValue, ...rest }) { - return useFirstOf( - useForm([ - { - type: 'checkbox', - ...rest, - }, - ]) - ); -} - -export function useSelectInput({ initialValue, options, ...rest }) { - return useFirstOf( - useForm([ - { - type: 'select', - validators: useMemo( - () => [ - validateOneOf(options.map(option => option.value)), - validateNotEmpty, - ], - [options] - ), - options, - initialValue: initialValue || options[0].value, - ...rest, - }, - ]) - ); -} - const kAddressValidators = [ validateEthereumAddress, validateNotNullAddress, diff --git a/src/views/Admin/AdminSetProxy.js b/src/views/Admin/AdminSetProxy.js index d30124f5c..9290de69f 100644 --- a/src/views/Admin/AdminSetProxy.js +++ b/src/views/Admin/AdminSetProxy.js @@ -1,6 +1,6 @@ -import React, { useCallback, useEffect } from 'react'; +import React, { useCallback, useMemo } from 'react'; import cn from 'classnames'; -import { Grid, Text, Input, Flex, ToggleInput } from 'indigo-react'; +import { Grid, Text, Flex, ToggleInput } from 'indigo-react'; import * as azimuth from 'azimuth-js'; import { useNetwork } from 'store/network'; @@ -16,12 +16,18 @@ import * as need from 'lib/need'; import { useLocalRouter } from 'lib/LocalRouter'; import { ETH_ZERO_ADDR, eqAddr, isZeroAddress } from 'lib/wallet'; import capitalize from 'lib/capitalize'; -import { useAddressInput, useCheckboxInput } from 'lib/useInputs'; import useEthereumTransaction from 'lib/useEthereumTransaction'; import ViewHeader from 'components/ViewHeader'; import InlineEthereumTransaction from 'components/InlineEthereumTransaction'; import { GAS_LIMITS } from 'lib/constants'; +import { + composeValidator, + buildCheckboxValidator, + buildAddressValidator, + AddressInput, +} from 'form/Inputs'; +import BridgeForm from 'form/BridgeForm'; const proxyFromDetails = (details, contracts, proxyType) => { switch (proxyType) { @@ -107,41 +113,41 @@ export default function AdminSetProxy() { bind, } = useSetProxy(data.proxyType); - const [unsetInput, { data: isUnsetting }] = useCheckboxInput({ - name: 'unset', - label: 'Unset', - inverseLabel: 'Specify', - initialValue: false, - disabled: inputsLocked, - }); - const [ - addressInput, - { pass: validAddress, data: address }, - { reset: resetAddress }, - ] = useAddressInput({ - name: 'address', - label: `New ${properProxyType} Address`, - disabled: inputsLocked || isUnsetting, - }); - - useEffect(() => { - if (isUnsetting) { - unset(); - resetAddress(); - } else if (validAddress) { - construct(address); - } else { - unconstruct(); + const validateForm = useCallback((values, errors) => { + if (!values.unset && errors.address) { + return errors; } - }, [ - validAddress, - address, - construct, - isUnsetting, - unset, - unconstruct, - resetAddress, - ]); + + return {}; + }, []); + + const validate = useMemo( + () => + composeValidator( + { + unset: buildCheckboxValidator(), + address: buildAddressValidator(), + }, + validateForm + ), + [validateForm] + ); + + const onValues = useCallback( + ({ valid, values, form }) => { + if (valid) { + if (values.unset) { + unset(); + form.change('address', ''); + } else { + construct(values.address); + } + } else { + unconstruct(); + } + }, + [construct, unconstruct, unset] + ); const proxyAddress = proxyFromDetails(_details, _contracts, data.proxyType); const isProxySet = !isZeroAddress(proxyAddress); @@ -169,27 +175,50 @@ export default function AdminSetProxy() { {proxyAddressLabel} - - - {isProxySet ? proxyAddress : 'Unset'} - - {!completed && isProxySet && ( - + {}} + initialValues={{ unset: false }}> + {({ handleSubmit, values }) => ( + <> + + + {isProxySet ? proxyAddress : 'Unset'} + + {!completed && isProxySet && ( + + )} + + + {completed ? ( + + ) : ( + + )} + )} - - - {completed ? ( - - ) : ( - - )} + [validateExactly(ticket, 'Does not match expected master ticket.')], + const validate = useMemo( + () => + composeValidator({ + ticket: buildTicketValidator([ + validateExactly(ticket, 'Does not match expected master ticket.'), + ]), + }), [ticket] ); - const [ticketInput, { pass }] = useTicketInput({ - name: 'ticket', - label: 'New master ticket', - initialValue: STUB_VERIFY_TICKET ? ticket : undefined, - autoFocus: true, - validators, - }); const goExecute = useCallback(() => push(names.EXECUTE), [push, names]); @@ -33,15 +36,26 @@ export default function ReticketVerify({ newWallet }) { Verify New Master Ticket - - - Verify & Reticket - + + {({ handleSubmit }) => ( + <> + + + Verify & Reticket + + + )} + ); } diff --git a/src/views/Disclaimer.js b/src/views/Disclaimer.js index 9d8e8f033..0605b9c4c 100644 --- a/src/views/Disclaimer.js +++ b/src/views/Disclaimer.js @@ -1,24 +1,26 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useMemo } from 'react'; import cn from 'classnames'; import { Grid, H3, B, Text, CheckboxInput } from 'indigo-react'; -import { useCheckboxInput } from 'lib/useInputs'; import { useHistory } from 'store/history'; import useHasDisclaimed from 'lib/useHasDisclaimed'; import View from 'components/View'; -import { ForwardButton } from 'components/Buttons'; import WarningBox from 'components/WarningBox'; +import BridgeForm from 'form/BridgeForm'; +import SubmitButton from 'form/SubmitButton'; +import { composeValidator, buildCheckboxValidator } from 'form/Inputs'; const TEXT_STYLE = 'f5'; export default function ActivateDisclaimer() { const { pop } = useHistory(); const [, setHasDisclaimed] = useHasDisclaimed(); - const [understoodInput, { data: isUnderstood }] = useCheckboxInput({ - name: 'checkbox', - label: 'I acknowledge and understand these rights', - }); + + const validate = useMemo( + () => composeValidator({ checkbox: buildCheckboxValidator(true) }), + [] + ); const goBack = useCallback(async () => { setHasDisclaimed(true); @@ -76,15 +78,22 @@ export default function ActivateDisclaimer() { Warning: Nobody but you can restore or reset your Master Ticket - - - Continue - + + {({ handleSubmit }) => ( + <> + + + + Continue + + + )} + ); From 21d1c95dff63a002b774ee2f1a5d460f7b19ced8 Mon Sep 17 00:00:00 2001 From: "Matt Condon (shrugs)" Date: Mon, 5 Aug 2019 23:46:21 +0200 Subject: [PATCH 09/43] checkpoint: 90% done with inviteemail.js --- package-lock.json | 25 +- package.json | 2 + src/components/ViewHeader.js | 2 +- src/form/BridgeForm.js | 3 +- src/form/Inputs.js | 43 +- src/indigo-react/index.js | 2 - src/indigo-react/lib/index.js | 1 - src/indigo-react/lib/useForm.js | 264 ---------- src/indigo-react/lib/useOnClickOutside.js | 1 - src/lib/useArray.js | 48 -- src/lib/useConstant.js | 13 + src/lib/useInputs.js | 152 ------ src/lib/useMailer.js | 45 +- src/views/Admin/AdminNetworkingKeys.js | 3 + src/views/Admin/AdminSetProxy.js | 17 +- src/views/Admin/AdminTransfer.js | 92 ++-- src/views/Admin/Reticket/ReticketVerify.js | 4 + src/views/CreateGalaxy.js | 177 +++---- src/views/Invite/InviteEmail.js | 586 ++++++++++----------- src/views/IssueChild.js | 175 +++--- src/views/Party/PartySetPoolSize.js | 128 +++-- 21 files changed, 710 insertions(+), 1073 deletions(-) delete mode 100644 src/indigo-react/lib/index.js delete mode 100644 src/indigo-react/lib/useForm.js delete mode 100644 src/lib/useArray.js create mode 100644 src/lib/useConstant.js delete mode 100644 src/lib/useInputs.js diff --git a/package-lock.json b/package-lock.json index bda0b4c74..187c6ef65 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2168,7 +2168,7 @@ "dev": true }, "PaperCollateralRenderer": { - "version": "github:urbit/PaperCollateralRenderer#27bd1381529618fb067a35575f8191d04ecfe10b", + "version": "github:urbit/PaperCollateralRenderer#8478aaf397e12769171b7b14332c47e4457e1d46", "from": "github:urbit/PaperCollateralRenderer#rc-immediate", "requires": { "babel-polyfill": "^6.26.0", @@ -6934,6 +6934,11 @@ "@babel/runtime": "^7.3.1" } }, + "final-form-arrays": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/final-form-arrays/-/final-form-arrays-3.0.1.tgz", + "integrity": "sha512-GKXecufCNCjDcz1+3peL21LuuTlApoxCcnpOnmfeJfC3xAlFKGdytYMfifP7W1IEWTGC8twTv3zItESkej8qpg==" + }, "final-form-set-field-data": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/final-form-set-field-data/-/final-form-set-field-data-1.0.2.tgz", @@ -14551,6 +14556,24 @@ } } }, + "react-final-form-arrays": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/react-final-form-arrays/-/react-final-form-arrays-3.1.0.tgz", + "integrity": "sha512-eJdAlhTKzlDD/d1wedD592a99eJNGO0e9GzY++RLN99P23cMGKSzCmsiGWLPwpY0H/C/LmNSL4XzWyH/aZembA==", + "requires": { + "@babel/runtime": "^7.4.5" + }, + "dependencies": { + "@babel/runtime": { + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.5.5.tgz", + "integrity": "sha512-28QvEGyQyNkB0/m2B4FU7IEZGK2NUrcMtT6BZEFALTguLk+AUT6ofsHtPk5QyjAdUkpMJ+/Em+quwz4HOt30AQ==", + "requires": { + "regenerator-runtime": "^0.13.2" + } + } + } + }, "react-hot-loader": { "version": "4.12.0", "resolved": "https://registry.npmjs.org/react-hot-loader/-/react-hot-loader-4.12.0.tgz", diff --git a/package.json b/package.json index 1cb36f4c9..3c27bdce5 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "ethereumjs-tx": "^1.3.7", "file-saver": "^2.0.0", "final-form": "^4.18.2", + "final-form-arrays": "^3.0.1", "final-form-set-field-data": "^1.0.2", "folktale": "^2.3.1", "jszip": "^3.1.5", @@ -42,6 +43,7 @@ "react-app-rewire-hot-loader": "^2.0.1", "react-dom": "^16.8.6", "react-final-form": "^6.3.0", + "react-final-form-arrays": "^3.1.0", "react-hot-loader": "^4.12.0", "react-scripts": "3.0.1", "react-teleporter": "^1.1.0", diff --git a/src/components/ViewHeader.js b/src/components/ViewHeader.js index e5539c21d..dcb32f01c 100644 --- a/src/components/ViewHeader.js +++ b/src/components/ViewHeader.js @@ -3,5 +3,5 @@ import cn from 'classnames'; import { H5 } from 'indigo-react'; export default function ViewHeader({ children, className }) { - return
{children}
; + return
{children}
; } diff --git a/src/form/BridgeForm.js b/src/form/BridgeForm.js index 33df62af2..3214df06f 100644 --- a/src/form/BridgeForm.js +++ b/src/form/BridgeForm.js @@ -1,13 +1,14 @@ import React from 'react'; import { Form } from 'react-final-form'; import setFieldData from 'final-form-set-field-data'; +import arrayMutators from 'final-form-arrays'; import ValuesHandler from './ValuesHandler'; import ValidationPauser from './ValidationPauser'; export default function BridgeForm({ children, onValues, ...rest }) { return ( -
+ {formProps => ( <> diff --git a/src/form/Inputs.js b/src/form/Inputs.js index 627820e2a..c9f5c4e74 100644 --- a/src/form/Inputs.js +++ b/src/form/Inputs.js @@ -14,9 +14,11 @@ import { validateHexString, validateHexLength, validateEthereumAddress, + validateGreaterThan, + validateEmail, } from 'lib/validators'; import { compose } from 'lib/lib'; -import { prependSig } from 'lib/transformers'; +import { prependSig, convertToNumber } from 'lib/transformers'; import { DEFAULT_HD_PATH } from 'lib/wallet'; import InputSigil from 'components/InputSigil'; @@ -28,6 +30,7 @@ const PLACEHOLDER_TICKET = '~sampel-ticlyt-migfun-falmel'; const PLACEHOLDER_ADDRESS = '0x12345abcdeDB11D175F123F6891AA64F01c24F7d'; const PLACEHOLDER_PRIVATE_KEY = '0x12345abcdee6beb2f323fab48b432925c9785808d33a6ca6d7ba00b45e9370c3'; +const PLACEHOLDER_EMAIL = 'Email Address'; const buildValidator = ( validators = [], @@ -54,12 +57,11 @@ export const buildCheckboxValidator = mustBe => export const buildPassphraseValidator = () => buildValidator([]); // TODO: validate hdpath format export const buildHdPathValidator = () => buildValidator([validateNotEmpty]); -export const buildPointValidator = (size = 4) => - buildValidator([ - validatePoint, - validateMaximumPatpByteLength(size), - validateNotEmpty, - ]); +export const buildPointValidator = (size = 4, validate) => + buildValidator( + [validatePoint, validateMaximumPatpByteLength(size), validateNotEmpty], + validate + ); export const buildSelectValidator = options => buildValidator([validateOneOf(options.map(option => option.value))]); export const buildHexValidator = length => @@ -76,6 +78,10 @@ export const buildAddressValidator = () => validateHexString, validateNotEmpty, ]); +export const buildNumberValidator = (min = 0) => + buildValidator([validateGreaterThan(min)]); +export const buildEmailValidator = () => + buildValidator([validateEmail, validateNotEmpty]); // the default form validator just returns field-level validations const kDefaultFormValidator = (values, errors) => errors; @@ -227,3 +233,26 @@ export function AddressInput({ ...rest }) { /> ); } + +export function NumberInput({ ...rest }) { + return ( + + ); +} + +export function EmailInput({ ...rest }) { + return ( + + ); +} diff --git a/src/indigo-react/index.js b/src/indigo-react/index.js index 20827cf09..b1cd906a2 100644 --- a/src/indigo-react/index.js +++ b/src/indigo-react/index.js @@ -11,5 +11,3 @@ export { default as IconButton } from './components/IconButton'; export { default as LinkButton } from './components/LinkButton'; export * from './components/Typography'; - -export * from './lib'; diff --git a/src/indigo-react/lib/index.js b/src/indigo-react/lib/index.js deleted file mode 100644 index 78ab1347a..000000000 --- a/src/indigo-react/lib/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default as useForm } from './useForm'; diff --git a/src/indigo-react/lib/useForm.js b/src/indigo-react/lib/useForm.js deleted file mode 100644 index 675a93005..000000000 --- a/src/indigo-react/lib/useForm.js +++ /dev/null @@ -1,264 +0,0 @@ -import { useCallback, useMemo } from 'react'; -import { keyBy, get, every, some } from 'lodash'; - -import { compose } from 'lib/lib'; -import { kDefaultValidator } from 'lib/validators'; -import useSetState from 'lib/useSetState'; -import useDeepEqualReference from 'lib/useDeepEqualReference'; - -// interface InputConfig { -// name: string; -// validators: Function[]; -// transformers: Function[]; -// initialValue: string; -// autoFocus: boolean; -// disabled: boolean; -// ...extra, -// } - -const defaultsFor = (configs, mapper) => - configs.reduce( - (memo, config) => ({ ...memo, [config.name]: mapper(config) }), - {} - ); - -/** - * useForm manages a set of inputs for rendering them in a loop - */ -export default function useForm(inputConfigs = []) { - const configs = useDeepEqualReference(inputConfigs); - - const byName = useMemo(() => keyBy(configs, 'name'), [configs]); - - // track values - const [values, _setValues, clearValues] = useSetState(() => - defaultsFor(configs, config => - config.initialValue === undefined ? '' : config.initialValue - ) - ); - // ^ NB(shrugs): because we're not syncing the initialValue of _additional_ - // configs that are added, we won't be able to set an initialValue for a - // dynamic form that then triggers validation. for initial configs, - // initialValue can be a validated string and everything will be happy, - // but for dynamically added input configs, we won't get validation, etc - // until there's a traditional state update. - - // track focused states - const [focused, setFocused, clearFocused] = useSetState(() => - defaultsFor(configs, config => config.autoFocus && !config.disabled) - ); - const focuses = useMemo( - () => configs.map(config => focused[config.name] || false), // - [configs, focused] - ); - - // track whether or not input has been focused - const [hasBeenFocused, setHasBeenFocused, clearHasBeenFocused] = useSetState( - {} - ); - - // track whether or not the input has been touched - const [hasBeenTouched, setHasBeenTouched, clearHasBeenTouched] = useSetState( - {} - ); - - // build fn that transforms a value by input name - const transform = useCallback( - (name, value) => compose(...get(byName, [name, 'transformers'], []))(value), - [byName] - ); - - // set a value (and transform it) - const setValue = useCallback( - (name, value) => _setValues({ [name]: transform(name, value) }), - [_setValues, transform] - ); - - // reset the form - const reset = useCallback(() => { - clearFocused(); - clearHasBeenFocused(); - clearHasBeenTouched(); - clearValues(); - }, [clearFocused, clearHasBeenFocused, clearHasBeenTouched, clearValues]); - - // build fn that validates a value by input name - const validate = useCallback( - (name, value) => - compose( - ...get(byName, [name, 'validators'], []), - kDefaultValidator - )(value), - [byName] - ); - - const getValue = useCallback( - (name, e) => { - if (byName[name].type === 'checkbox') { - return e.target.checked; - } - - return e.target.value; - }, - [byName] - ); - - // on change, transform and set value - const onChange = useCallback( - name => e => { - setValue(name, getValue(name, e)); - setHasBeenTouched({ [name]: true }); - }, // - [setValue, getValue, setHasBeenTouched] - ); - - // on focus, update focus - const onFocus = useCallback( - name => e => setFocused({ [name]: true }), // - [setFocused] - ); - - // on blur, defocus and set has been focused - const onBlur = useCallback( - name => e => { - setFocused({ [name]: false }); - setHasBeenFocused({ [name]: true }); - }, - [setFocused, setHasBeenFocused] - ); - - // memo-compute validations of curent values - const validations = useMemo( - () => configs.map(config => validate(config.name, values[config.name])), // - [configs, validate, values] - ); - - // memo-compute the set of (perhaps changed by validation) data - const datas = useMemo(() => validations.map(v => v.data), [validations]); - const syncPasses = useMemo( - () => validations.map(v => v.pass, [validations]), - [validations] - ); - - // the input has errored if it - // 1) did not pass validation and has an error text - // 2) or has a specific error from parent state - // and then we store that in errorTexts[i] - // [] - const errorTexts = useMemo( - () => - configs.map((config, i) => validations[i].error || config.error || false), - [configs, validations] - ); - - // we should hint at an error if it - // 1) has been focused before - // 2) and has an error - // boolean[] - const hintErrors = useMemo( - () => - configs.map( - (config, i) => hasBeenFocused[config.name] && !!errorTexts[i] - ), - [configs, errorTexts, hasBeenFocused] - ); - - // we should block on error (with potential reflow) if it - // 1) has been touched - // 2) and has an error - // consumers should use this value to show error text and block on operations - // [] - const errors = useMemo( - () => - configs.map((config, i) => hasBeenTouched[config.name] && errorTexts[i]), - [configs, errorTexts, hasBeenTouched] - ); - - // this input has passed if - // 1) it has passed local validation - // 2) there are no errors - const passes = useMemo( - () => validations.map((v, i) => v.pass && !errorTexts[i]), - [validations, errorTexts] - ); - - // visibly tell the user that their input has passed if - // 1) they are or have interacted with the input before - // 2) and it has passed as defined above - const visiblePasses = useMemo( - () => - configs.map( - (config, i) => - (focused[config.name] || hasBeenFocused[config.name]) && // - passes[i] - ), - [configs, hasBeenFocused, focused, passes] - ); - - // generate the list of input states - const inputs = useMemo( - () => - configs.map( - ({ name, error, autoFocus, disabled, initialValue, ...rest }, i) => { - const value = - datas[i] !== undefined - ? datas[i] - : initialValue !== undefined - ? initialValue - : ''; - return { - // Input props - name, - value: value, - data: passes[i] ? datas[i] : undefined, - pass: passes[i], - syncPass: syncPasses[i], - visiblyPassed: visiblePasses[i], - error: errors[i], - hintError: hintErrors[i], - focused: focuses[i], - touched: hasBeenTouched[name], - autoFocus: autoFocus && !disabled, - disabled, - ...rest, - // DOM Properties for - bind: { - value, - checked: !!datas[i], - onChange: onChange(name), - onFocus: onFocus(name), - onBlur: onBlur(name), - autoFocus: autoFocus && !disabled, - }, - }; - } - ), - [ - configs, - datas, - passes, - syncPasses, - visiblePasses, - errors, - hintErrors, - focuses, - hasBeenTouched, - onChange, - onFocus, - onBlur, - ] - ); - - // did all of the inputs pass validation? - const pass = useMemo(() => every(passes), [passes]); - // did any of the inputs error? - const error = useMemo(() => some(errors), [errors]); - - return { - inputs, - pass, - error, - setValue, - reset, - }; -} diff --git a/src/indigo-react/lib/useOnClickOutside.js b/src/indigo-react/lib/useOnClickOutside.js index 27bf10ef5..55b890780 100644 --- a/src/indigo-react/lib/useOnClickOutside.js +++ b/src/indigo-react/lib/useOnClickOutside.js @@ -1,7 +1,6 @@ import { useEffect } from 'react'; // via https:// usehooks.com/useOnClickOutside/ - export default function useOnClickOutside(ref, handler) { useEffect(() => { const listener = event => { diff --git a/src/lib/useArray.js b/src/lib/useArray.js deleted file mode 100644 index 8a3214035..000000000 --- a/src/lib/useArray.js +++ /dev/null @@ -1,48 +0,0 @@ -import { useCallback, useState } from 'react'; -import { identity } from 'lodash'; - -/** - * Manages an immutable array of items. - */ -export default function useArray(initialItems = [], itemBuilder = identity) { - const [items, _setItems] = useState(initialItems); - - const append = useCallback( - item => _setItems(items => [...items, itemBuilder(item)]), - [_setItems, itemBuilder] - ); - - const removeAt = useCallback( - i => - _setItems(items => { - const newItems = [...items.slice(0, i), ...items.slice(i + 1)]; - return newItems; - }), - [_setItems] - ); - - const updateAt = useCallback( - (i, update = {}) => - _setItems(items => [ - ...items.slice(0, i), - { - ...items[i], - ...update, - }, - ...items.slice(i + 1), - ]), - [_setItems] - ); - - const clear = useCallback(() => _setItems([]), [_setItems]); - - return [ - items, - { - append, - removeAt, - updateAt, - clear, - }, - ]; -} diff --git a/src/lib/useConstant.js b/src/lib/useConstant.js new file mode 100644 index 000000000..716717b35 --- /dev/null +++ b/src/lib/useConstant.js @@ -0,0 +1,13 @@ +import { useRef } from 'react'; + +// inspired by, but simpler version of: +// https://github.com/Andarist/use-constant/blob/master/src/index.ts +export default function useConstant(fn) { + const ref = useRef(); + + if (!ref.current) { + ref.current = fn(); + } + + return ref.current; +} diff --git a/src/lib/useInputs.js b/src/lib/useInputs.js deleted file mode 100644 index 2db0fb565..000000000 --- a/src/lib/useInputs.js +++ /dev/null @@ -1,152 +0,0 @@ -import React, { useCallback, useMemo } from 'react'; -import { AccessoryIcon, useForm } from 'indigo-react'; -import { identity } from 'lodash'; - -import { DEFAULT_HD_PATH } from 'lib/wallet'; -import { - validateNotEmpty, - validateMnemonic, - validatePoint, - validateTicket, - validateEmail, - validateLength, - validateHexString, - validateOneOf, - validateMaximumPatpByteLength, - validateEthereumAddress, - validateNotNullAddress, - validateGreaterThan, -} from 'lib/validators'; -import { prependSig, convertToNumber } from 'lib/transformers'; - -import InputSigil from 'components/InputSigil'; - -const PLACEHOLDER_POINT = '~sampel-ponnym'; -const PLACEHOLDER_HD_PATH = DEFAULT_HD_PATH; -const PLACEHOLDER_MNEMONIC = - 'example crew supreme gesture quantum web media hazard theory mercy wing kitten'; -const PLACEHOLDER_TICKET = '~sampel-ticlyt-migfun-falmel'; -const PLACEHOLDER_ADDRESS = '0x12345abcdeDB11D175F123F6891AA64F01c24F7d'; -const PLACEHOLDER_PRIVATE_KEY = - '0x12345abcdee6beb2f323fab48b432925c9785808d33a6ca6d7ba00b45e9370c3'; - -// pulls out the first input from a useForm() call -function useFirstOf({ inputs, setValue, ...rest }, mapper = identity) { - // ask the mapper function for any values to overwrite - const input = { - ...inputs[0], - ...mapper(inputs[0]), - }; - // memoize the setValue callback for manually setting the value - // in response to some imperative event - const _setValue = useCallback(value => setValue(input.name, value), [ - setValue, - input.name, - ]); - - // provide the first input as the first element - // _and_ as the second element to facilitate destructuring - // and then provide the pass/error/setValue/etc properties at the end - return [ - input, - input, - { - ...rest, - setValue: _setValue, - }, - ]; -} - -const kPointTransformers = [prependSig]; -export function usePointInput({ size = 4, validators = [], ...rest }) { - const _validators = useMemo( - () => [ - ...validators, - validatePoint, - validateMaximumPatpByteLength(size), - validateNotEmpty, - ], - [size, validators] - ); - - return useFirstOf( - useForm([ - { - type: 'text', - label: 'Point', - placeholder: PLACEHOLDER_POINT, - validators: _validators, - transformers: kPointTransformers, - mono: true, - ...rest, - }, - ]), - ({ error, pass, focused, value }) => ({ - accessory: value ? ( - - ) : null, - }) - ); -} - -export function useGalaxyInput(props) { - return usePointInput({ - label: 'Galaxy Name', - size: 1, - ...props, - }); -} - -const kAddressValidators = [ - validateEthereumAddress, - validateNotNullAddress, - validateNotEmpty, -]; -export function useAddressInput({ ...rest }) { - return useFirstOf( - useForm([ - { - type: 'string', - label: 'Ethereum Address', - placeholder: PLACEHOLDER_ADDRESS, - autoComplete: 'off', - validators: kAddressValidators, - mono: true, - ...rest, - }, - ]) - ); -} - -const kNumberTransformers = [convertToNumber]; -const kNumberValidators = [validateGreaterThan(0)]; -export function useNumberInput({ ...rest }) { - return useFirstOf( - useForm([ - { - type: 'number', - label: 'Number', - autoComplete: 'off', - transformers: kNumberTransformers, - validators: kNumberValidators, - ...rest, - }, - ]) - ); -} - -const kEmailValidators = [validateEmail, validateNotEmpty]; -export const buildEmailInputConfig = extra => ({ - type: 'email', - autoComplete: 'off', - validators: kEmailValidators, - initialValue: '', - ...extra, -}); diff --git a/src/lib/useMailer.js b/src/lib/useMailer.js index 7fc9a21bc..3c55c5472 100644 --- a/src/lib/useMailer.js +++ b/src/lib/useMailer.js @@ -1,43 +1,27 @@ -import { useCallback } from 'react'; -import { Just, Nothing } from 'folktale/maybe'; +import { useCallback, useRef } from 'react'; import { hasReceived, sendMail } from './inviteMail'; -import useSetState from './useSetState'; const STUB_MAILER = process.env.REACT_APP_STUB_MAILER === 'true'; -function useHasReceivedCache() { - const [cache, addToCache] = useSetState(); +export default function useMailer(emails) { + const cache = useRef({}); const getHasReceived = useCallback( - email => cache[email] || Nothing(), // - [cache] - ); - - const syncHasReceivedForEmail = useCallback( async email => { - if (Just.hasInstance(getHasReceived(email))) { - // never update the cache after we know about it - return; - } - - if (STUB_MAILER) { - // always allow sending emails when stubbing - return addToCache({ [email]: Just(false) }); + if (!cache.current[email]) { + if (STUB_MAILER) { + cache.current[email] = false; + } else { + cache.current[email] = await hasReceived(email); + } } - const _hasReceived = await hasReceived(email); - addToCache({ [email]: Just(_hasReceived) }); + return cache.current[email]; }, - [getHasReceived, addToCache] + [cache] ); - return { getHasReceived, syncHasReceivedForEmail }; -} - -export default function useMailer(emails) { - const hasReceivedCache = useHasReceivedCache(emails); - // prefix to avoid clobbering sendMail import // also throws if return value is false const _sendMail = useCallback(async (email, ticket, sender, rawTx) => { @@ -46,8 +30,11 @@ export default function useMailer(emails) { return true; } - return await sendMail(email, ticket, sender, rawTx); + const success = await sendMail(email, ticket, sender, rawTx); + if (!success) { + throw new Error('Failed to send mail'); + } }, []); - return { ...hasReceivedCache, sendMail: _sendMail }; + return { getHasReceived, sendMail: _sendMail }; } diff --git a/src/views/Admin/AdminNetworkingKeys.js b/src/views/Admin/AdminNetworkingKeys.js index 75eb2b1a6..117edd1be 100644 --- a/src/views/Admin/AdminNetworkingKeys.js +++ b/src/views/Admin/AdminNetworkingKeys.js @@ -39,6 +39,7 @@ import { } from 'form/Inputs'; import BridgeForm from 'form/BridgeForm'; import Condition from 'form/Condition'; +import FormError from 'form/FormError'; const chainKeyProp = name => d => d[name] === CURVE_ZERO_ADDR ? Nothing() : Just(d[name]); @@ -365,6 +366,8 @@ export default function AdminNetworkingKeys() { )} + + { switch (proxyType) { @@ -218,14 +219,16 @@ export default function AdminSetProxy() { )} )} - - pop()} - /> + + + pop()} + /> + ); } diff --git a/src/views/Admin/AdminTransfer.js b/src/views/Admin/AdminTransfer.js index 61095a562..9b484a72c 100644 --- a/src/views/Admin/AdminTransfer.js +++ b/src/views/Admin/AdminTransfer.js @@ -1,6 +1,6 @@ -import React, { useCallback, useEffect } from 'react'; +import React, { useCallback, useMemo } from 'react'; import cn from 'classnames'; -import { Grid, Text, Input } from 'indigo-react'; +import { Grid, Text } from 'indigo-react'; import * as azimuth from 'azimuth-js'; import { useNetwork } from 'store/network'; @@ -9,13 +9,19 @@ import { usePointCache } from 'store/pointCache'; import * as need from 'lib/need'; import { useLocalRouter } from 'lib/LocalRouter'; -import { useAddressInput } from 'lib/useInputs'; import useCurrentPointName from 'lib/useCurrentPointName'; import useEthereumTransaction from 'lib/useEthereumTransaction'; import { GAS_LIMITS } from 'lib/constants'; import ViewHeader from 'components/ViewHeader'; import InlineEthereumTransaction from 'components/InlineEthereumTransaction'; +import { + AddressInput, + composeValidator, + buildAddressValidator, +} from 'form/Inputs'; +import BridgeForm from 'form/BridgeForm'; +import FormError from 'form/FormError'; function useTransfer() { const { contracts } = useNetwork(); @@ -47,46 +53,64 @@ export default function AdminTransfer() { bind, } = useTransfer(); - const [addressInput, { pass, data: address }] = useAddressInput({ - name: 'address', - label: `Ethereum Address`, - disabled: inputsLocked, - }); + const validate = useMemo( + () => composeValidator({ address: buildAddressValidator() }), + [] + ); - useEffect(() => { - if (pass) { - construct(address); - } else { - unconstruct(); - } - }, [pass, address, construct, unconstruct]); + const onValues = useCallback( + ({ valid, values }) => { + if (valid) { + construct(values.address); + } else { + unconstruct(); + } + }, + [construct, unconstruct] + ); return ( Transfer Point - - {completed - ? `${address} is now the Transfer Proxy for ${name} and can accept the transfer by logging into Multipass themselves. Until they accept your transfer, you will still have ownership over ${name}.` - : `Transfer ${name} to a new owner.`} - - {!completed && ( - - )} + {}} onValues={onValues}> + {({ handleSubmit, values }) => ( + <> + + {completed + ? `${values.address} is now the Transfer Proxy for ${name} and can accept the transfer by logging into Multipass themselves. Until they accept your transfer, you will still have ownership over ${name}.` + : `Transfer ${name} to a new owner.`} + + + {!completed && ( + + )} + + - pop()} - /> + pop()} + /> + + )} + ); } diff --git a/src/views/Admin/Reticket/ReticketVerify.js b/src/views/Admin/Reticket/ReticketVerify.js index 2465666ae..145f715d5 100644 --- a/src/views/Admin/Reticket/ReticketVerify.js +++ b/src/views/Admin/Reticket/ReticketVerify.js @@ -12,6 +12,7 @@ import { buildTicketValidator, } from 'form/Inputs'; import SubmitButton from 'form/SubmitButton'; +import FormError from 'form/FormError'; const STUB_VERIFY_TICKET = isDevelopment; @@ -50,6 +51,9 @@ export default function ReticketVerify({ newWallet }) { name="ticket" label="New master ticket" /> + + + Verify & Reticket diff --git a/src/views/CreateGalaxy.js b/src/views/CreateGalaxy.js index a9e733c52..2315e502e 100644 --- a/src/views/CreateGalaxy.js +++ b/src/views/CreateGalaxy.js @@ -1,5 +1,4 @@ -import React, { useCallback, useEffect, useState } from 'react'; -import { Nothing, Just } from 'folktale/maybe'; +import React, { useCallback, useMemo, useState } from 'react'; import cn from 'classnames'; import { Grid, Text, Input } from 'indigo-react'; import * as azimuth from 'azimuth-js'; @@ -8,7 +7,6 @@ import { useNetwork } from 'store/network'; import { usePointCache } from 'store/pointCache'; import * as need from 'lib/need'; -import { useAddressInput, useGalaxyInput } from 'lib/useInputs'; import useEthereumTransaction from 'lib/useEthereumTransaction'; import { GAS_LIMITS } from 'lib/constants'; import patp2dec from 'lib/patp2dec'; @@ -18,6 +16,14 @@ import { useLocalRouter } from 'lib/LocalRouter'; import ViewHeader from 'components/ViewHeader'; import InlineEthereumTransaction from 'components/InlineEthereumTransaction'; import View from 'components/View'; +import BridgeForm from 'form/BridgeForm'; +import { + PointInput, + composeValidator, + buildPointValidator, + buildAddressValidator, +} from 'form/Inputs'; +import FormError from 'form/FormError'; function useCreateGalaxy() { const { contracts } = useNetwork(); @@ -45,9 +51,6 @@ export default function CreateGalaxy() { const { contracts } = useNetwork(); const _contracts = need.contracts(contracts); - const [error, setError] = useState(); - const [isAvailable, setIsAvailable] = useState(Nothing()); - const { construct, unconstruct, @@ -56,75 +59,40 @@ export default function CreateGalaxy() { bind, } = useCreateGalaxy(); - const [ - galaxyNameInput, - { pass: validGalaxyName, syncPass: syncValidGalaxyName, value: galaxyName }, - // ^ we use value: here so our effect runs onChange - ] = useGalaxyInput({ - name: 'galaxy', - disabled: inputsLocked, - autoFocus: true, - error: - error || - isAvailable.matchWith({ - Nothing: () => 'Loading availability...', // TODO: make async loading? - Just: p => (p.value ? undefined : 'This galaxy is already owned.'), - }), - }); - - const [ownerInput, { pass: validOwner, data: owner }] = useAddressInput({ - name: 'owner', - label: `Ethereum Address`, - disabled: inputsLocked, - }); - - useEffect(() => { - if (validGalaxyName && validOwner) { - construct(patp2dec(galaxyName), owner); - } else { - unconstruct(); - } - }, [owner, construct, unconstruct, validGalaxyName, validOwner, galaxyName]); - - useEffect(() => { - if (!syncValidGalaxyName || inputsLocked) { - return; - } - - setError(); - setIsAvailable(Nothing()); - - let cancelled = false; - - (async () => { - try { - const currentOwner = await azimuth.azimuth.getOwner( - _contracts, - patp2dec(galaxyName) - ); - - const isAvailable = isZeroAddress(currentOwner); - - if (cancelled) { - return; - } - - setIsAvailable(Just(isAvailable)); - } catch (error) { - console.error(error); - setError(error.message); - setIsAvailable(Just(false)); + const validateGalaxy = useCallback( + async galaxyName => { + const currentOwner = await azimuth.azimuth.getOwner( + _contracts, + patp2dec(galaxyName) + ); + + const isAvailable = isZeroAddress(currentOwner); + if (!isAvailable) { + return 'This galaxy is already spawned and owned.'; } - })(); + }, + [_contracts] + ); - return () => (cancelled = true); - }, [ - _contracts, - galaxyName, - inputsLocked, - setIsAvailable, - syncValidGalaxyName, - ]); + const validate = useMemo( + () => + composeValidator({ + galaxyName: buildPointValidator(1, validateGalaxy), + owner: buildAddressValidator(), + }), + [validateGalaxy] + ); + + const onValues = useCallback( + ({ valid, values }) => { + if (valid) { + construct(patp2dec(values.galaxyName), values.owner); + } else { + unconstruct(); + } + }, + [construct, unconstruct] + ); return ( @@ -133,26 +101,49 @@ export default function CreateGalaxy() { Create a Galaxy - {completed && ( - - {galaxyName} has been created and can be claimed by {owner}. - - )} - - - - - pop()} - /> + {}} onValues={onValues}> + {({ handleSubmit, values }) => ( + <> + {completed && ( + + {values.galaxyName} has been created and can be claimed by{' '} + {values.owner}. + + )} + + + + + + + pop()} + /> + + )} + ); diff --git a/src/views/Invite/InviteEmail.js b/src/views/Invite/InviteEmail.js index f08827b81..810a60cac 100644 --- a/src/views/Invite/InviteEmail.js +++ b/src/views/Invite/InviteEmail.js @@ -1,20 +1,19 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import cn from 'classnames'; import * as ob from 'urbit-ob'; import * as azimuth from 'azimuth-js'; import { Grid, Flex, - Input, IconButton, HelpText, Text, ErrorText, AccessoryIcon, - useForm, } from 'indigo-react'; import { uniq } from 'lodash'; import { fromWei, toWei } from 'web3-utils'; +import { FieldArray } from 'react-final-form-arrays'; import { usePointCursor } from 'store/pointCursor'; import { useNetwork } from 'store/network'; @@ -29,8 +28,6 @@ import { hexify, } from 'lib/txn'; import * as tank from 'lib/tank'; -import useArray from 'lib/useArray'; -import { buildEmailInputConfig } from 'lib/useInputs'; import { MIN_PLANET, GAS_LIMITS, DEFAULT_GAS_PRICE_GWEI } from 'lib/constants'; import * as need from 'lib/need'; import * as wg from 'lib/walletgen'; @@ -40,6 +37,14 @@ import useMailer from 'lib/useMailer'; import LoadableButton from 'components/LoadableButton'; import Highlighted from 'components/Highlighted'; +import BridgeForm from 'form/BridgeForm'; +import { Field } from 'react-final-form'; +import { EmailInput, buildEmailValidator } from 'form/Inputs'; +import { FORM_ERROR } from 'final-form'; +import SubmitButton from 'form/SubmitButton'; +import FormError from 'form/FormError'; + +const INITIAL_VALUES = { emails: [''] }; const GAS_LIMIT = GAS_LIMITS.GIFT_PLANET; const INVITE_COST = toWei( @@ -76,21 +81,14 @@ const buttonText = (status, count) => { } }; -// world's simplest uid -let id = 0; -const buildInputConfig = (extra = {}) => - buildEmailInputConfig({ - name: `email-${id++}`, - placeholder: 'Email Address', - ...extra, - }); - const buildAccessoryFor = (dones, errors) => name => { if (dones[name]) return ; if (errors[name]) return ; return ; }; +const emailFormatValidator = buildEmailValidator(); + // TODO: test with tank, successful txs export default function InviteEmail() { // TODO: resumption after error? @@ -99,7 +97,9 @@ export default function InviteEmail() { const { syncInvites, getInvites } = usePointCache(); const { pointCursor } = usePointCursor(); const point = need.point(pointCursor); - const { getHasReceived, syncHasReceivedForEmail, sendMail } = useMailer(); + const { getHasReceived, sendMail } = useMailer(); + + const cachedEmails = useRef([]); const { availableInvites } = getInvites(point); const maxInvitesToSend = availableInvites.matchWith({ @@ -107,15 +107,6 @@ export default function InviteEmail() { Just: p => p.value, }); - // manage the array of input configs - const [ - inputConfigs, - { append: appendInput, removeAt: removeInputAt }, - ] = useArray( - [buildInputConfig({ label: 'Email Address', autoFocus: true })], - buildInputConfig - ); - // manage per-input state const [hovered, setHovered] = useSetState(); const [invites, addInvite, clearInvites] = useSetState(); @@ -135,30 +126,40 @@ export default function InviteEmail() { const isFailed = status === STATUS.FAILURE; const isDone = status === STATUS.SUCCESS; - // add disabled, error info to input configs - const dynamicConfigs = useMemo( - () => - inputConfigs.map(config => { - config.disabled = !canInput; - const hasReceivedError = getHasReceived(config.name).matchWith({ - Nothing: () => null, // loading - Just: p => p.value && HAS_RECEIVED_TEXT, - }); - config.error = hasReceivedError || errors[config.name]; - return config; - }), - [inputConfigs, errors, canInput, getHasReceived] - ); + const validate = useCallback( + async values => { + // compute the list of valid emails + const emails = values.emails.filter(d => !!d); + if (uniq(emails).length !== emails.length) { + return { [FORM_ERROR]: 'Duplicate email.' }; + } - // construct the state of the set of inputs we're rendering below - const { inputs, pass } = useForm(dynamicConfigs); - // did all of the inputs pass inspection (and there are no general errors)? - const allPass = pass && !generalError; - // the form is submittable iff passing and input is allowed - const canGenerate = allPass && canInput; - // additional inputs can be added iff input is allowed and we have enough - // invites to send - const canAddInvite = canInput && inputConfigs.length < maxInvitesToSend; + let errors = {}; + for (let i = 0; i < values.emails.length; i++) { + const name = `emails[${i}]`; + const email = values.emails[i]; + + // check individual email validity + // TODO: validate each email individually using similar pattern from form/Inputs + // and then reutrn that + const error = await emailFormatValidator(email); + if (error) { + errors[name] = error; + continue; + } + + const hasReceived = await getHasReceived(email); + if (hasReceived) { + errors[name] = HAS_RECEIVED_TEXT; + continue; + } + } + + console.log(errors); + return errors; + }, + [getHasReceived] + ); // progress is [0, .length] of invites or receipts, as we're generating them const progress = isGenerating @@ -176,125 +177,102 @@ export default function InviteEmail() { return () => null; })(); - const generateInvites = useCallback(async () => { - const _contracts = contracts.getOrElse(null); - const _web3 = web3.getOrElse(null); - const _wallet = wallet.getOrElse(null); - if (!_contracts || !_web3 || !_wallet) { - // not using need because we want a custom error - throw new Error('Internal Error: Missing Contracts/Web3/Wallet'); - } - - //TODO want to do this on-input, but that gets weird. see #188 - let knowAll = true; - let alreadyReceived = []; - await Promise.all( - inputs.map(async input => { - const email = input.data; - getHasReceived(email).matchWith({ - Nothing: () => { - knowAll = false; - }, - Just: p => { - if (p.value) alreadyReceived.push(email); - }, - }); - }) - ); - if (!knowAll) { - throw new Error('No word yet from email service...'); - } - if (alreadyReceived.length > 0) { - throw new Error( - 'The following recipients already own a point: ' + - alreadyReceived.join(', ') - ); - } - - const nonce = await _web3.eth.getTransactionCount(_wallet.address); - const chainId = await _web3.eth.net.getId(); - const planets = await azimuth.delegatedSending.getPlanetsToSend( - _contracts, - point, - inputs.length - ); - - // account for the race condition where invites got used up while we were - // composing our target list - if (planets.length < inputs.length) { - // resync invites to the cache, since they're out of date - syncInvites(point); + const generateInvites = useCallback( + async values => { + debugger; + const _contracts = contracts.getOrElse(null); + const _web3 = web3.getOrElse(null); + const _wallet = wallet.getOrElse(null); + if (!_contracts || !_web3 || !_wallet) { + // not using need because we want a custom error + throw new Error('Internal Error: Missing Contracts/Web3/Wallet'); + } - throw new Error( - `Can currently only send ${planets.length} invites. ` + - `Please remove invites until you are within the limit.` + const nonce = await _web3.eth.getTransactionCount(_wallet.address); + const chainId = await _web3.eth.net.getId(); + const planets = await azimuth.delegatedSending.getPlanetsToSend( + _contracts, + point, + values.emails.length ); - } - clearInvites(); - // NB(shrugs) - must be processed in serial because main thread, etc - let errorCount = 0; - for (let i = 0; i < inputs.length; i++) { - const input = inputs[i]; - try { - const { data: email, name } = inputs[i]; + // account for the race condition where invites got used up while we were + // composing our target list + if (planets.length < values.emails.length) { + // resync invites to the cache, since they're out of date + syncInvites(point); + + return { + [FORM_ERROR]: + `Can currently only send ${planets.length} invites. ` + + `Please remove invites until you are within the limit.`, + }; + } - const { ticket, owner } = await wg.generateTemporaryTicketAndWallet( - MIN_PLANET - // we're always giving planets, so generate a ticket of the correct size - ); + clearInvites(); + // NB(shrugs) - must be processed in serial because main thread, etc + let errorCount = 0; + for (let i = 0; i < values.emails.length; i++) { + try { + const email = values.emails[i]; + + const { ticket, owner } = await wg.generateTemporaryTicketAndWallet( + MIN_PLANET + // we're always giving planets, so generate a ticket of the correct size + ); + + const inviteTx = azimuth.delegatedSending.sendPoint( + _contracts, + point, + planets[i], + owner.keys.address + ); + const signedTx = await signTransaction({ + wallet: _wallet, + walletType, + walletHdPath, + networkType, + // TODO: ^ make a useTransactionSigner to encapsulate this logic + txn: inviteTx, + gasPrice: DEFAULT_GAS_PRICE_GWEI.toString(), + gasLimit: GAS_LIMIT.toString(), + nonce: nonce + i, + chainId, + }); + const rawTx = hexify(signedTx.serialize()); + + addInvite({ [email]: { email, ticket, signedTx, rawTx } }); + } catch (error) { + console.error(error); + errorCount++; + return { + [FORM_ERROR]: `Wallet Error: unable to generate invite wallets.`, + }; + } + } - const inviteTx = azimuth.delegatedSending.sendPoint( - _contracts, - point, - planets[i], - owner.keys.address + if (errorCount > 0) { + throw new Error( + `There ${pluralize(errorCount, 'was', 'were')} ${pluralize( + errorCount, + 'error' + )} while generating wallets.` ); - const signedTx = await signTransaction({ - wallet: _wallet, - walletType, - walletHdPath, - networkType, - // TODO: ^ make a useTransactionSigner to encapsulate this logic - txn: inviteTx, - gasPrice: DEFAULT_GAS_PRICE_GWEI.toString(), - gasLimit: GAS_LIMIT.toString(), - nonce: nonce + i, - chainId, - }); - const rawTx = hexify(signedTx.serialize()); - - addInvite({ [name]: { email, ticket, signedTx, rawTx } }); - } catch (error) { - console.error(error); - errorCount++; - addError({ [input.name]: `Wallet Error: ${input.email}` }); } - } - - if (errorCount > 0) { - throw new Error( - `There ${pluralize(errorCount, 'was', 'were')} ${pluralize( - errorCount, - 'error' - )} while generating wallets.` - ); - } - }, [ - contracts, - web3, - wallet, - inputs, - point, - clearInvites, - getHasReceived, - addError, - syncInvites, - walletType, - walletHdPath, - networkType, - addInvite, - ]); + }, + [ + contracts, + web3, + wallet, + point, + clearInvites, + syncInvites, + walletType, + walletHdPath, + networkType, + addInvite, + ] + ); const sendInvites = useCallback(async () => { const _web3 = web3.getOrElse(null); @@ -308,8 +286,8 @@ export default function InviteEmail() { _web3, point, _wallet.address, - INVITE_COST * inputs.length, - inputs.map(input => invites[input.name].rawTx), + INVITE_COST * cachedEmails.current.length, + cachedEmails.current.map(email => invites[email].rawTx), (address, minBalance, balance) => setNeedFunds({ address, minBalance, balance }), () => setNeedFunds(undefined) @@ -320,8 +298,8 @@ export default function InviteEmail() { let unsentInvites = []; let orphanedInvites = []; - const txAndMailings = inputs.map(async input => { - const invite = invites[input.name]; + const txAndMailings = cachedEmails.current.map(async email => { + const invite = invites[email]; try { const txHash = await sendSignedTransaction( _web3, @@ -339,19 +317,18 @@ export default function InviteEmail() { } try { - const success = await sendMail( + await sendMail( invite.email, invite.ticket, ob.patp(point), invite.rawTx ); - if (!success) throw new Error('Failed to send mail'); } catch (error) { console.error(error); orphanedInvites.push(invite); } - addReceipt({ [input.name]: true }); + addReceipt({ [email]: true }); }); await Promise.all(txAndMailings); @@ -375,47 +352,30 @@ export default function InviteEmail() { if (errorString !== '') { throw new Error(errorString); } - }, [ - web3, - inputs, - addReceipt, - clearReceipts, - invites, - point, - wallet, - sendMail, - ]); - - const onClick = useCallback(async () => { + }, [web3, addReceipt, clearReceipts, invites, point, wallet, sendMail]); + + const onSubmit = useCallback( + async values => { + setStatus(STATUS.GENERATING); + await generateInvites(values); + cachedEmails.current = values.emails; + setStatus(STATUS.CAN_SEND); + }, + [generateInvites] + ); + + const doSend = useCallback(async () => { setGeneralError(null); try { - if (canGenerate) { - setStatus(STATUS.GENERATING); - await generateInvites(); - setStatus(STATUS.CAN_SEND); - } else if (canSend) { - setStatus(STATUS.SENDING); - await sendInvites(); - setStatus(STATUS.SUCCESS); - } + setStatus(STATUS.SENDING); + await sendInvites(); + setStatus(STATUS.SUCCESS); } catch (error) { console.error(error); setGeneralError(error); setStatus(STATUS.FAILURE); } - }, [canGenerate, canSend, generateInvites, sendInvites]); - - // when inputs update, check to see if any of them are duplicates - useEffect(() => { - // compute the list of valid emails - const emails = inputs.map(i => i.data).filter(d => !!d); - if (uniq(emails).length !== emails.length) { - setGeneralError(new Error(`Duplicate email.`)); - } else { - setGeneralError(null); - setStatus(STATUS.INPUT); - } - }, [inputs]); + }, [sendInvites]); // when we transition to done, sync invites because we just sent some // and therefore know that state has changed @@ -425,109 +385,137 @@ export default function InviteEmail() { } }, [isDone, syncInvites, point]); - useEffect(() => { - for (const input of inputs) { - if (input.pass) { - syncHasReceivedForEmail(input.data); - } - } - }, [inputs, syncHasReceivedForEmail]); - return ( - - {/* use hidden class instead of removing component from dom */} - {/* in order to avoid janky reflow */} - appendInput()} - disabled={!canAddInvite} - className={cn({ hidden: isDone })} - solid> - + - - - - {isDone && ( - <> - - {pluralize(inputs.length, 'invite')}{' '} - {pluralize(inputs.length, 'has', 'have')} been successfully sent - - {inputs.map(input => ( - - {invites[input.name].email} - - ))} - - )} - - {!isDone && ( - <> - {/* email inputs */} - {inputs.map((input, i) => { - const isFirst = i === 0; - return ( - setHovered({ [input.name]: true })} - onMouseLeave={() => setHovered({ [input.name]: false })} - full> - - {!isFirst && - (input.focused || hovered[input.name]) && - canInput && ( + + {({ handleSubmit, valid, values }) => ( + + {({ fields }) => ( + <> + + {/* use hidden class instead of removing component from dom */} + {/* in order to avoid janky reflow */} + fields.push('')} + disabled={!canInput || fields.length >= maxInvitesToSend} + className={cn({ hidden: isDone })} + solid> + + + + + + {isDone && ( + <> + + + {pluralize(fields.length, 'invite')} + {' '} + {pluralize(fields.length, 'has', 'have')} been + successfully sent + + + {fields.map(name => ( + + {({ value }) => value} + + ))} + + )} + + {!isDone && ( + <> + {fields.map((name, i) => { + const isFirst = i === 0; + return ( + setHovered({ [name]: true })} + onMouseLeave={() => setHovered({ [name]: false })}> + + + {({ meta: { active } }) => { + return ( + <> + {!isFirst && + (active || hovered[name]) && + canInput && ( + + fields.remove(i)} + solid + secondary> + - + + + )} + + ); + }} + + + ); + })} + + {canInput && ( + + {buttonText(status, fields.length)} + + )} + - removeInputAt(i)} - solid - secondary> - - - + full + as={LoadableButton} + disabled={!(canInput || canSend || valid)} + accessory={`${visualProgress}/${fields.length}`} + onClick={doSend} + success={canSend} + solid> + {buttonText(status, fields.length)} - )} - - ); - })} - - - {buttonText(status, inputs.length)} - - - {needFunds && ( - - - Your ownership address {needFunds.address} needs at least{' '} - {fromWei(needFunds.minBalance)} ETH and currently has{' '} - {fromWei(needFunds.balance)} ETH. Waiting until the account has - enough funds. - - - )} - - {generalError && ( - - {generalError.message.toString()} - - )} - - )} + + {needFunds && ( + + + Your ownership address {needFunds.address} needs at + least {fromWei(needFunds.minBalance)} ETH and + currently has {fromWei(needFunds.balance)} ETH. + Waiting until the account has enough funds. + + + )} + + + + {generalError && ( + + {generalError.message.toString()} + + )} + + )} + + )} + + )} + ); } diff --git a/src/views/IssueChild.js b/src/views/IssueChild.js index 1fda805cf..ea66c285a 100644 --- a/src/views/IssueChild.js +++ b/src/views/IssueChild.js @@ -1,7 +1,6 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { Nothing, Just } from 'folktale/maybe'; +import React, { useCallback, useMemo, useState } from 'react'; import cn from 'classnames'; -import { Grid, Text, Input } from 'indigo-react'; +import { Grid, Text } from 'indigo-react'; import * as azimuth from 'azimuth-js'; import * as ob from 'urbit-ob'; @@ -10,18 +9,25 @@ import { usePointCache } from 'store/pointCache'; import { usePointCursor } from 'store/pointCursor'; import * as need from 'lib/need'; -import { useAddressInput, usePointInput } from 'lib/useInputs'; import useEthereumTransaction from 'lib/useEthereumTransaction'; import { GAS_LIMITS } from 'lib/constants'; import patp2dec from 'lib/patp2dec'; -import useLifecycle from 'lib/useLifecycle'; -import { validateNameInNumberSet } from 'lib/validators'; import { getSpawnCandidate } from 'lib/child'; import { useLocalRouter } from 'lib/LocalRouter'; +import useConstant from 'lib/useConstant'; import ViewHeader from 'components/ViewHeader'; import InlineEthereumTransaction from 'components/InlineEthereumTransaction'; import View from 'components/View'; +import { + composeValidator, + buildPointValidator, + PointInput, + AddressInput, + buildAddressValidator, +} from 'form/Inputs'; +import BridgeForm from 'form/BridgeForm'; +import FormError from 'form/FormError'; function useIssueChild() { const { contracts } = useNetwork(); @@ -55,7 +61,11 @@ export default function IssueChild() { const _contracts = need.contracts(contracts); const _point = parseInt(need.point(pointCursor), 10); - const [availablePoints, setAvailablePoints] = useState(Nothing()); + const availablePointsPromise = useConstant(() => + azimuth.azimuth + .getUnspawnedChildren(_contracts, _point) + .then(points => new Set(points)) + ); const candidates = useMemo(() => { const getCandidate = () => ob.patp(getSpawnCandidate(_point)); @@ -72,62 +82,37 @@ export default function IssueChild() { bind, } = useIssueChild(); - const validators = useMemo( - () => [ - validateNameInNumberSet( - availablePoints.getOrElse(new Set()), - 'This point cannot be spawned.' - ), - ], - [availablePoints] - ); - const [ - pointNameInput, - { pass: validPointName, value: pointName }, - // ^ we use value: here so our effect runs onChange - ] = usePointInput({ - name: 'point', - disabled: inputsLocked, - autoFocus: true, - validators, - error: availablePoints.matchWith({ - Nothing: () => 'Loading availability...', - Just: () => undefined, - }), - }); - - const [ownerInput, { pass: validOwner, data: owner }] = useAddressInput({ - name: 'owner', - label: `Ethereum Address`, - disabled: inputsLocked, - }); - - useEffect(() => { - if (validPointName && validOwner) { - construct(patp2dec(pointName), owner); - } else { - unconstruct(); - } - }, [owner, construct, unconstruct, validPointName, validOwner, pointName]); - - useLifecycle(() => { - let mounted = true; - - (async () => { - const availablePoints = await azimuth.azimuth.getUnspawnedChildren( - _contracts, - _point - ); - - if (!mounted) { - return; + const validatePoint = useCallback( + async name => { + const point = patp2dec(name); + const hasPoint = (await availablePointsPromise).has(point); + + if (!hasPoint) { + return 'This point cannot be spawned.'; } + }, + [availablePointsPromise] + ); - setAvailablePoints(Just(new Set(availablePoints))); - })(); + const validate = useMemo( + () => + composeValidator({ + point: buildPointValidator(4, validatePoint), + owner: buildAddressValidator(), + }), + [validatePoint] + ); - return () => (mounted = false); - }); + const onValues = useCallback( + ({ valid, values }) => { + if (valid) { + construct(patp2dec(values.point), values.owner); + } else { + unconstruct(); + } + }, + [construct, unconstruct] + ); return ( @@ -143,30 +128,52 @@ export default function IssueChild() { )} - {completed && ( - - {pointName} has been spawned and can be claimed by {owner}. - - )} - - {!completed && ( - <> - - - - )} - - pop()} - /> + {}} onValues={onValues}> + {({ handleSubmit, values }) => ( + <> + {completed && ( + + {values.point} has been spawned and can be claimed by{' '} + {values.owner}. + + )} + + {!completed && ( + <> + + + + )} + + + + pop()} + /> + + )} + ); diff --git a/src/views/Party/PartySetPoolSize.js b/src/views/Party/PartySetPoolSize.js index 93130cbfa..5912be6de 100644 --- a/src/views/Party/PartySetPoolSize.js +++ b/src/views/Party/PartySetPoolSize.js @@ -1,6 +1,6 @@ -import React, { useCallback, useEffect } from 'react'; +import React, { useCallback, useMemo } from 'react'; import cn from 'classnames'; -import { Grid, Text, Input } from 'indigo-react'; +import { Grid, Text } from 'indigo-react'; import * as azimuth from 'azimuth-js'; import { useNetwork } from 'store/network'; @@ -8,7 +8,6 @@ import { usePointCursor } from 'store/pointCursor'; import { usePointCache } from 'store/pointCache'; import * as need from 'lib/need'; -import { usePointInput, useNumberInput } from 'lib/useInputs'; import useEthereumTransaction from 'lib/useEthereumTransaction'; import { GAS_LIMITS } from 'lib/constants'; import { useLocalRouter } from 'lib/LocalRouter'; @@ -19,6 +18,15 @@ import patp2dec from 'lib/patp2dec'; import View from 'components/View'; import useCurrentPermissions from 'lib/useCurrentPermissions'; import WarningBox from 'components/WarningBox'; +import BridgeForm from 'form/BridgeForm'; +import { + PointInput, + composeValidator, + NumberInput, + buildNumberValidator, + buildPointValidator, +} from 'form/Inputs'; +import FormError from 'form/FormError'; function useSetPoolSize() { const { contracts } = useNetwork(); @@ -60,31 +68,25 @@ export default function PartySetPoolSize() { bind, } = useSetPoolSize(); - const [poolOwnerInput, { pass: validPoint, data: poolOwner }] = usePointInput( - { - name: 'point', - label: 'Point', - disabled: inputsLocked, - } + const validate = useMemo( + () => + composeValidator({ + poolOwner: buildPointValidator(), + poolSize: buildNumberValidator(0), + }), + [] ); - const [ - poolSizeInput, - { pass: validPoolSize, data: poolSize }, - ] = useNumberInput({ - name: 'poolsize', - label: 'Pool Size', - initialValue: 5, - disabled: inputsLocked, - }); - - useEffect(() => { - if (validPoint && validPoolSize) { - construct(patp2dec(poolOwner), poolSize); - } else { - unconstruct(); - } - }, [poolOwner, construct, validPoint, validPoolSize, poolSize, unconstruct]); + const onValues = useCallback( + ({ valid, values }) => { + if (valid) { + construct(patp2dec(values.poolOwner), values.poolSize); + } else { + unconstruct(); + } + }, + [construct, unconstruct] + ); return ( @@ -92,6 +94,7 @@ export default function PartySetPoolSize() { Set Pool Size + {!spawnIsDelegatedSending && ( The spawn proxy must be set to{' '} @@ -101,30 +104,57 @@ export default function PartySetPoolSize() { for invitations to be available. )} - - {completed - ? `${poolSize} invites have been allocated to ${poolOwner}` - : `Allocate invites to a child point.`} - - - {!completed && ( - <> - - - - )} - pop()} - /> + {}} + onValues={onValues} + initialValues={{ poolSize: 5 }}> + {({ handleSubmit, values }) => ( + <> + + {completed + ? `${values.poolSize} invites have been allocated to ${values.poolOwner}` + : `Allocate invites to a child point.`} + + + {!completed && ( + <> + + + + )} + + + + pop()} + /> + + )} + ); From 6526dd7a5b44018abbeff40aeb54e18095c38fc9 Mon Sep 17 00:00:00 2001 From: "Matt Condon (shrugs)" Date: Tue, 6 Aug 2019 10:46:49 +0200 Subject: [PATCH 10/43] feat: finalize final-form migration --- src/components/InlineEthereumTransaction.js | 6 +- src/components/UploadButton.js | 4 +- src/form/BridgeForm.js | 36 ++++- src/form/Inputs.js | 99 ------------ src/form/validators.js | 104 +++++++++++++ src/indigo-react/components/SelectInput.js | 1 - src/lib/useEthereumTransaction.js | 16 +- src/lib/useRouter.js | 4 +- src/views/Activate/ActivateCode.js | 65 +++----- src/views/Activate/PassportVerify.js | 20 +-- src/views/Admin/AdminNetworkingKeys.js | 38 ++--- src/views/Admin/AdminSetProxy.js | 27 ++-- src/views/Admin/AdminTransfer.js | 9 +- src/views/Admin/Reticket/ReticketVerify.js | 20 +-- src/views/CreateGalaxy.js | 6 +- src/views/Disclaimer.js | 9 +- src/views/Invite/InviteEmail.js | 158 +++++++++++--------- src/views/IssueChild.js | 7 +- src/views/Login/Keystore.js | 8 +- src/views/Login/Ledger.js | 29 ++-- src/views/Login/Mnemonic.js | 15 +- src/views/Login/PrivateKey.js | 9 +- src/views/Login/Ticket.js | 27 ++-- src/views/Login/Trezor.js | 25 +++- src/views/Party/PartySetPoolSize.js | 15 +- 25 files changed, 418 insertions(+), 339 deletions(-) create mode 100644 src/form/validators.js diff --git a/src/components/InlineEthereumTransaction.js b/src/components/InlineEthereumTransaction.js index 4c7df4818..8c7460e1c 100644 --- a/src/components/InlineEthereumTransaction.js +++ b/src/components/InlineEthereumTransaction.js @@ -17,7 +17,7 @@ import { hexify } from 'lib/txn'; import { GenerateButton, ForwardButton, RestartButton } from './Buttons'; import WarningBox from './WarningBox'; -import { composeValidator, buildCheckboxValidator } from 'form/Inputs'; +import { composeValidator, buildCheckboxValidator } from 'form/validators'; import BridgeForm from 'form/BridgeForm'; import Condition from 'form/Condition'; @@ -123,7 +123,7 @@ export default function InlineEthereumTransaction({ return ( - {}}> + {({ handleSubmit }) => ( <> {renderPrimaryButton()} @@ -151,7 +151,7 @@ export default function InlineEthereumTransaction({ name="useAdvanced" label="Advanced Configuration" inverseLabel="Cancel Advanced Configuration" - disabled={!showConfigureInput} + disabled={!showConfigureInput || initializing} /> diff --git a/src/components/UploadButton.js b/src/components/UploadButton.js index 3d3b6607e..42f005415 100644 --- a/src/components/UploadButton.js +++ b/src/components/UploadButton.js @@ -6,7 +6,9 @@ export default function UploadButton({ children, onChange, ...rest }) { return ( ); diff --git a/src/views/Admin/AdminTransfer.js b/src/views/Admin/AdminTransfer.js index 9b484a72c..50dca04fb 100644 --- a/src/views/Admin/AdminTransfer.js +++ b/src/views/Admin/AdminTransfer.js @@ -15,11 +15,8 @@ import { GAS_LIMITS } from 'lib/constants'; import ViewHeader from 'components/ViewHeader'; import InlineEthereumTransaction from 'components/InlineEthereumTransaction'; -import { - AddressInput, - composeValidator, - buildAddressValidator, -} from 'form/Inputs'; +import { AddressInput } from 'form/Inputs'; +import { composeValidator, buildAddressValidator } from 'form/validators'; import BridgeForm from 'form/BridgeForm'; import FormError from 'form/FormError'; @@ -75,7 +72,7 @@ export default function AdminTransfer() { Transfer Point - {}} onValues={onValues}> + {({ handleSubmit, values }) => ( <> ({ + ticket: STUB_VERIFY_TICKET ? ticket : 'undefined', + }), + [ticket] + ); + const goExecute = useCallback(() => push(names.EXECUTE), [push, names]); return ( @@ -39,10 +43,8 @@ export default function ReticketVerify({ newWallet }) { + afterSubmit={goExecute} + initialValues={initialValues}> {({ handleSubmit }) => ( <> - {}} onValues={onValues}> + {({ handleSubmit, values }) => ( <> {completed && ( diff --git a/src/views/Disclaimer.js b/src/views/Disclaimer.js index 0605b9c4c..2dffd9c73 100644 --- a/src/views/Disclaimer.js +++ b/src/views/Disclaimer.js @@ -9,7 +9,7 @@ import View from 'components/View'; import WarningBox from 'components/WarningBox'; import BridgeForm from 'form/BridgeForm'; import SubmitButton from 'form/SubmitButton'; -import { composeValidator, buildCheckboxValidator } from 'form/Inputs'; +import { composeValidator, buildCheckboxValidator } from 'form/validators'; const TEXT_STYLE = 'f5'; @@ -22,6 +22,8 @@ export default function ActivateDisclaimer() { [] ); + const initialValues = useMemo(() => ({ checkbox: false }), []); + const goBack = useCallback(async () => { setHasDisclaimed(true); pop(); @@ -78,7 +80,10 @@ export default function ActivateDisclaimer() { Warning: Nobody but you can restore or reset your Master Ticket - + {({ handleSubmit }) => ( <> name => { const emailFormatValidator = buildEmailValidator(); -// TODO: test with tank, successful txs export default function InviteEmail() { - // TODO: resumption after error? const { contracts, web3, networkType } = useNetwork(); const { wallet, walletType, walletHdPath } = useWallet(); const { syncInvites, getInvites } = usePointCache(); @@ -126,41 +135,44 @@ export default function InviteEmail() { const isFailed = status === STATUS.FAILURE; const isDone = status === STATUS.SUCCESS; - const validate = useCallback( - async values => { - // compute the list of valid emails - const emails = values.emails.filter(d => !!d); - if (uniq(emails).length !== emails.length) { - return { [FORM_ERROR]: 'Duplicate email.' }; + const validateEmail = useCallback( + async email => { + // check individual email validity + const error = await emailFormatValidator(email); + if (error) { + return error; } - let errors = {}; - for (let i = 0; i < values.emails.length; i++) { - const name = `emails[${i}]`; - const email = values.emails[i]; - - // check individual email validity - // TODO: validate each email individually using similar pattern from form/Inputs - // and then reutrn that - const error = await emailFormatValidator(email); - if (error) { - errors[name] = error; - continue; - } - - const hasReceived = await getHasReceived(email); - if (hasReceived) { - errors[name] = HAS_RECEIVED_TEXT; - continue; - } + const hasReceived = await getHasReceived(email); + if (hasReceived) { + return HAS_RECEIVED_TEXT; } - - console.log(errors); - return errors; }, [getHasReceived] ); + const validateEmails = useCallback( + async emails => await Promise.all(emails.map(validateEmail)), + [validateEmail] + ); + + const validateForm = useCallback(async (values, errors) => { + if (hasErrors(errors)) { + return errors; + } + + // check for email uniqenesss + const emails = values.emails.filter(d => !!d); + if (uniq(emails).length !== emails.length) { + return { [FORM_ERROR]: 'Duplicate email.' }; + } + }, []); + + const validate = useMemo( + () => composeValidator({ emails: validateEmails }, validateForm), + [validateEmails, validateForm] + ); + // progress is [0, .length] of invites or receipts, as we're generating them const progress = isGenerating ? Object.keys(invites).length @@ -179,7 +191,6 @@ export default function InviteEmail() { const generateInvites = useCallback( async values => { - debugger; const _contracts = contracts.getOrElse(null); const _web3 = web3.getOrElse(null); const _wallet = wallet.getOrElse(null); @@ -215,6 +226,7 @@ export default function InviteEmail() { for (let i = 0; i < values.emails.length; i++) { try { const email = values.emails[i]; + const planet = planets[i]; const { ticket, owner } = await wg.generateTemporaryTicketAndWallet( MIN_PLANET @@ -224,40 +236,40 @@ export default function InviteEmail() { const inviteTx = azimuth.delegatedSending.sendPoint( _contracts, point, - planets[i], + planet, owner.keys.address ); + const signedTx = await signTransaction({ wallet: _wallet, walletType, walletHdPath, networkType, + chainId, + nonce: nonce + i, // TODO: ^ make a useTransactionSigner to encapsulate this logic txn: inviteTx, gasPrice: DEFAULT_GAS_PRICE_GWEI.toString(), gasLimit: GAS_LIMIT.toString(), - nonce: nonce + i, - chainId, }); + const rawTx = hexify(signedTx.serialize()); addInvite({ [email]: { email, ticket, signedTx, rawTx } }); } catch (error) { console.error(error); errorCount++; - return { - [FORM_ERROR]: `Wallet Error: unable to generate invite wallets.`, - }; } } if (errorCount > 0) { - throw new Error( - `There ${pluralize(errorCount, 'was', 'were')} ${pluralize( + return { + [FORM_ERROR]: `There ${pluralize( errorCount, - 'error' - )} while generating wallets.` - ); + 'was', + 'were' + )} ${pluralize(errorCount, 'error')} while generating wallets.`, + }; } }, [ @@ -275,6 +287,7 @@ export default function InviteEmail() { ); const sendInvites = useCallback(async () => { + const emails = cachedEmails.current; const _web3 = web3.getOrElse(null); const _wallet = wallet.getOrElse(null); if (!_web3 || !_wallet) { @@ -286,8 +299,8 @@ export default function InviteEmail() { _web3, point, _wallet.address, - INVITE_COST * cachedEmails.current.length, - cachedEmails.current.map(email => invites[email].rawTx), + INVITE_COST * emails.length, + emails.map(email => invites[email].rawTx), (address, minBalance, balance) => setNeedFunds({ address, minBalance, balance }), () => setNeedFunds(undefined) @@ -298,7 +311,7 @@ export default function InviteEmail() { let unsentInvites = []; let orphanedInvites = []; - const txAndMailings = cachedEmails.current.map(async email => { + const txAndMailings = emails.map(async email => { const invite = invites[email]; try { const txHash = await sendSignedTransaction( @@ -356,9 +369,12 @@ export default function InviteEmail() { const onSubmit = useCallback( async values => { - setStatus(STATUS.GENERATING); - await generateInvites(values); cachedEmails.current = values.emails; + setStatus(STATUS.GENERATING); + const errors = await generateInvites(values); + if (errors) { + return errors; + } setStatus(STATUS.CAN_SEND); }, [generateInvites] @@ -372,7 +388,7 @@ export default function InviteEmail() { setStatus(STATUS.SUCCESS); } catch (error) { console.error(error); - setGeneralError(error); + setGeneralError(error.message); setStatus(STATUS.FAILURE); } }, [sendInvites]); @@ -407,7 +423,7 @@ export default function InviteEmail() { - {isDone && ( + {isDone ? ( <> @@ -419,13 +435,13 @@ export default function InviteEmail() { {fields.map(name => ( - {({ value }) => value} + + {({ input: { value } }) => value} + ))} - )} - - {!isDone && ( + ) : ( <> {fields.map((name, i) => { const isFirst = i === 0; @@ -471,26 +487,28 @@ export default function InviteEmail() { ); })} - {canInput && ( + {canInput ? ( + handleSubmit={handleSubmit} + accessory={`${visualProgress}/${fields.length}`}> + {buttonText(status, fields.length)} + + ) : ( + {buttonText(status, fields.length)} )} - - {buttonText(status, fields.length)} - - {needFunds && ( @@ -505,8 +523,8 @@ export default function InviteEmail() { {generalError && ( - - {generalError.message.toString()} + + {generalError} )} diff --git a/src/views/IssueChild.js b/src/views/IssueChild.js index ea66c285a..dc57777cf 100644 --- a/src/views/IssueChild.js +++ b/src/views/IssueChild.js @@ -19,13 +19,12 @@ import useConstant from 'lib/useConstant'; import ViewHeader from 'components/ViewHeader'; import InlineEthereumTransaction from 'components/InlineEthereumTransaction'; import View from 'components/View'; +import { PointInput, AddressInput } from 'form/Inputs'; import { composeValidator, buildPointValidator, - PointInput, - AddressInput, buildAddressValidator, -} from 'form/Inputs'; +} from 'form/validators'; import BridgeForm from 'form/BridgeForm'; import FormError from 'form/FormError'; @@ -128,7 +127,7 @@ export default function IssueChild() { )} - {}} onValues={onValues}> + {({ handleSubmit, values }) => ( <> {completed && ( diff --git a/src/views/Login/Keystore.js b/src/views/Login/Keystore.js index 05b8b3917..4d3911c93 100644 --- a/src/views/Login/Keystore.js +++ b/src/views/Login/Keystore.js @@ -8,19 +8,19 @@ import { useWallet } from 'store/wallet'; import { EthereumWallet, WALLET_TYPES } from 'lib/wallet'; import useLoginView from 'lib/useLoginView'; +import { PassphraseInput } from 'form/Inputs'; import { composeValidator, buildPassphraseValidator, - PassphraseInput, buildUploadValidator, -} from 'form/Inputs'; +} from 'form/validators'; import UploadInput from 'form/UploadInput'; import ContinueButton from './ContinueButton'; import BridgeForm from 'form/BridgeForm'; import { FORM_ERROR } from 'final-form'; import FormError from 'form/FormError'; -export default function Keystore({ className }) { +export default function Keystore({ className, goHome }) { useLoginView(WALLET_TYPES.KEYSTORE); // globals @@ -61,7 +61,7 @@ export default function Keystore({ className }) { encrypted with a password, you'll also need to enter that below. - + {({ handleSubmit }) => ( <> { @@ -103,6 +102,16 @@ export default function Ledger({ className, goHome }) { } }, []); + const initialValues = useMemo( + () => ({ + useCustomPath: false, + hdpath: PATH_OPTIONS[0].value.replace(/x/g, ACCOUNT_OPTIONS[0].value), + derivationpath: PATH_OPTIONS[0].value, + account: ACCOUNT_OPTIONS[0].value, + }), + [] + ); + const full = useBreakpoints([true, true, false]); const half = useBreakpoints([false, false, true]); const isHTTPS = document.location.protocol === 'https:'; @@ -162,12 +171,8 @@ export default function Ledger({ className, goHome }) { validate={validate} onValues={onValues} onSubmit={onSubmit} - initialValues={{ - useCustomPath: false, - hdpath: PATH_OPTIONS[0].value.replace(/x/g, ACCOUNT_OPTIONS[0].value), - derivationpath: PATH_OPTIONS[0].value, - account: ACCOUNT_OPTIONS[0].value, - }}> + afterSubmit={goHome} + initialValues={initialValues}> {({ handleSubmit }) => ( <> diff --git a/src/views/Login/Mnemonic.js b/src/views/Login/Mnemonic.js index 42bafdc5f..0a527f9f5 100644 --- a/src/views/Login/Mnemonic.js +++ b/src/views/Login/Mnemonic.js @@ -7,16 +7,14 @@ import { useWallet } from 'store/wallet'; import { walletFromMnemonic, WALLET_TYPES } from 'lib/wallet'; import useLoginView from 'lib/useLoginView'; +import { MnemonicInput, HdPathInput, PassphraseInput } from 'form/Inputs'; import { - MnemonicInput, - HdPathInput, - PassphraseInput, composeValidator, buildMnemonicValidator, buildCheckboxValidator, buildPassphraseValidator, buildHdPathValidator, -} from 'form/Inputs'; +} from 'form/validators'; import BridgeForm from 'form/BridgeForm'; import Condition from 'form/Condition'; import FormError from 'form/FormError'; @@ -60,13 +58,18 @@ export default function Mnemonic({ className, goHome }) { [setAuthMnemonic, setWallet, setWalletHdPath] ); + const initialValues = useMemo( + () => ({ hdpath: walletHdPath, useAdvanced: false }), + [walletHdPath] + ); + return ( + afterSubmit={goHome} + initialValues={initialValues}> {({ handleSubmit }) => ( <> - + {({ handleSubmit }) => ( <> ({ + point: impliedPoint || '', + usePasshrase: false, + useShards: false, + }), + [impliedPoint] + ); + return ( + afterSubmit={goHome} + initialValues={initialValues}> {({ handleSubmit }) => ( <> diff --git a/src/views/Login/Trezor.js b/src/views/Login/Trezor.js index 2c681a7d7..c93f453b4 100644 --- a/src/views/Login/Trezor.js +++ b/src/views/Login/Trezor.js @@ -24,19 +24,21 @@ import { buildCheckboxValidator, buildSelectValidator, buildHdPathValidator, -} from 'form/Inputs'; +} from 'form/validators'; import BridgeForm from 'form/BridgeForm'; import Condition from 'form/Condition'; -import ContinueButton from './ContinueButton'; import FormError from 'form/FormError'; +import ContinueButton from './ContinueButton'; +import { FORM_ERROR } from 'final-form'; + const ACCOUNT_OPTIONS = times(20, i => ({ text: `Account #${i + 1}`, value: i, })); // see Ledger.js for context — Trezor is basicaly Ledger with less complexity -export default function Trezor({ className }) { +export default function Trezor({ className, goHome }) { useLoginView(WALLET_TYPES.TREZOR); const { setWallet, setWalletHdPath } = useWallet(); @@ -72,6 +74,7 @@ export default function Trezor({ className }) { setWalletHdPath(values.hdPath); } else { setWallet(Nothing()); + return { [FORM_ERROR]: 'Failed to authenticate with your Trezor.' }; } }, [setWallet, setWalletHdPath] @@ -87,6 +90,15 @@ export default function Trezor({ className }) { } }, []); + const initialValues = useMemo( + () => ({ + useCustomPath: false, + hdpath: TREZOR_PATH.replace(/x/g, ACCOUNT_OPTIONS[0].value), + account: ACCOUNT_OPTIONS[0].value, + }), + [] + ); + return ( @@ -102,11 +114,8 @@ export default function Trezor({ className }) { validate={validate} onValues={onValues} onSubmit={onSubmit} - initialValues={{ - useCustomPath: false, - hdpath: TREZOR_PATH.replace(/x/g, ACCOUNT_OPTIONS[0].value), - account: ACCOUNT_OPTIONS[0].value, - }}> + afterSubmit={goHome} + initialValues={initialValues}> {({ handleSubmit }) => ( <> diff --git a/src/views/Party/PartySetPoolSize.js b/src/views/Party/PartySetPoolSize.js index 5912be6de..275619365 100644 --- a/src/views/Party/PartySetPoolSize.js +++ b/src/views/Party/PartySetPoolSize.js @@ -11,21 +11,21 @@ import * as need from 'lib/need'; import useEthereumTransaction from 'lib/useEthereumTransaction'; import { GAS_LIMITS } from 'lib/constants'; import { useLocalRouter } from 'lib/LocalRouter'; +import useCurrentPermissions from 'lib/useCurrentPermissions'; +import patp2dec from 'lib/patp2dec'; import ViewHeader from 'components/ViewHeader'; import InlineEthereumTransaction from 'components/InlineEthereumTransaction'; -import patp2dec from 'lib/patp2dec'; import View from 'components/View'; -import useCurrentPermissions from 'lib/useCurrentPermissions'; import WarningBox from 'components/WarningBox'; + import BridgeForm from 'form/BridgeForm'; +import { PointInput, NumberInput } from 'form/Inputs'; import { - PointInput, composeValidator, - NumberInput, buildNumberValidator, buildPointValidator, -} from 'form/Inputs'; +} from 'form/validators'; import FormError from 'form/FormError'; function useSetPoolSize() { @@ -88,6 +88,8 @@ export default function PartySetPoolSize() { [construct, unconstruct] ); + const initialValues = useMemo(() => ({ poolSize: 5 }), []); + return ( @@ -107,9 +109,8 @@ export default function PartySetPoolSize() { {}} onValues={onValues} - initialValues={{ poolSize: 5 }}> + initialValues={initialValues}> {({ handleSubmit, values }) => ( <> Date: Tue, 6 Aug 2019 11:18:03 +0200 Subject: [PATCH 11/43] fix: clean up validator logic, so fresh so clean --- src/form/validators.js | 66 +++-- src/lib/validators.js | 303 ++++----------------- src/views/Activate/ActivateCode.js | 4 +- src/views/Activate/PassportVerify.js | 4 +- src/views/Admin/Reticket/ReticketVerify.js | 4 +- src/views/CreateGalaxy.js | 2 +- src/views/IssueChild.js | 2 +- src/views/Login/Ticket.js | 10 +- 8 files changed, 108 insertions(+), 287 deletions(-) diff --git a/src/form/validators.js b/src/form/validators.js index 291aa2e84..55b496139 100644 --- a/src/form/validators.js +++ b/src/form/validators.js @@ -2,8 +2,7 @@ import { some } from 'lodash'; import { validateNotEmpty, - validateTicket, - kDefaultValidator, + validatePatq, validateMnemonic, validatePoint, validateMaximumPatpByteLength, @@ -13,19 +12,21 @@ import { validateEthereumAddress, validateGreaterThan, validateEmail, + validateNotNullAddress, } from 'lib/validators'; -import { compose } from 'lib/lib'; -const buildValidator = ( - validators = [], - fn = () => undefined -) => async value => { - return ( - compose( - ...validators, - kDefaultValidator - )(value).error || (await fn(value)) - ); +// iterate over validators, exiting early if there's an error +const buildValidator = (validators = []) => async value => { + for (const validator of validators) { + try { + const error = await validator(value); + if (error) { + return error; + } + } catch (error) { + return error.message; + } + } }; // error object has errors if some of its fields are @@ -34,10 +35,10 @@ const buildValidator = ( export const hasErrors = iter => some(iter, v => (Array.isArray(v) ? hasErrors(v) : v !== undefined)); -export const buildTicketValidator = (validators = []) => - buildValidator([...validators, validateTicket, validateNotEmpty]); +export const buildPatqValidator = (validators = []) => + buildValidator([validateNotEmpty, validatePatq, ...validators]); export const buildMnemonicValidator = () => - buildValidator([validateMnemonic, validateNotEmpty]); + buildValidator([validateNotEmpty, validateMnemonic]); export const buildCheckboxValidator = mustBe => buildValidator([ validateOneOf(mustBe !== undefined ? [mustBe] : [true, false]), @@ -45,34 +46,40 @@ export const buildCheckboxValidator = mustBe => export const buildPassphraseValidator = () => buildValidator([]); // TODO: validate hdpath format export const buildHdPathValidator = () => buildValidator([validateNotEmpty]); -export const buildPointValidator = (size = 4, validate) => - buildValidator( - [validatePoint, validateMaximumPatpByteLength(size), validateNotEmpty], - validate - ); +export const buildPointValidator = (size = 4, validators = []) => + buildValidator([ + validateNotEmpty, + validateMaximumPatpByteLength(size), + validatePoint, + ...validators, + ]); export const buildSelectValidator = options => buildValidator([validateOneOf(options.map(option => option.value))]); export const buildHexValidator = length => buildValidator([ - validateHexLength(length), - validateHexString, validateNotEmpty, + validateHexString, + validateHexLength(length), ]); export const buildUploadValidator = () => buildValidator([validateNotEmpty]); export const buildAddressValidator = () => buildValidator([ - validateEthereumAddress, - validateHexLength(40), - validateHexString, validateNotEmpty, + validateHexString, + validateHexLength(40), + validateNotNullAddress, + validateEthereumAddress, ]); export const buildNumberValidator = (min = 0) => buildValidator([validateGreaterThan(min)]); export const buildEmailValidator = () => - buildValidator([validateEmail, validateNotEmpty]); + buildValidator([validateNotEmpty, validateEmail]); // the default form validator just returns field-level validations const kDefaultFormValidator = (values, errors) => errors; + +// the form validator is the composition of all of the field validators +// plus an additional form validator function export const composeValidator = ( fieldValidators = {}, formValidator = kDefaultFormValidator @@ -83,6 +90,7 @@ export const composeValidator = ( fieldValidators[name](value) ); + // async reduce errors per-field into an errors object const fieldLevelValidator = async values => { const errors = await Promise.all( names.map((name, i) => fieldLevelValidators[i](values[name])) @@ -97,8 +105,12 @@ export const composeValidator = ( ); }; + // final-form `validate` function return async values => { + // ask for field-level errors const errors = await fieldLevelValidator(values); + // pass the current values and their validity to the form-level validator + // that can implement conditional logic and more complex validations return await formValidator(values, errors); }; }; diff --git a/src/lib/validators.js b/src/lib/validators.js index b851a2e0d..c12d1b85d 100644 --- a/src/lib/validators.js +++ b/src/lib/validators.js @@ -1,6 +1,6 @@ import * as bip39 from 'bip39'; import * as ob from 'urbit-ob'; -import { identity, includes } from 'lodash'; +import { includes } from 'lodash'; import { isValidAddress, ETH_ZERO_ADDR, ETH_ZERO_ADDR_SHORT } from './wallet'; import patp2dec from './patp2dec'; @@ -14,274 +14,83 @@ import { MIN_GALAXY, MAX_GALAXY } from './constants'; // via: https://emailregex.com/ const emailRegExp = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; -// is this 64 hex chars? -const plain64CharHexValue = /[0-9A-Fa-f]{64}/; - // is this only hex values with a 0x prefix? const isHexString = /0x[0-9A-Fa-f]/; -// Wraps single validation functions in a controlled and predictable way. -export const simpleValidatorWrapper = ({ - prevMessage, - transform = identity, - validator, - error, -}) => { - // If a previous validation has already failed, skip this validation and - // return the prev message to the next stage in the validation function chain. - // Failed validations should drop all the way down the chain and drop out of - // the output. - if (!prevMessage.pass) { - return prevMessage; - } - - // Run the validator and return the result. - const pass = validator(prevMessage.data); - const data = pass ? transform(prevMessage.data) : prevMessage.data; - - return newMessage(data, pass, !pass && error); -}; - -// Validation message -// { -// data: ... -// pass: ... -// error: ... -// } -// Creates a new validation message in a uniform way. -const newMessage = (data, pass, error) => ({ - // The input data - data, - // Has the data passed validation? - pass, - // If data has failed a validator, the error message goes here. - error, -}); - -// A validator that always passes. -export const kDefaultValidator = data => newMessage(data, true, null); - // Validates a bip39 mnemonic -export const validateMnemonic = m => - simpleValidatorWrapper({ - prevMessage: m, - validator: d => bip39.validateMnemonic(d), - error: 'This is not a valid mnemonic.', - }); +export const validateMnemonic = v => + !bip39.validateMnemonic(v) && 'This is not a valid mnemonic.'; // Checks an empty field -export const validateNotEmpty = m => - simpleValidatorWrapper({ - prevMessage: m, - validator: d => { - try { - return d.length > 0; - } catch { - return false; - } - }, - error: 'This field is required.', - }); +export const validateNotEmpty = v => + v.length === 0 && 'This field is required.'; // Checks if a patp is a valid galaxy -export const validateGalaxy = m => - simpleValidatorWrapper({ - prevMessage: m, - validator: d => { - let point; - try { - point = patp2dec(d); - return point >= MIN_GALAXY && point <= MAX_GALAXY; - } catch (e) { - return false; - } - }, - error: 'This is not a valid galaxy.', - }); - -export const validatePoint = m => - simpleValidatorWrapper({ - prevMessage: m, - validator: d => { - try { - return ob.isValidPatp(d); - } catch (e) { - return false; - } - }, - error: 'This is not a valid point.', - }); - -export const validateTicket = m => - simpleValidatorWrapper({ - prevMessage: m, - validator: d => { - try { - return ob.isValidPatq(d); - } catch (e) { - return false; - } - }, - error: 'This is not a valid ticket.', - }); - -export const validateShard = m => - simpleValidatorWrapper({ - prevMessage: m, - validator: d => { - try { - return ob.isValidPatq(d); - } catch (e) { - return false; - } - }, - error: 'This is not a valid shard.', - }); - -export const validateOneOf = (options = []) => m => - simpleValidatorWrapper({ - prevMessage: m, - validator: d => includes(options, d), - error: `Is not a valid option.`, - }); - -export const validateHexString = m => - simpleValidatorWrapper({ - prevMessage: m, - validator: d => isHexString.test(d), - error: 'This is not a valid hex string.', - }); - -// @deprecate -export const validateNetworkKey = m => - simpleValidatorWrapper({ - prevMessage: m, - validator: d => plain64CharHexValue.test(d), - error: 'This is not a valid network key.', - }); +export const validateGalaxy = v => { + try { + const point = patp2dec(v); + const isValidGalaxy = point >= MIN_GALAXY && point <= MAX_GALAXY; + if (!isValidGalaxy) { + throw new Error(); + } + } catch { + return 'This is not a valid galaxy.'; + } +}; -// @deprecate -export const validateNetworkSeed = m => - simpleValidatorWrapper({ - prevMessage: m, - validator: d => plain64CharHexValue.test(d), - error: 'This is not a valid network seed.', - }); +export const validatePoint = v => { + try { + if (!ob.isValidPatp(v)) { + throw new Error(); + } + } catch { + return 'This is not a valid point.'; + } +}; -// Checks if a string is a valid ethereum address -export const validateEthereumAddress = m => - simpleValidatorWrapper({ - prevMessage: m, - validator: d => isValidAddress(d), - error: 'This is not a valid Ethereum address.', - }); +export const validatePatq = v => { + try { + if (!ob.isValidPatq(v)) { + throw new Error(); + } + } catch { + return 'This is not a valid ticket.'; + } +}; -export const validateEmail = m => - simpleValidatorWrapper({ - prevMessage: m, - validator: d => emailRegExp.test(d), - error: 'This is not a valid email address.', - }); +export const validateOneOf = (options = []) => v => + !includes(options, v) && 'Is not a valid option.'; -export const validateInt = m => - simpleValidatorWrapper({ - prevMessage: m, - transform: d => parseInt(d, 10), - validator: d => { - try { - parseInt(d, 10); - return; - } catch { - return false; - } - }, - error: 'This is not a valid number.', - }); +export const validateHexString = v => + !isHexString.test(v) && 'This is not a valid hex string.'; -export const validateExactly = (value, error) => m => - simpleValidatorWrapper({ - prevMessage: m, - validator: d => d === value, - error, - }); +export const validateEthereumAddress = v => + !isValidAddress(v) && 'This is not a valid Ethereum address.'; -export const validateNotAny = (values = []) => m => - simpleValidatorWrapper({ - prevMessage: m, - validator: d => !values.includes(d), - error: `Cannot be ${m.data}.`, - }); +export const validateEmail = v => + !emailRegExp.test(v) && 'This is not a valid email address.'; -export const validateLength = l => m => - simpleValidatorWrapper({ - prevMessage: m, - validator: d => { - try { - return d.length === l; - } catch { - return false; - } - }, - error: `Must be exactly ${l} characters.`, - }); +export const validateExactly = (value, error) => v => v !== value && error; -export const validateHexLength = l => m => - simpleValidatorWrapper({ - prevMessage: m, - validator: d => { - try { - return d.length === l + 2; - } catch { - return false; - } - }, - error: `Must be exactly ${l} hex characters.`, - }); +export const validateNotAny = (values = []) => v => + values.includes(v) && `Cannot be ${v}.`; -export const validateMaximumLength = l => m => - simpleValidatorWrapper({ - prevMessage: m, - validator: d => { - try { - return d.length <= l; - } catch { - return false; - } - }, - error: `Must be ${l} characters or fewer.`, - }); +export const validateLength = l => v => + v.length !== l && `Must be exactly ${l} characters.`; -export const validateGreaterThan = l => m => - simpleValidatorWrapper({ - prevMessage: m, - validator: d => d > l, - error: `Must be at least ${l}.`, - }); +export const validateHexLength = l => v => + v.length !== l + 2 && `Must be exactly ${l} hex characters.`; -export const validateInSet = (set, error) => m => - simpleValidatorWrapper({ - prevMessage: m, - validator: d => set.has(d), - error, - }); +export const validateMaximumLength = l => v => + v.length > l && `Must be ${l} characters or fewer.`; -export const validateNameInNumberSet = (set, error) => m => - simpleValidatorWrapper({ - prevMessage: m, - validator: p => { - const num = patp2dec(p); - return set.has(num); - }, - error, - }); +export const validateGreaterThan = l => v => + !(v > l) && `Must be at least ${l}.`; -export const validatePatpByteLength = byteLength => { - return validateLength(patpStringLength(byteLength)); -}; +export const validateInSet = (set, error) => v => !set.has(v) && error; -export const validateMaximumPatpByteLength = byteLength => { - return validateMaximumLength(patpStringLength(byteLength)); -}; +export const validateMaximumPatpByteLength = byteLength => + validateMaximumLength(patpStringLength(byteLength)); export const validateNotNullAddress = validateNotAny([ ETH_ZERO_ADDR, diff --git a/src/views/Activate/ActivateCode.js b/src/views/Activate/ActivateCode.js index 4b2a89651..61309e0ac 100644 --- a/src/views/Activate/ActivateCode.js +++ b/src/views/Activate/ActivateCode.js @@ -25,7 +25,7 @@ import useHasDisclaimed from 'lib/useHasDisclaimed'; import BridgeForm from 'form/BridgeForm'; import SubmitButton from 'form/SubmitButton'; import { TicketInput } from 'form/Inputs'; -import { composeValidator, buildTicketValidator } from 'form/validators'; +import { composeValidator, buildPatqValidator } from 'form/validators'; import FormError from 'form/FormError'; import { FORM_ERROR } from 'final-form'; @@ -56,7 +56,7 @@ export default function ActivateCode() { }, [names, push, hasDisclaimed]); const validate = useMemo( - () => composeValidator({ ticket: buildTicketValidator() }), + () => composeValidator({ ticket: buildPatqValidator() }), [] ); diff --git a/src/views/Activate/PassportVerify.js b/src/views/Activate/PassportVerify.js index 8f2be075a..18c3d0500 100644 --- a/src/views/Activate/PassportVerify.js +++ b/src/views/Activate/PassportVerify.js @@ -8,7 +8,7 @@ import { isDevelopment } from 'lib/flags'; import SubmitButton from 'form/SubmitButton'; import { TicketInput } from 'form/Inputs'; -import { composeValidator, buildTicketValidator } from 'form/validators'; +import { composeValidator, buildPatqValidator } from 'form/validators'; import BridgeForm from 'form/BridgeForm'; import { useActivateFlow } from './ActivateFlow'; @@ -26,7 +26,7 @@ export default function PassportVerify({ className }) { const validate = useMemo( () => composeValidator({ - ticket: buildTicketValidator([ + ticket: buildPatqValidator([ validateExactly(ticket, 'Does not match expected master ticket.'), ]), }), diff --git a/src/views/Admin/Reticket/ReticketVerify.js b/src/views/Admin/Reticket/ReticketVerify.js index 1550bcdef..bd40089d9 100644 --- a/src/views/Admin/Reticket/ReticketVerify.js +++ b/src/views/Admin/Reticket/ReticketVerify.js @@ -7,7 +7,7 @@ import { isDevelopment } from 'lib/flags'; import BridgeForm from 'form/BridgeForm'; import { TicketInput } from 'form/Inputs'; -import { composeValidator, buildTicketValidator } from 'form/validators'; +import { composeValidator, buildPatqValidator } from 'form/validators'; import SubmitButton from 'form/SubmitButton'; import FormError from 'form/FormError'; @@ -20,7 +20,7 @@ export default function ReticketVerify({ newWallet }) { const validate = useMemo( () => composeValidator({ - ticket: buildTicketValidator([ + ticket: buildPatqValidator([ validateExactly(ticket, 'Does not match expected master ticket.'), ]), }), diff --git a/src/views/CreateGalaxy.js b/src/views/CreateGalaxy.js index beb58867f..386ff7dba 100644 --- a/src/views/CreateGalaxy.js +++ b/src/views/CreateGalaxy.js @@ -77,7 +77,7 @@ export default function CreateGalaxy() { const validate = useMemo( () => composeValidator({ - galaxyName: buildPointValidator(1, validateGalaxy), + galaxyName: buildPointValidator(1, [validateGalaxy]), owner: buildAddressValidator(), }), [validateGalaxy] diff --git a/src/views/IssueChild.js b/src/views/IssueChild.js index dc57777cf..a007d6269 100644 --- a/src/views/IssueChild.js +++ b/src/views/IssueChild.js @@ -96,7 +96,7 @@ export default function IssueChild() { const validate = useMemo( () => composeValidator({ - point: buildPointValidator(4, validatePoint), + point: buildPointValidator(4, [validatePoint]), owner: buildAddressValidator(), }), [validatePoint] diff --git a/src/views/Login/Ticket.js b/src/views/Login/Ticket.js index 0d5ced837..839564acf 100644 --- a/src/views/Login/Ticket.js +++ b/src/views/Login/Ticket.js @@ -23,7 +23,7 @@ import { TicketInput, PassphraseInput, PointInput } from 'form/Inputs'; import { composeValidator, buildCheckboxValidator, - buildTicketValidator, + buildPatqValidator, buildPassphraseValidator, buildPointValidator, } from 'form/validators'; @@ -112,10 +112,10 @@ export default function Ticket({ className, goHome }) { usePassphrase: buildCheckboxValidator(), useShards: buildCheckboxValidator(), point: buildPointValidator(4), - ticket: buildTicketValidator(), - shard1: buildTicketValidator(), - shard2: buildTicketValidator(), - shard3: buildTicketValidator(), + ticket: buildPatqValidator(), + shard1: buildPatqValidator(), + shard2: buildPatqValidator(), + shard3: buildPatqValidator(), passphrase: buildPassphraseValidator(), }, validateForm From 228dce84bf48413d1bdf87db950a305fe20ef61c Mon Sep 17 00:00:00 2001 From: "Matt Condon (shrugs)" Date: Tue, 6 Aug 2019 11:59:08 +0200 Subject: [PATCH 12/43] fix: minor updates from PR self-review --- src/form/SubmitButton.js | 2 +- src/form/ValuesHandler.js | 5 +++- src/form/validators.js | 16 +++++++------ src/indigo-react/components/SelectInput.js | 2 +- src/indigo-react/components/ToggleInput.js | 2 +- src/views/Admin/AdminNetworkingKeys.js | 8 +++---- src/views/Admin/AdminSetProxy.js | 2 -- src/views/Invite/InviteEmail.js | 28 ++++++++++------------ src/views/Login/Ledger.js | 2 +- src/views/Login/PrivateKey.js | 6 ++--- src/views/Login/Trezor.js | 21 ++++++++-------- 11 files changed, 45 insertions(+), 49 deletions(-) diff --git a/src/form/SubmitButton.js b/src/form/SubmitButton.js index 46f3e5c9e..b9cb9f79a 100644 --- a/src/form/SubmitButton.js +++ b/src/form/SubmitButton.js @@ -1,10 +1,10 @@ import React from 'react'; import cn from 'classnames'; +import { useFormState } from 'react-final-form'; import { ForwardButton } from 'components/Buttons'; import { blinkIf } from 'components/Blinky'; -import { useFormState } from 'react-final-form'; export default function SubmitButton({ as: As = ForwardButton, diff --git a/src/form/ValuesHandler.js b/src/form/ValuesHandler.js index 2109a4215..3b444a9a5 100644 --- a/src/form/ValuesHandler.js +++ b/src/form/ValuesHandler.js @@ -1,6 +1,9 @@ import { useEffect } from 'react'; import { useFormState, useForm } from 'react-final-form'; +/** + * ValuesHandler notifies callback function when form values change. + */ export default function ValuesHandler({ onValues }) { const form = useForm(); const { valid, validating, values } = useFormState({ @@ -13,7 +16,7 @@ export default function ValuesHandler({ onValues }) { useEffect(() => { if (!validating) { - onValues && onValues({ valid: valid && !validating, values, form }); + onValues && onValues({ valid, values, form }); } }, [form, onValues, valid, validating, values]); diff --git a/src/form/validators.js b/src/form/validators.js index 55b496139..f36cbe0fd 100644 --- a/src/form/validators.js +++ b/src/form/validators.js @@ -29,6 +29,10 @@ const buildValidator = (validators = []) => async value => { } }; +// maps a validator across an array of values +export const buildArrayValidator = validator => async values => + await Promise.all(values.map(validator)); + // error object has errors if some of its fields are // 1) an array with any defined values // 2) any defined values @@ -72,17 +76,15 @@ export const buildAddressValidator = () => ]); export const buildNumberValidator = (min = 0) => buildValidator([validateGreaterThan(min)]); -export const buildEmailValidator = () => - buildValidator([validateNotEmpty, validateEmail]); - -// the default form validator just returns field-level validations -const kDefaultFormValidator = (values, errors) => errors; +export const buildEmailValidator = (validators = []) => + buildValidator([validateNotEmpty, validateEmail, ...validators]); // the form validator is the composition of all of the field validators // plus an additional form validator function export const composeValidator = ( fieldValidators = {}, - formValidator = kDefaultFormValidator + // the default form validator just returns field-level validations + formValidator = (values, errors) => errors ) => { const names = Object.keys(fieldValidators); @@ -105,7 +107,7 @@ export const composeValidator = ( ); }; - // final-form `validate` function + // the final-form `validate` function return async values => { // ask for field-level errors const errors = await fieldLevelValidator(values); diff --git a/src/indigo-react/components/SelectInput.js b/src/indigo-react/components/SelectInput.js index 4f5de1c68..5b43ab987 100644 --- a/src/indigo-react/components/SelectInput.js +++ b/src/indigo-react/components/SelectInput.js @@ -15,7 +15,7 @@ export default function SelectInput({ className, mono = false, options = [], - disabled, + disabled = false, }) { const { input, diff --git a/src/indigo-react/components/ToggleInput.js b/src/indigo-react/components/ToggleInput.js index 25fb75464..cda0c464a 100644 --- a/src/indigo-react/components/ToggleInput.js +++ b/src/indigo-react/components/ToggleInput.js @@ -13,7 +13,7 @@ export default function ToggleInput({ className, // - disabled, + disabled = false, ...rest }) { diff --git a/src/views/Admin/AdminNetworkingKeys.js b/src/views/Admin/AdminNetworkingKeys.js index 402b452fd..dce8a4050 100644 --- a/src/views/Admin/AdminNetworkingKeys.js +++ b/src/views/Admin/AdminNetworkingKeys.js @@ -168,8 +168,6 @@ export default function AdminNetworkingKeys() { if (values.useNetworkSeed && errors.networkSeed) { return errors; } - - return {}; }, []); const validate = useMemo( @@ -236,16 +234,16 @@ export default function AdminNetworkingKeys() { {key.matchWith({ Nothing: () => ( - + Unset ), Just: ({ value: key }) => ( <> - + 0x - + {renderNetworkKey(key)} diff --git a/src/views/Admin/AdminSetProxy.js b/src/views/Admin/AdminSetProxy.js index 80eb997ae..bc0450c2d 100644 --- a/src/views/Admin/AdminSetProxy.js +++ b/src/views/Admin/AdminSetProxy.js @@ -118,8 +118,6 @@ export default function AdminSetProxy() { if (!values.unset && errors.address) { return errors; } - - return {}; }, []); const validate = useMemo( diff --git a/src/views/Invite/InviteEmail.js b/src/views/Invite/InviteEmail.js index 21c92e871..82567826b 100644 --- a/src/views/Invite/InviteEmail.js +++ b/src/views/Invite/InviteEmail.js @@ -50,6 +50,7 @@ import { buildEmailValidator, composeValidator, hasErrors, + buildArrayValidator, } from 'form/validators'; import { FORM_ERROR } from 'final-form'; import SubmitButton from 'form/SubmitButton'; @@ -98,8 +99,6 @@ const buildAccessoryFor = (dones, errors) => name => { return ; }; -const emailFormatValidator = buildEmailValidator(); - export default function InviteEmail() { const { contracts, web3, networkType } = useNetwork(); const { wallet, walletType, walletHdPath } = useWallet(); @@ -135,14 +134,8 @@ export default function InviteEmail() { const isFailed = status === STATUS.FAILURE; const isDone = status === STATUS.SUCCESS; - const validateEmail = useCallback( + const validateHasReceived = useCallback( async email => { - // check individual email validity - const error = await emailFormatValidator(email); - if (error) { - return error; - } - const hasReceived = await getHasReceived(email); if (hasReceived) { return HAS_RECEIVED_TEXT; @@ -151,11 +144,6 @@ export default function InviteEmail() { [getHasReceived] ); - const validateEmails = useCallback( - async emails => await Promise.all(emails.map(validateEmail)), - [validateEmail] - ); - const validateForm = useCallback(async (values, errors) => { if (hasErrors(errors)) { return errors; @@ -169,8 +157,16 @@ export default function InviteEmail() { }, []); const validate = useMemo( - () => composeValidator({ emails: validateEmails }, validateForm), - [validateEmails, validateForm] + () => + composeValidator( + { + emails: buildArrayValidator( + buildEmailValidator([validateHasReceived]) + ), + }, + validateForm + ), + [validateForm, validateHasReceived] ); // progress is [0, .length] of invites or receipts, as we're generating them diff --git a/src/views/Login/Ledger.js b/src/views/Login/Ledger.js index 9bb612b5f..85b2b7de9 100644 --- a/src/views/Login/Ledger.js +++ b/src/views/Login/Ledger.js @@ -77,10 +77,10 @@ export default function Ledger({ className, goHome }) { const chainCode = Buffer.from(info.chainCode, 'hex'); const pub = secp256k1.publicKeyConvert(publicKey, true); const hd = bip32.fromPublicKey(pub, chainCode); + setWallet(Just(hd)); setWalletHdPath(addHdPrefix(values.hdpath)); } catch (error) { - setWallet(Nothing()); return { [FORM_ERROR]: error.message }; } }, diff --git a/src/views/Login/PrivateKey.js b/src/views/Login/PrivateKey.js index 4c652d34c..38f2420bb 100644 --- a/src/views/Login/PrivateKey.js +++ b/src/views/Login/PrivateKey.js @@ -22,7 +22,7 @@ export default function PrivateKey({ className, goHome }) { const validate = useMemo( () => composeValidator({ - privatekey: buildHexValidator(64), + privateKey: buildHexValidator(64), }), [] ); @@ -30,7 +30,7 @@ export default function PrivateKey({ className, goHome }) { const onValues = useCallback( ({ valid, values }) => { if (valid) { - const sec = Buffer.from(stripHexPrefix(values.privatekey), 'hex'); + const sec = Buffer.from(stripHexPrefix(values.privateKey), 'hex'); const newWallet = new EthereumWallet(sec); setWallet(Just(newWallet)); } else { @@ -48,7 +48,7 @@ export default function PrivateKey({ className, goHome }) { diff --git a/src/views/Login/Trezor.js b/src/views/Login/Trezor.js index c93f453b4..c4eaa1ef0 100644 --- a/src/views/Login/Trezor.js +++ b/src/views/Login/Trezor.js @@ -60,22 +60,21 @@ export default function Trezor({ className, goHome }) { appUrl: 'https://github.com/urbit/bridge', }); - const info = await TrezorConnect.getPublicKey({ + const { success, payload } = await TrezorConnect.getPublicKey({ path: values.hdpath, }); - if (info.success === true) { - const payload = info.payload; - const publicKey = Buffer.from(payload.publicKey, 'hex'); - const chainCode = Buffer.from(payload.chainCode, 'hex'); - const pub = secp256k1.publicKeyConvert(publicKey, true); - const hd = bip32.fromPublicKey(pub, chainCode); - setWallet(Just(hd)); - setWalletHdPath(values.hdPath); - } else { - setWallet(Nothing()); + if (!success) { return { [FORM_ERROR]: 'Failed to authenticate with your Trezor.' }; } + + const publicKey = Buffer.from(payload.publicKey, 'hex'); + const chainCode = Buffer.from(payload.chainCode, 'hex'); + const pub = secp256k1.publicKeyConvert(publicKey, true); + const hd = bip32.fromPublicKey(pub, chainCode); + + setWallet(Just(hd)); + setWalletHdPath(values.hdPath); }, [setWallet, setWalletHdPath] ); From 52de81675e8164a9dad8835ef88eb1391b863825 Mon Sep 17 00:00:00 2001 From: "Matt Condon (shrugs)" Date: Tue, 6 Aug 2019 12:11:54 +0200 Subject: [PATCH 13/43] fix: make waitForTransactionConfirm correctly throw on unconfirmed --- src/lib/txn.js | 12 +++++++++--- src/views/Invite/InviteEmail.js | 4 +--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/lib/txn.js b/src/lib/txn.js index 3562728b3..98c3cd7c4 100644 --- a/src/lib/txn.js +++ b/src/lib/txn.js @@ -129,13 +129,19 @@ const sendSignedTransaction = (web3, stx, doubtNonceError) => { }); }; -// returns a Promise, where the bool indicates tx success/failure +// returns a Promise, throwing on tx failure const waitForTransactionConfirm = (web3, txHash) => { return retry(async (bail, n) => { const receipt = await web3.eth.getTransactionReceipt(txHash); const confirmed = receipt !== null; - if (confirmed) return receipt.status === true; - else throw new Error('retrying'); + if (!confirmed) { + throw new Error('Transaction not confirmed.'); + } + + const success = receipt.status === true; + if (!success) { + return bail(new Error('Transaction failed.')); + } }, RETRY_OPTIONS); }; diff --git a/src/views/Invite/InviteEmail.js b/src/views/Invite/InviteEmail.js index f5ce7a25e..1b4121a6b 100644 --- a/src/views/Invite/InviteEmail.js +++ b/src/views/Invite/InviteEmail.js @@ -315,9 +315,7 @@ export default function InviteEmail() { tankWasUsed ); - // TODO: waitForTransactionConfirm never rejects - const didConfirm = await waitForTransactionConfirm(_web3, txHash); - if (!didConfirm) throw new Error(); + await waitForTransactionConfirm(_web3, txHash); } catch (error) { console.error(error); unsentInvites.push(invite); From e006bf2031ee01ed9f43c9a613c2bd0fa4420090 Mon Sep 17 00:00:00 2001 From: "Matt Condon (shrugs)" Date: Tue, 6 Aug 2019 12:20:14 +0200 Subject: [PATCH 14/43] chore: add note about illogical warning on ActivateCode --- src/views/Activate/ActivateCode.js | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/views/Activate/ActivateCode.js b/src/views/Activate/ActivateCode.js index 61309e0ac..0bcafe0d8 100644 --- a/src/views/Activate/ActivateCode.js +++ b/src/views/Activate/ActivateCode.js @@ -84,11 +84,10 @@ export default function ActivateCode() { if (incoming.length > 0) { if (incoming.length > 1) { - // TODO: warnings - // setGeneralError( - // 'This invite code has multiple points available.\n' + - // "Once you've activated this point, activate the next with the same process." - // ); + // TODO: putting a warning here doesn't make sense since the user + // will be immediately redirected away — what do? + // 'This invite code has multiple points available.\n' + + // "Once you've activated this point, activate the next with the same process."; } const point = parseInt(incoming[0], 10); From e8991deef9f4860d82ea68df323ef4290e515630 Mon Sep 17 00:00:00 2001 From: "Matt Condon (shrugs)" Date: Tue, 6 Aug 2019 12:35:58 +0200 Subject: [PATCH 15/43] fix: implement warnings --- src/components/Highlighted.js | 20 ++++++++++++++------ src/form/BridgeForm.js | 3 +++ src/form/WarningEngine.js | 15 +++++++++++++++ src/indigo-react/components/Input.js | 18 ++++++++++++++++-- src/indigo-react/components/SelectInput.js | 20 +++++++++++++++++--- src/views/Login/Ledger.js | 2 +- src/views/Login/Ticket.js | 17 +++++++++++------ src/views/Login/Trezor.js | 2 +- 8 files changed, 78 insertions(+), 19 deletions(-) create mode 100644 src/form/WarningEngine.js diff --git a/src/components/Highlighted.js b/src/components/Highlighted.js index e31679624..8db9dd0a3 100644 --- a/src/components/Highlighted.js +++ b/src/components/Highlighted.js @@ -1,13 +1,21 @@ import React from 'react'; import cn from 'classnames'; -export default function Highlighted({ warning = false, ...rest }) { +export default function Highlighted({ + as: As = 'span', + className, + warning = false, + ...rest +}) { return ( - ); diff --git a/src/form/BridgeForm.js b/src/form/BridgeForm.js index 88eea6115..012cc037e 100644 --- a/src/form/BridgeForm.js +++ b/src/form/BridgeForm.js @@ -6,6 +6,7 @@ import { noop } from 'lodash'; import ValuesHandler from './ValuesHandler'; import ValidationPauser from './ValidationPauser'; +import WarningEngine from './WarningEngine'; /** * BridgeForm adds nice-to-haves for every form in bridge. @@ -20,6 +21,7 @@ export default function BridgeForm({ onValues, onSubmit = noop, afterSubmit = noop, + warnings, ...rest }) { const _onSubmit = useCallback( @@ -41,6 +43,7 @@ export default function BridgeForm({ {...rest}> {formProps => ( <> + {warnings && } {children(formProps)} {onValues && } diff --git a/src/form/WarningEngine.js b/src/form/WarningEngine.js new file mode 100644 index 000000000..70350f96e --- /dev/null +++ b/src/form/WarningEngine.js @@ -0,0 +1,15 @@ +import { useEffect } from 'react'; +import { useForm } from 'react-final-form'; +import { map } from 'lodash'; + +export default function WarningEngine({ warnings }) { + const form = useForm(); + + useEffect(() => { + map(warnings, (warning, name) => + form.mutators.setFieldData(name, { warning }) + ); + }, [form, warnings]); + + return null; +} diff --git a/src/indigo-react/components/Input.js b/src/indigo-react/components/Input.js index bda706dc5..d631acca0 100644 --- a/src/indigo-react/components/Input.js +++ b/src/indigo-react/components/Input.js @@ -1,9 +1,9 @@ import React from 'react'; import cn from 'classnames'; +import { useField } from 'react-final-form'; import Flex from './Flex'; import { ErrorText } from './Typography'; -import { useField } from 'react-final-form'; export default function Input({ // visuals @@ -26,7 +26,14 @@ export default function Input({ }) { const { input, - meta: { active, error, submitting, touched, valid }, + meta: { + active, + error, + submitting, + touched, + valid, + data: { warning }, + }, } = useField(name, config); disabled = disabled || submitting; @@ -101,6 +108,13 @@ export default function Input({
)}
+ + {warning && ( + + {warning} + + )} + {touched && !active && error && ( {error} diff --git a/src/indigo-react/components/SelectInput.js b/src/indigo-react/components/SelectInput.js index 5b43ab987..f8b0c1992 100644 --- a/src/indigo-react/components/SelectInput.js +++ b/src/indigo-react/components/SelectInput.js @@ -1,11 +1,11 @@ import React, { useCallback, useRef, useState } from 'react'; import cn from 'classnames'; +import { useField } from 'react-final-form'; +import useOnClickOutside from 'indigo-react/lib/useOnClickOutside'; import Flex from './Flex'; import { ErrorText } from './Typography'; -import useOnClickOutside from 'indigo-react/lib/useOnClickOutside'; import AccessoryIcon from './AccessoryIcon'; -import { useField } from 'react-final-form'; // NOTE: if we really care about accessibility, we should pull in a dependency export default function SelectInput({ @@ -19,7 +19,14 @@ export default function SelectInput({ }) { const { input, - meta: { active, error, submitting, touched, valid }, + meta: { + active, + error, + submitting, + touched, + valid, + data: { warning }, + }, } = useField(name, { type: 'select', }); @@ -121,6 +128,13 @@ export default function SelectInput({ )} + + {warning && ( + + {warning} + + )} + {touched && !active && error && ( {error} diff --git a/src/views/Login/Ledger.js b/src/views/Login/Ledger.js index 85b2b7de9..0bc132505 100644 --- a/src/views/Login/Ledger.js +++ b/src/views/Login/Ledger.js @@ -1,6 +1,6 @@ import React, { useCallback, useMemo } from 'react'; import cn from 'classnames'; -import { Just, Nothing } from 'folktale/maybe'; +import { Just } from 'folktale/maybe'; import { P, Text, diff --git a/src/views/Login/Ticket.js b/src/views/Login/Ticket.js index 839564acf..4fae59665 100644 --- a/src/views/Login/Ticket.js +++ b/src/views/Login/Ticket.js @@ -30,6 +30,7 @@ import { import FormError from 'form/FormError'; import ContinueButton from './ContinueButton'; +import useSetState from 'lib/useSetState'; export default function Ticket({ className, goHome }) { useLoginView(WALLET_TYPES.TICKET); @@ -38,6 +39,7 @@ export default function Ticket({ className, goHome }) { const { setUrbitWallet } = useWallet(); const { setPointCursor } = usePointCursor(); const impliedPoint = useImpliedPoint(); + const [warnings, addWarning] = useSetState(); const cachedUrbitWallet = useRef(Nothing()); @@ -88,11 +90,13 @@ export default function Ticket({ className, goHome }) { ), ]); - if (!isOwner && !isTransferProxy) { - // notify the user, but allow login regardless - // TODO: warnings - // 'This ticket is not the owner of or transfer proxy for this point.' - } + const noPermissions = !isOwner && !isTransferProxy; + // notify the user, but allow login regardless + addWarning({ + point: noPermissions + ? 'This wallet is not the owner or transfer proxy for this point.' + : null, + }); } catch (error) { console.error(error); return { @@ -102,7 +106,7 @@ export default function Ticket({ className, goHome }) { }; } }, - [contracts] + [addWarning, contracts] ); const validate = useMemo( @@ -147,6 +151,7 @@ export default function Ticket({ className, goHome }) { return ( Date: Tue, 6 Aug 2019 21:16:33 +0200 Subject: [PATCH 16/43] fix: simplify inputsigil --- src/components/InputSigil.js | 20 +++++++++----------- src/components/MaybeSigil.js | 2 +- src/components/Passport.js | 3 +-- src/form/Inputs.js | 5 ++--- 4 files changed, 13 insertions(+), 17 deletions(-) diff --git a/src/components/InputSigil.js b/src/components/InputSigil.js index 63223b711..3edcdf9ae 100644 --- a/src/components/InputSigil.js +++ b/src/components/InputSigil.js @@ -1,19 +1,18 @@ import React, { useEffect, useState } from 'react'; import { Just } from 'folktale/maybe'; -import * as ob from 'urbit-ob'; import MaybeSigil from './MaybeSigil'; -const selectColorway = (pass, fail, focused) => { - if (pass) { +const selectColorway = (valid, error, active) => { + if (valid) { return ['#2AA779', '#FFFFFF']; } - if (focused) { + if (active) { return ['#4330FC', '#FFFFFF']; } - if (fail) { + if (error) { return ['#F8C134', '#FFFFFF']; } @@ -24,25 +23,24 @@ export default function InputSigil({ className, patp, size, - pass, + valid, error, - focused, + active, ...rest }) { const [lastValidPatp, setLastValidPatp] = useState(patp); useEffect(() => { - if (ob.isValidPatp(patp)) { + if (valid) { setLastValidPatp(patp); } - }, [patp]); + }, [patp, valid]); return ( ); diff --git a/src/components/MaybeSigil.js b/src/components/MaybeSigil.js index 3a2ce8370..94f46f202 100644 --- a/src/components/MaybeSigil.js +++ b/src/components/MaybeSigil.js @@ -14,7 +14,7 @@ export default function MaybeSigil({ className, patp, size, ...rest }) { }); return validPatp ? ( - + ) : ( ); diff --git a/src/components/Passport.js b/src/components/Passport.js index 0ee827e3a..2fb379939 100644 --- a/src/components/Passport.js +++ b/src/components/Passport.js @@ -75,7 +75,7 @@ function Passport({ point, className, ticket = null, address = Nothing() }) { return ( - + {visualName} @@ -141,7 +141,6 @@ Passport.Mini = function MiniPassport({ point, className, inverted, ...rest }) { diff --git a/src/form/Inputs.js b/src/form/Inputs.js index 30583616f..368ba0939 100644 --- a/src/form/Inputs.js +++ b/src/form/Inputs.js @@ -97,10 +97,9 @@ export function PointInput({ name, size = 4, ...rest }) { ) : null } From 80fec93ed837527676a07133829f9633abc7c70c Mon Sep 17 00:00:00 2001 From: "Matt Condon (shrugs)" Date: Tue, 6 Aug 2019 21:32:54 +0200 Subject: [PATCH 17/43] fix: rename to formatters, auto-downcase pat fields --- src/form/Inputs.js | 13 ++++++++++--- src/{lib/transformers.js => form/formatters.js} | 12 +++++++++++- src/indigo-react/components/Input.js | 4 ++-- src/lib/useImpliedPoint.js | 2 +- src/lib/useImpliedTicket.js | 2 +- 5 files changed, 25 insertions(+), 8 deletions(-) rename src/{lib/transformers.js => form/formatters.js} (65%) diff --git a/src/form/Inputs.js b/src/form/Inputs.js index 368ba0939..46c53ce8c 100644 --- a/src/form/Inputs.js +++ b/src/form/Inputs.js @@ -2,7 +2,12 @@ import React from 'react'; import { Input, AccessoryIcon } from 'indigo-react'; import { useField } from 'react-final-form'; -import { prependSig, convertToNumber } from 'lib/transformers'; +import { + prependSig, + convertToNumber, + buildFormatter, + downcase, +} from 'form/formatters'; import { DEFAULT_HD_PATH } from 'lib/wallet'; import InputSigil from 'components/InputSigil'; @@ -16,6 +21,8 @@ const PLACEHOLDER_PRIVATE_KEY = '0x12345abcdee6beb2f323fab48b432925c9785808d33a6ca6d7ba00b45e9370c3'; const PLACEHOLDER_EMAIL = 'Email Address'; +const formatPat = buildFormatter([downcase, prependSig]); + export function TicketInput({ name, ...rest }) { const { meta: { valid, error, validating, touched, active }, @@ -43,7 +50,7 @@ export function TicketInput({ name, ...rest }) { ) : null } - config={{ format: prependSig }} + config={{ format: formatPat }} mono {...rest} /> @@ -103,7 +110,7 @@ export function PointInput({ name, size = 4, ...rest }) { /> ) : null } - config={{ format: prependSig }} + config={{ format: formatPat }} mono {...rest} /> diff --git a/src/lib/transformers.js b/src/form/formatters.js similarity index 65% rename from src/lib/transformers.js rename to src/form/formatters.js index b7217f04e..921c7822f 100644 --- a/src/lib/transformers.js +++ b/src/form/formatters.js @@ -1,4 +1,6 @@ -// import { fill } from './lib' +import { compose } from 'lib/lib'; + +export const buildFormatter = (formatters = []) => compose(...formatters); export const prependSig = (s = '') => s.length && s.charAt(0) !== '~' ? `~${s}` : s; @@ -11,6 +13,14 @@ export const convertToNumber = s => { } }; +export const downcase = s => { + if (!s) { + return s; + } + + return s.toLowerCase(); +}; + // const hideAllButLast = s => { // const ll = s[s.length -1]; // const bs = fill(s.length - 1, '•') diff --git a/src/indigo-react/components/Input.js b/src/indigo-react/components/Input.js index d631acca0..49e2bb00a 100644 --- a/src/indigo-react/components/Input.js +++ b/src/indigo-react/components/Input.js @@ -85,8 +85,8 @@ export default function Input({ { 'b-green3': valid, 'b-black': !valid && active, - 'b-yellow3': !valid && !active && touched && error, - 'b-gray2': !valid && !active && !touched && !error, + 'b-yellow3': !valid && !active && touched, + 'b-gray2': !valid && !active && !touched, } )} id={name} diff --git a/src/lib/useImpliedPoint.js b/src/lib/useImpliedPoint.js index 05a42c242..f136c0896 100644 --- a/src/lib/useImpliedPoint.js +++ b/src/lib/useImpliedPoint.js @@ -1,6 +1,6 @@ import * as ob from 'urbit-ob'; -import { prependSig } from './transformers'; +import { prependSig } from 'form/formatters'; /** * pull the suggested point from the subdomain diff --git a/src/lib/useImpliedTicket.js b/src/lib/useImpliedTicket.js index b58b35694..2fc9afc9c 100644 --- a/src/lib/useImpliedTicket.js +++ b/src/lib/useImpliedTicket.js @@ -1,6 +1,6 @@ import * as ob from 'urbit-ob'; -import { prependSig } from './transformers'; +import { prependSig } from 'form/formatters'; /** * pull the suggested ticket from the #hash in the url From 58fbcd2516c54a6cdf2efb2f53f6c21595ecbf5d Mon Sep 17 00:00:00 2001 From: "Matt Condon (shrugs)" Date: Tue, 6 Aug 2019 23:06:04 +0200 Subject: [PATCH 18/43] fix: allow til to be deleted if it's the last character --- src/form/formatters.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/form/formatters.js b/src/form/formatters.js index 921c7822f..a9fd689d7 100644 --- a/src/form/formatters.js +++ b/src/form/formatters.js @@ -2,8 +2,13 @@ import { compose } from 'lib/lib'; export const buildFormatter = (formatters = []) => compose(...formatters); -export const prependSig = (s = '') => - s.length && s.charAt(0) !== '~' ? `~${s}` : s; +export const prependSig = s => { + if (!s || s.length <= 1) { + return s || ''; + } + + return s.charAt(0) === '~' ? s : `~${s}`; +}; export const convertToNumber = s => { try { From c0c82dc2584e68551a2420eb6eae6f6d00a61391 Mon Sep 17 00:00:00 2001 From: "Matt Condon (shrugs)" Date: Tue, 6 Aug 2019 23:06:26 +0200 Subject: [PATCH 19/43] fix: ticket is password type --- src/form/Inputs.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/form/Inputs.js b/src/form/Inputs.js index 46c53ce8c..bf0706b8b 100644 --- a/src/form/Inputs.js +++ b/src/form/Inputs.js @@ -38,7 +38,7 @@ export function TicketInput({ name, ...rest }) { return ( Date: Tue, 6 Aug 2019 23:11:48 +0200 Subject: [PATCH 20/43] fix: html5 input props updated for a11y --- src/form/Inputs.js | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/src/form/Inputs.js b/src/form/Inputs.js index bf0706b8b..a1a67e97e 100644 --- a/src/form/Inputs.js +++ b/src/form/Inputs.js @@ -41,6 +41,9 @@ export function TicketInput({ name, ...rest }) { type="password" name={name} placeholder={PLACEHOLDER_TICKET} + autoCapitalize="none" + autoComplete="off" + autoCorrect="off" accessory={ touched && !active && error ? ( @@ -59,7 +62,14 @@ export function TicketInput({ name, ...rest }) { export function MnemonicInput({ ...rest }) { return ( - + ); } @@ -68,7 +78,9 @@ export function HdPathInput({ ...rest }) { ); @@ -79,7 +91,9 @@ export function PassphraseInput({ ...rest }) { ); @@ -99,6 +113,9 @@ export function PointInput({ name, size = 4, ...rest }) { label="Point" name={name} placeholder={PLACEHOLDER_POINT} + autoCapitalize="none" + autoComplete="off" + autoCorrect="off" accessory={ value ? ( @@ -134,7 +153,8 @@ export function AddressInput({ ...rest }) { @@ -146,7 +166,6 @@ export function NumberInput({ ...rest }) { @@ -158,7 +177,7 @@ export function EmailInput({ ...rest }) { ); From 8587d9460f518ae01edb0f9ddc9de2793a06906a Mon Sep 17 00:00:00 2001 From: "Matt Condon (shrugs)" Date: Thu, 8 Aug 2019 11:53:59 +0200 Subject: [PATCH 21/43] fix: lessen ui-flush delay on Ticket.js to a single-frame --- src/views/Login/Ticket.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/views/Login/Ticket.js b/src/views/Login/Ticket.js index 4fae59665..72e7bdbb2 100644 --- a/src/views/Login/Ticket.js +++ b/src/views/Login/Ticket.js @@ -49,7 +49,7 @@ export default function Ticket({ className, goHome }) { return errors; } - await timeout(100); // allow ui events to flush + await timeout(16); // allow ui events to flush let ticket; if (values.useShards) { From d955c27d97ec5eb0f258fc854d4197ff9196fb35 Mon Sep 17 00:00:00 2001 From: "Matt Condon (shrugs)" Date: Thu, 8 Aug 2019 12:00:18 +0200 Subject: [PATCH 22/43] fix: 'v is undefined' and 'cannot read .length of undefined' errors when validating empty inputs --- src/lib/validators.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/validators.js b/src/lib/validators.js index c12d1b85d..6409a2479 100644 --- a/src/lib/validators.js +++ b/src/lib/validators.js @@ -23,7 +23,7 @@ export const validateMnemonic = v => // Checks an empty field export const validateNotEmpty = v => - v.length === 0 && 'This field is required.'; + (v === undefined || v.length === 0) && 'This field is required.'; // Checks if a patp is a valid galaxy export const validateGalaxy = v => { From c67a697a077155d76b59038cbe7ed2117316786c Mon Sep 17 00:00:00 2001 From: "Matt Condon (shrugs)" Date: Fri, 9 Aug 2019 11:55:46 +0200 Subject: [PATCH 23/43] fix: remove autocomplete for password inputs (not respected anyway) see: https://stackoverflow.com/a/3868314 --- src/form/Inputs.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/form/Inputs.js b/src/form/Inputs.js index a1a67e97e..25795e73a 100644 --- a/src/form/Inputs.js +++ b/src/form/Inputs.js @@ -42,7 +42,6 @@ export function TicketInput({ name, ...rest }) { name={name} placeholder={PLACEHOLDER_TICKET} autoCapitalize="none" - autoComplete="off" autoCorrect="off" accessory={ touched && !active && error ? ( @@ -92,7 +91,6 @@ export function PassphraseInput({ ...rest }) { type="password" placeholder="Passphrase" autoCapitalize="none" - autoComplete="off" autoCorrect="off" {...rest} /> From 9ba9efeec28ba90c279293ce1c8bf4c0607502fd Mon Sep 17 00:00:00 2001 From: "Matt Condon (shrugs)" Date: Fri, 9 Aug 2019 18:42:33 +0200 Subject: [PATCH 24/43] deps: use a local checkout of final-form --- package-lock.json | 19 ++++++++++++++++--- package.json | 2 +- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 187c6ef65..a669091da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6927,11 +6927,24 @@ } }, "final-form": { - "version": "4.18.2", - "resolved": "https://registry.npmjs.org/final-form/-/final-form-4.18.2.tgz", - "integrity": "sha512-VQx/5x9M4CiC8fG678Dm1IS3mXvBl7ZNIUx5tUZCk00lFImJzQix4KO0+eGtl49sha2bYOxuYn8jRJiq6sazXA==", + "version": "file:../final-form", "requires": { "@babel/runtime": "^7.3.1" + }, + "dependencies": { + "@babel/runtime": { + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.5.5.tgz", + "integrity": "sha512-28QvEGyQyNkB0/m2B4FU7IEZGK2NUrcMtT6BZEFALTguLk+AUT6ofsHtPk5QyjAdUkpMJ+/Em+quwz4HOt30AQ==", + "requires": { + "regenerator-runtime": "^0.13.2" + } + }, + "regenerator-runtime": { + "version": "0.13.3", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.3.tgz", + "integrity": "sha512-naKIZz2GQ8JWh///G7L3X6LaQUAMp2lvb1rvwwsURe/VXwD6VMfr+/1NuNw3ag8v2kY1aQ/go5SNn79O9JU7yw==" + } } }, "final-form-arrays": { diff --git a/package.json b/package.json index 3c27bdce5..420e1940b 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "ethereum-blockies-base64": "^1.0.2", "ethereumjs-tx": "^1.3.7", "file-saver": "^2.0.0", - "final-form": "^4.18.2", + "final-form": "file://../final-form", "final-form-arrays": "^3.0.1", "final-form-set-field-data": "^1.0.2", "folktale": "^2.3.1", From 20e39633028c74eade90c9480884847d708e457b Mon Sep 17 00:00:00 2001 From: "Matt Condon (shrugs)" Date: Fri, 9 Aug 2019 18:47:49 +0200 Subject: [PATCH 25/43] fix: final-form-set-field-data was causing a validation loop... ...because final-form re-runs validations unconditionally when a mutator is called, and we were registering warnings within our validators: https://github.com/final-form/final-form/blob/master/src/FinalForm.js#L268 --- src/form/BridgeForm.js | 9 +-------- src/form/WarningEngine.js | 15 --------------- src/indigo-react/components/Input.js | 10 ++-------- src/indigo-react/components/SelectInput.js | 10 ++-------- src/views/Login/Ticket.js | 8 ++++++-- 5 files changed, 11 insertions(+), 41 deletions(-) delete mode 100644 src/form/WarningEngine.js diff --git a/src/form/BridgeForm.js b/src/form/BridgeForm.js index 012cc037e..4b191db4a 100644 --- a/src/form/BridgeForm.js +++ b/src/form/BridgeForm.js @@ -1,12 +1,10 @@ import React, { useCallback } from 'react'; import { Form } from 'react-final-form'; -import setFieldData from 'final-form-set-field-data'; import arrayMutators from 'final-form-arrays'; import { noop } from 'lodash'; import ValuesHandler from './ValuesHandler'; import ValidationPauser from './ValidationPauser'; -import WarningEngine from './WarningEngine'; /** * BridgeForm adds nice-to-haves for every form in bridge. @@ -21,7 +19,6 @@ export default function BridgeForm({ onValues, onSubmit = noop, afterSubmit = noop, - warnings, ...rest }) { const _onSubmit = useCallback( @@ -37,13 +34,9 @@ export default function BridgeForm({ ); return ( - + {formProps => ( <> - {warnings && } {children(formProps)} {onValues && } diff --git a/src/form/WarningEngine.js b/src/form/WarningEngine.js deleted file mode 100644 index 70350f96e..000000000 --- a/src/form/WarningEngine.js +++ /dev/null @@ -1,15 +0,0 @@ -import { useEffect } from 'react'; -import { useForm } from 'react-final-form'; -import { map } from 'lodash'; - -export default function WarningEngine({ warnings }) { - const form = useForm(); - - useEffect(() => { - map(warnings, (warning, name) => - form.mutators.setFieldData(name, { warning }) - ); - }, [form, warnings]); - - return null; -} diff --git a/src/indigo-react/components/Input.js b/src/indigo-react/components/Input.js index 49e2bb00a..d43ca90a8 100644 --- a/src/indigo-react/components/Input.js +++ b/src/indigo-react/components/Input.js @@ -14,6 +14,7 @@ export default function Input({ accessory, disabled = false, mono = false, + warning, // callbacks onEnter, @@ -26,14 +27,7 @@ export default function Input({ }) { const { input, - meta: { - active, - error, - submitting, - touched, - valid, - data: { warning }, - }, + meta: { active, error, submitting, touched, valid }, } = useField(name, config); disabled = disabled || submitting; diff --git a/src/indigo-react/components/SelectInput.js b/src/indigo-react/components/SelectInput.js index f8b0c1992..5fe92fc5f 100644 --- a/src/indigo-react/components/SelectInput.js +++ b/src/indigo-react/components/SelectInput.js @@ -16,17 +16,11 @@ export default function SelectInput({ mono = false, options = [], disabled = false, + warning, }) { const { input, - meta: { - active, - error, - submitting, - touched, - valid, - data: { warning }, - }, + meta: { active, error, submitting, touched, valid }, } = useField(name, { type: 'select', }); diff --git a/src/views/Login/Ticket.js b/src/views/Login/Ticket.js index 72e7bdbb2..3c3b79167 100644 --- a/src/views/Login/Ticket.js +++ b/src/views/Login/Ticket.js @@ -151,14 +151,18 @@ export default function Ticket({ className, goHome }) { return ( {({ handleSubmit }) => ( <> - + Date: Fri, 9 Aug 2019 18:50:03 +0200 Subject: [PATCH 26/43] fix: stops validation flickering for unecessarilly async validations see the comment for details, but the gist is that validation was async so final-form was always toggling the `validation` state, which flickers when the only validations we're doing are sync. so now all of the validators conditionally return promises, which helps nicely. --- src/form/validators.js | 54 +++++++++++++++++++++++--------- src/lib/isPromise.js | 6 ++++ src/views/CreateGalaxy.js | 21 +++++++------ src/views/IssueChild.js | 21 +++++++------ src/views/Login/Ticket.js | 65 ++++++++++++++++++++++----------------- 5 files changed, 105 insertions(+), 62 deletions(-) create mode 100644 src/lib/isPromise.js diff --git a/src/form/validators.js b/src/form/validators.js index f36cbe0fd..bdc7fe7cf 100644 --- a/src/form/validators.js +++ b/src/form/validators.js @@ -14,16 +14,18 @@ import { validateEmail, validateNotNullAddress, } from 'lib/validators'; +import isPromise from 'lib/isPromise'; // iterate over validators, exiting early if there's an error -const buildValidator = (validators = []) => async value => { +const buildValidator = (validators = []) => value => { for (const validator of validators) { try { - const error = await validator(value); + const error = validator(value); if (error) { return error; } } catch (error) { + console.error(error); return error.message; } } @@ -93,26 +95,48 @@ export const composeValidator = ( ); // async reduce errors per-field into an errors object - const fieldLevelValidator = async values => { - const errors = await Promise.all( - names.map((name, i) => fieldLevelValidators[i](values[name])) + const fieldLevelValidator = values => { + const errorsOrPromises = names.map((name, i) => + fieldLevelValidators[i](values[name]) ); - return names.reduce( - (memo, name, i) => ({ - ...memo, - [name]: errors[i], - }), - {} - ); + const reduce = errors => + names.reduce( + (memo, name, i) => ({ + ...memo, + [name]: errors[i], + }), + {} + ); + + if (some(errorsOrPromises, isPromise)) { + // if any of these results are a promise, await them all then reduce + return Promise.all(errorsOrPromises).then(reduce); + } + + // otherwise return immediately + return reduce(errorsOrPromises); }; // the final-form `validate` function - return async values => { + // NOTE: if we return a Promise to final-form it will toggle the `validating` + // state, which is expected. If our promise resolves immediately, however, + // that means our `validating` state flickers the UI and it looks pretty bad. + // The solution is to conditionally return a promise only when necessary. + return values => { // ask for field-level errors - const errors = await fieldLevelValidator(values); + const errorsOrPromise = fieldLevelValidator(values); + // pass the current values and their validity to the form-level validator // that can implement conditional logic and more complex validations - return await formValidator(values, errors); + const runFormValidator = errors => formValidator(values, errors); + + if (errorsOrPromise.then) { + // if promise, promise + return errorsOrPromise.then(runFormValidator); + } + + // otherwise, it's an errors object + return runFormValidator(errorsOrPromise); }; }; diff --git a/src/lib/isPromise.js b/src/lib/isPromise.js new file mode 100644 index 000000000..905bfb4cc --- /dev/null +++ b/src/lib/isPromise.js @@ -0,0 +1,6 @@ +// an object is a promise if it has a `.then` function +// https://github.com/then/is-promise/blob/master/index.js +export default obj => + !!obj && + (typeof obj === 'object' || typeof obj === 'function') && + typeof obj.then === 'function'; diff --git a/src/views/CreateGalaxy.js b/src/views/CreateGalaxy.js index 386ff7dba..de4bdcf76 100644 --- a/src/views/CreateGalaxy.js +++ b/src/views/CreateGalaxy.js @@ -59,16 +59,16 @@ export default function CreateGalaxy() { bind, } = useCreateGalaxy(); - const validateGalaxy = useCallback( - async galaxyName => { + const validateForm = useCallback( + async values => { const currentOwner = await azimuth.azimuth.getOwner( _contracts, - patp2dec(galaxyName) + patp2dec(values.galaxyName) ); const isAvailable = isZeroAddress(currentOwner); if (!isAvailable) { - return 'This galaxy is already spawned and owned.'; + return { galaxyName: 'This galaxy is already spawned and owned.' }; } }, [_contracts] @@ -76,11 +76,14 @@ export default function CreateGalaxy() { const validate = useMemo( () => - composeValidator({ - galaxyName: buildPointValidator(1, [validateGalaxy]), - owner: buildAddressValidator(), - }), - [validateGalaxy] + composeValidator( + { + galaxyName: buildPointValidator(1), + owner: buildAddressValidator(), + }, + validateForm + ), + [validateForm] ); const onValues = useCallback( diff --git a/src/views/IssueChild.js b/src/views/IssueChild.js index a007d6269..0e5a42e8a 100644 --- a/src/views/IssueChild.js +++ b/src/views/IssueChild.js @@ -81,13 +81,13 @@ export default function IssueChild() { bind, } = useIssueChild(); - const validatePoint = useCallback( - async name => { - const point = patp2dec(name); + const validateForm = useCallback( + async values => { + const point = patp2dec(values.name); const hasPoint = (await availablePointsPromise).has(point); if (!hasPoint) { - return 'This point cannot be spawned.'; + return { point: 'This point cannot be spawned.' }; } }, [availablePointsPromise] @@ -95,11 +95,14 @@ export default function IssueChild() { const validate = useMemo( () => - composeValidator({ - point: buildPointValidator(4, [validatePoint]), - owner: buildAddressValidator(), - }), - [validatePoint] + composeValidator( + { + point: buildPointValidator(4), + owner: buildAddressValidator(), + }, + validateForm + ), + [validateForm] ); const onValues = useCallback( diff --git a/src/views/Login/Ticket.js b/src/views/Login/Ticket.js index 3c3b79167..78b499fe7 100644 --- a/src/views/Login/Ticket.js +++ b/src/views/Login/Ticket.js @@ -43,34 +43,13 @@ export default function Ticket({ className, goHome }) { const cachedUrbitWallet = useRef(Nothing()); - const validateForm = useCallback( - async (values, errors) => { - if (errors.point) { - return errors; - } - - await timeout(16); // allow ui events to flush - - let ticket; - if (values.useShards) { - if (errors.shard1 || errors.shard2 || errors.shard3) { - return errors; - } - - ticket = kg.combine([values.shard1, values.shard2, values.shard3]); - } else { - if (errors.ticket) { - return errors; - } - - ticket = values.ticket; - } - + const validateFormAsync = useCallback( + async (values, ticket) => { try { - // ticket const _contracts = need.contracts(contracts); const point = patp2dec(values.point); + await timeout(16); // allow ui events to flush cachedUrbitWallet.current = await urbitWalletFromTicket( ticket, point, @@ -92,11 +71,12 @@ export default function Ticket({ className, goHome }) { const noPermissions = !isOwner && !isTransferProxy; // notify the user, but allow login regardless - addWarning({ - point: noPermissions - ? 'This wallet is not the owner or transfer proxy for this point.' - : null, - }); + if (noPermissions) { + addWarning({ + point: + 'This wallet is not the owner or transfer proxy for this point.', + }); + } } catch (error) { console.error(error); return { @@ -109,6 +89,33 @@ export default function Ticket({ className, goHome }) { [addWarning, contracts] ); + const validateForm = useCallback( + (values, errors) => { + if (errors.point) { + addWarning({ point: null }); + return errors; + } + + let ticket; + if (values.useShards) { + if (errors.shard1 || errors.shard2 || errors.shard3) { + return errors; + } + + ticket = kg.combine([values.shard1, values.shard2, values.shard3]); + } else { + if (errors.ticket) { + return errors; + } + + ticket = values.ticket; + } + + return validateFormAsync(values, ticket); + }, + [addWarning, validateFormAsync] + ); + const validate = useMemo( () => composeValidator( From 6d2e7f9a70d01c940120784c36e374381c81f00d Mon Sep 17 00:00:00 2001 From: "Matt Condon (shrugs)" Date: Mon, 12 Aug 2019 14:44:43 +0200 Subject: [PATCH 27/43] deps: update final-form to 4.18.4 (including async-validation fix) --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index a669091da..5c0ea2775 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6927,7 +6927,7 @@ } }, "final-form": { - "version": "file:../final-form", + "version": "4.18.4", "requires": { "@babel/runtime": "^7.3.1" }, diff --git a/package.json b/package.json index 420e1940b..5ad48554e 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "ethereum-blockies-base64": "^1.0.2", "ethereumjs-tx": "^1.3.7", "file-saver": "^2.0.0", - "final-form": "file://../final-form", + "final-form": "^4.18.4", "final-form-arrays": "^3.0.1", "final-form-set-field-data": "^1.0.2", "folktale": "^2.3.1", From 8ff3bf1b9789c2550861e4e861328bfb39811c46 Mon Sep 17 00:00:00 2001 From: "Matt Condon (shrugs)" Date: Mon, 12 Aug 2019 14:55:59 +0200 Subject: [PATCH 28/43] fix: use isPromise in final-form validate function for conditional check --- src/form/validators.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/form/validators.js b/src/form/validators.js index bdc7fe7cf..4a691fbf9 100644 --- a/src/form/validators.js +++ b/src/form/validators.js @@ -131,7 +131,7 @@ export const composeValidator = ( // that can implement conditional logic and more complex validations const runFormValidator = errors => formValidator(values, errors); - if (errorsOrPromise.then) { + if (isPromise(errorsOrPromise)) { // if promise, promise return errorsOrPromise.then(runFormValidator); } From fc0bf5091d18a11d4ef222f0f094b64a09cae0b8 Mon Sep 17 00:00:00 2001 From: "Matt Condon (shrugs)" Date: Mon, 12 Aug 2019 16:38:17 +0200 Subject: [PATCH 29/43] deps: the result of running 'npm install' updated lockfile --- package-lock.json | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5c0ea2775..d5856a8a4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6928,23 +6928,10 @@ }, "final-form": { "version": "4.18.4", + "resolved": "https://registry.npmjs.org/final-form/-/final-form-4.18.4.tgz", + "integrity": "sha512-UUymL6UykjwO2yUN3EhBdw8ajaa448/CczgXvLcyXwbHRjWbA3Yjdxm6WSHQBx4pLv4iEqkvmPRnQ+xmS9GUcA==", "requires": { "@babel/runtime": "^7.3.1" - }, - "dependencies": { - "@babel/runtime": { - "version": "7.5.5", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.5.5.tgz", - "integrity": "sha512-28QvEGyQyNkB0/m2B4FU7IEZGK2NUrcMtT6BZEFALTguLk+AUT6ofsHtPk5QyjAdUkpMJ+/Em+quwz4HOt30AQ==", - "requires": { - "regenerator-runtime": "^0.13.2" - } - }, - "regenerator-runtime": { - "version": "0.13.3", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.3.tgz", - "integrity": "sha512-naKIZz2GQ8JWh///G7L3X6LaQUAMp2lvb1rvwwsURe/VXwD6VMfr+/1NuNw3ag8v2kY1aQ/go5SNn79O9JU7yw==" - } } }, "final-form-arrays": { From ceb0cae5aab7a37271eb9625f28f1c14aa799b58 Mon Sep 17 00:00:00 2001 From: "Matt Condon (shrugs)" Date: Mon, 12 Aug 2019 16:42:11 +0200 Subject: [PATCH 30/43] fix: allow til to be deleted, but immediately add it on insert --- src/form/Inputs.js | 4 ++-- src/form/formatters.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/form/Inputs.js b/src/form/Inputs.js index 25795e73a..9f6fc0ad2 100644 --- a/src/form/Inputs.js +++ b/src/form/Inputs.js @@ -52,7 +52,7 @@ export function TicketInput({ name, ...rest }) { ) : null } - config={{ format: formatPat }} + config={{ parse: formatPat }} mono {...rest} /> @@ -125,7 +125,7 @@ export function PointInput({ name, size = 4, ...rest }) { /> ) : null } - config={{ format: formatPat }} + config={{ parse: formatPat }} mono {...rest} /> diff --git a/src/form/formatters.js b/src/form/formatters.js index a9fd689d7..092e4df8c 100644 --- a/src/form/formatters.js +++ b/src/form/formatters.js @@ -3,7 +3,7 @@ import { compose } from 'lib/lib'; export const buildFormatter = (formatters = []) => compose(...formatters); export const prependSig = s => { - if (!s || s.length <= 1) { + if (!s || s.length === 0) { return s || ''; } From 95edabbc146d520bbb65e422885b5d3fe6f2c212 Mon Sep 17 00:00:00 2001 From: "Matt Condon (shrugs)" Date: Mon, 12 Aug 2019 16:48:54 +0200 Subject: [PATCH 31/43] fix: only call `onValues` when validation is not paused see https://github.com/final-form/react-final-form/issues/588 for more details --- src/form/BridgeForm.js | 2 +- src/form/ValuesHandler.js | 13 +++---------- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/src/form/BridgeForm.js b/src/form/BridgeForm.js index 4b191db4a..5827e0086 100644 --- a/src/form/BridgeForm.js +++ b/src/form/BridgeForm.js @@ -39,7 +39,7 @@ export default function BridgeForm({ <> {children(formProps)} - {onValues && } + {onValues && } )} diff --git a/src/form/ValuesHandler.js b/src/form/ValuesHandler.js index 3b444a9a5..f50595211 100644 --- a/src/form/ValuesHandler.js +++ b/src/form/ValuesHandler.js @@ -1,21 +1,14 @@ import { useEffect } from 'react'; -import { useFormState, useForm } from 'react-final-form'; +import { useForm } from 'react-final-form'; /** * ValuesHandler notifies callback function when form values change. */ -export default function ValuesHandler({ onValues }) { +export default function ValuesHandler({ valid, validating, values, onValues }) { const form = useForm(); - const { valid, validating, values } = useFormState({ - subscription: { - valid: true, - validating: true, - values: true, - }, - }); useEffect(() => { - if (!validating) { + if (!validating && !form.isValidationPaused()) { onValues && onValues({ valid, values, form }); } }, [form, onValues, valid, validating, values]); From 302442cb6e1106359421f864ea3961a4f4a351a5 Mon Sep 17 00:00:00 2001 From: "Matt Condon (shrugs)" Date: Mon, 12 Aug 2019 16:58:40 +0200 Subject: [PATCH 32/43] fix: minor cleanup across forms + use correct Input comonent {HdPath,Address}Input + separate validateForm and asyncValidate form to better handle the 'only return promise if necessary' pattern --- src/views/CreateGalaxy.js | 20 ++++++++++++++++---- src/views/Invite/InviteEmail.js | 2 +- src/views/IssueChild.js | 14 +++++++++++++- src/views/Login/Ledger.js | 13 +++---------- src/views/Login/Trezor.js | 12 +++--------- 5 files changed, 36 insertions(+), 25 deletions(-) diff --git a/src/views/CreateGalaxy.js b/src/views/CreateGalaxy.js index de4bdcf76..7efed2b0a 100644 --- a/src/views/CreateGalaxy.js +++ b/src/views/CreateGalaxy.js @@ -1,6 +1,6 @@ import React, { useCallback, useMemo, useState } from 'react'; import cn from 'classnames'; -import { Grid, Text, Input } from 'indigo-react'; +import { Grid, Text } from 'indigo-react'; import * as azimuth from 'azimuth-js'; import { useNetwork } from 'store/network'; @@ -17,11 +17,12 @@ import ViewHeader from 'components/ViewHeader'; import InlineEthereumTransaction from 'components/InlineEthereumTransaction'; import View from 'components/View'; import BridgeForm from 'form/BridgeForm'; -import { PointInput } from 'form/Inputs'; +import { PointInput, AddressInput } from 'form/Inputs'; import { composeValidator, buildPointValidator, buildAddressValidator, + hasErrors, } from 'form/validators'; import FormError from 'form/FormError'; @@ -59,7 +60,7 @@ export default function CreateGalaxy() { bind, } = useCreateGalaxy(); - const validateForm = useCallback( + const validateFormAsync = useCallback( async values => { const currentOwner = await azimuth.azimuth.getOwner( _contracts, @@ -74,6 +75,17 @@ export default function CreateGalaxy() { [_contracts] ); + const validateForm = useCallback( + (values, errors) => { + if (hasErrors(errors)) { + return errors; + } + + return validateFormAsync(values, errors); + }, + [validateFormAsync] + ); + const validate = useMemo( () => composeValidator( @@ -129,7 +141,7 @@ export default function CreateGalaxy() { /> { + const validateForm = useCallback((values, errors) => { if (hasErrors(errors)) { return errors; } diff --git a/src/views/IssueChild.js b/src/views/IssueChild.js index 0e5a42e8a..48ebe6c24 100644 --- a/src/views/IssueChild.js +++ b/src/views/IssueChild.js @@ -24,6 +24,7 @@ import { composeValidator, buildPointValidator, buildAddressValidator, + hasErrors, } from 'form/validators'; import BridgeForm from 'form/BridgeForm'; import FormError from 'form/FormError'; @@ -81,7 +82,7 @@ export default function IssueChild() { bind, } = useIssueChild(); - const validateForm = useCallback( + const validateFormAsync = useCallback( async values => { const point = patp2dec(values.name); const hasPoint = (await availablePointsPromise).has(point); @@ -93,6 +94,17 @@ export default function IssueChild() { [availablePointsPromise] ); + const validateForm = useCallback( + (values, errors) => { + if (hasErrors(errors)) { + return errors; + } + + return validateFormAsync(values, errors); + }, + [validateFormAsync] + ); + const validate = useMemo( () => composeValidator( diff --git a/src/views/Login/Ledger.js b/src/views/Login/Ledger.js index 0bc132505..22d502ac6 100644 --- a/src/views/Login/Ledger.js +++ b/src/views/Login/Ledger.js @@ -1,15 +1,7 @@ import React, { useCallback, useMemo } from 'react'; import cn from 'classnames'; import { Just } from 'folktale/maybe'; -import { - P, - Text, - Input, - Grid, - H5, - CheckboxInput, - SelectInput, -} from 'indigo-react'; +import { P, Text, Grid, H5, CheckboxInput, SelectInput } from 'indigo-react'; import { times } from 'lodash'; import * as bip32 from 'bip32'; import Transport from '@ledgerhq/hw-transport-u2f'; @@ -38,6 +30,7 @@ import FormError from 'form/FormError'; import ContinueButton from './ContinueButton'; import Condition from 'form/Condition'; import { FORM_ERROR } from 'final-form'; +import { HdPathInput } from 'form/Inputs'; const PATH_OPTIONS = [ { text: 'Ledger Live', value: LEDGER_LIVE_PATH }, @@ -176,7 +169,7 @@ export default function Ledger({ className, goHome }) { {({ handleSubmit }) => ( <> - + diff --git a/src/views/Login/Trezor.js b/src/views/Login/Trezor.js index 5e9f0d523..cea04f19a 100644 --- a/src/views/Login/Trezor.js +++ b/src/views/Login/Trezor.js @@ -4,14 +4,7 @@ import * as bip32 from 'bip32'; import { times } from 'lodash'; import TrezorConnect from 'trezor-connect'; import * as secp256k1 from 'secp256k1'; -import { - Text, - Input, - Grid, - H5, - CheckboxInput, - SelectInput, -} from 'indigo-react'; +import { Text, Grid, H5, CheckboxInput, SelectInput } from 'indigo-react'; import { useWallet } from 'store/wallet'; @@ -31,6 +24,7 @@ import FormError from 'form/FormError'; import ContinueButton from './ContinueButton'; import { FORM_ERROR } from 'final-form'; +import { HdPathInput } from 'form/Inputs'; const ACCOUNT_OPTIONS = times(20, i => ({ text: `Account #${i + 1}`, @@ -118,7 +112,7 @@ export default function Trezor({ className, goHome }) { {({ handleSubmit }) => ( <> - + From aed1af0c7689654921a3b2b9c0b7eb81160391e8 Mon Sep 17 00:00:00 2001 From: "Matt Condon (shrugs)" Date: Mon, 12 Aug 2019 17:25:56 +0200 Subject: [PATCH 33/43] feat: standardize onSubmit for ActivateCode, Login/Ticket & Keystore to allow the form to be submitted even with submit errors, we check the dirtySinceLastSubmit value. see the following issues for more deets: - https://github.com/final-form/react-final-form/issues/403 - https://github.com/final-form/react-final-form/issues/547 - etc etc there are a few others that I couldn't re-find this change means that SubmitButton needed extra logic, so we removed ContinueButton in favor of having that logic live in one place (no idea why I wrote ContinueButton in the first place tbh) --- src/form/FormError.js | 12 +++-- src/form/SubmitButton.js | 26 +++++---- src/views/Activate/ActivateCode.js | 2 +- src/views/Login/ContinueButton.js | 26 --------- src/views/Login/Keystore.js | 6 +-- src/views/Login/Ledger.js | 4 +- src/views/Login/Mnemonic.js | 6 ++- src/views/Login/PrivateKey.js | 6 ++- src/views/Login/Ticket.js | 87 +++++++++++++----------------- src/views/Login/Trezor.js | 4 +- 10 files changed, 79 insertions(+), 100 deletions(-) delete mode 100644 src/views/Login/ContinueButton.js diff --git a/src/form/FormError.js b/src/form/FormError.js index e59e65971..5f6b35da8 100644 --- a/src/form/FormError.js +++ b/src/form/FormError.js @@ -4,13 +4,19 @@ import { ErrorText } from 'indigo-react'; import { FORM_ERROR } from 'final-form'; export default function FormError(props) { - const { submitError, errors } = useFormState({ - subscription: { submitError: true, errors: true }, + const { submitError, errors, dirtySinceLastSubmit } = useFormState({ + subscription: { + submitError: true, + errors: true, + dirtySinceLastSubmit: true, + }, }); const formError = errors[FORM_ERROR]; + const showFormError = !!formError; + const showSubmitError = !!submitError && !dirtySinceLastSubmit; - return submitError || formError ? ( + return showSubmitError || showFormError ? ( {submitError || formError} ) : null; } diff --git a/src/form/SubmitButton.js b/src/form/SubmitButton.js index b9cb9f79a..4b42cc84a 100644 --- a/src/form/SubmitButton.js +++ b/src/form/SubmitButton.js @@ -13,24 +13,30 @@ export default function SubmitButton({ handleSubmit, ...rest }) { - const { valid, validating, submitting, submitError } = useFormState({ - subscription: { - valid: true, - validating: true, - submitting: true, - submitError: true, - }, - }); + const { + valid, + validating, + submitting, + hasValidationErrors, + hasSubmitErrors, + dirtySinceLastSubmit, + } = useFormState(); + + const canSubmit = + (valid || (hasSubmitErrors && dirtySinceLastSubmit)) && + !validating && + !hasValidationErrors && + !submitting; return ( - {submitError ? 'Error submitting' : children} + {children} ); } diff --git a/src/views/Activate/ActivateCode.js b/src/views/Activate/ActivateCode.js index 4afeb82a0..30114c66f 100644 --- a/src/views/Activate/ActivateCode.js +++ b/src/views/Activate/ActivateCode.js @@ -71,7 +71,7 @@ export default function ActivateCode() { // set our state on submission const onSubmit = useCallback( async values => { - await timeout(100); // allow the ui changes to flush before we lag it out + await timeout(16); // allow the ui changes to flush before we lag it out const _contracts = need.contracts(contracts); const { seed } = await generateTemporaryOwnershipWallet(values.ticket); diff --git a/src/views/Login/ContinueButton.js b/src/views/Login/ContinueButton.js deleted file mode 100644 index 8626f310f..000000000 --- a/src/views/Login/ContinueButton.js +++ /dev/null @@ -1,26 +0,0 @@ -import React from 'react'; -import cn from 'classnames'; -import { useFormState } from 'react-final-form'; - -import { ForwardButton } from 'components/Buttons'; -import Blinky from 'components/Blinky'; - -export default function ContinueButton({ className, children, handleSubmit }) { - const { valid, validating, submitting } = useFormState({ - subscription: { valid: true, validating: true, submitting: true }, - }); - - const loading = validating || submitting; - - return ( - : undefined} - onClick={handleSubmit}> - {children || 'Continue'} - - ); -} diff --git a/src/views/Login/Keystore.js b/src/views/Login/Keystore.js index 4d3911c93..d2b45e428 100644 --- a/src/views/Login/Keystore.js +++ b/src/views/Login/Keystore.js @@ -15,7 +15,7 @@ import { buildUploadValidator, } from 'form/validators'; import UploadInput from 'form/UploadInput'; -import ContinueButton from './ContinueButton'; +import SubmitButton from 'form/SubmitButton'; import BridgeForm from 'form/BridgeForm'; import { FORM_ERROR } from 'final-form'; import FormError from 'form/FormError'; @@ -44,7 +44,7 @@ export default function Keystore({ className, goHome }) { const wallet = new EthereumWallet(privateKey); setWallet(Just(wallet)); } catch (error) { - console.error(error); + // console.error(error); return { [FORM_ERROR]: "Couldn't decrypt wallet. You may have entered an incorrect password.", @@ -81,7 +81,7 @@ export default function Keystore({ className, goHome }) { - + Decrypt diff --git a/src/views/Login/Ledger.js b/src/views/Login/Ledger.js index 22d502ac6..4817a11aa 100644 --- a/src/views/Login/Ledger.js +++ b/src/views/Login/Ledger.js @@ -27,7 +27,7 @@ import { } from 'form/validators'; import BridgeForm from 'form/BridgeForm'; import FormError from 'form/FormError'; -import ContinueButton from './ContinueButton'; +import SubmitButton from 'form/SubmitButton'; import Condition from 'form/Condition'; import { FORM_ERROR } from 'final-form'; import { HdPathInput } from 'form/Inputs'; @@ -206,7 +206,7 @@ export default function Ledger({ className, goHome }) { - + Authenticate diff --git a/src/views/Login/Mnemonic.js b/src/views/Login/Mnemonic.js index 0a527f9f5..158e3b709 100644 --- a/src/views/Login/Mnemonic.js +++ b/src/views/Login/Mnemonic.js @@ -18,7 +18,7 @@ import { import BridgeForm from 'form/BridgeForm'; import Condition from 'form/Condition'; import FormError from 'form/FormError'; -import ContinueButton from './ContinueButton'; +import SubmitButton from 'form/SubmitButton'; export default function Mnemonic({ className, goHome }) { useLoginView(WALLET_TYPES.MNEMONIC); @@ -99,7 +99,9 @@ export default function Mnemonic({ className, goHome }) { - + + Continue + )} diff --git a/src/views/Login/PrivateKey.js b/src/views/Login/PrivateKey.js index 38f2420bb..9d0aa27e1 100644 --- a/src/views/Login/PrivateKey.js +++ b/src/views/Login/PrivateKey.js @@ -12,7 +12,7 @@ import BridgeForm from 'form/BridgeForm'; import { HexInput } from 'form/Inputs'; import { buildHexValidator, composeValidator } from 'form/validators'; -import ContinueButton from './ContinueButton'; +import SubmitButton from 'form/SubmitButton'; export default function PrivateKey({ className, goHome }) { useLoginView(WALLET_TYPES.PRIVATE_KEY); @@ -52,7 +52,9 @@ export default function PrivateKey({ className, goHome }) { label="Private key" /> - + + Continue + )} diff --git a/src/views/Login/Ticket.js b/src/views/Login/Ticket.js index 78b499fe7..dc0fecd1c 100644 --- a/src/views/Login/Ticket.js +++ b/src/views/Login/Ticket.js @@ -29,7 +29,7 @@ import { } from 'form/validators'; import FormError from 'form/FormError'; -import ContinueButton from './ContinueButton'; +import SubmitButton from 'form/SubmitButton'; import useSetState from 'lib/useSetState'; export default function Ticket({ className, goHome }) { @@ -41,16 +41,38 @@ export default function Ticket({ className, goHome }) { const impliedPoint = useImpliedPoint(); const [warnings, addWarning] = useSetState(); - const cachedUrbitWallet = useRef(Nothing()); + const validateForm = useCallback( + (values, errors) => { + if (errors.point) { + addWarning({ point: null }); + return errors; + } + + if (values.useShards) { + if (errors.shard1 || errors.shard2 || errors.shard3) { + return errors; + } + } else { + if (errors.ticket) { + return errors; + } + } + }, + [addWarning] + ); + + const onSubmit = useCallback( + async values => { + const ticket = values.useShards + ? kg.combine([values.shard1, values.shard2, values.shard3]) + : values.ticket; - const validateFormAsync = useCallback( - async (values, ticket) => { try { const _contracts = need.contracts(contracts); const point = patp2dec(values.point); await timeout(16); // allow ui events to flush - cachedUrbitWallet.current = await urbitWalletFromTicket( + const urbitWallet = await urbitWalletFromTicket( ticket, point, values.passphrase @@ -60,23 +82,27 @@ export default function Ticket({ className, goHome }) { azimuth.azimuth.isOwner( _contracts, point, - cachedUrbitWallet.current.ownership.keys.address + urbitWallet.ownership.keys.address ), azimuth.azimuth.isTransferProxy( _contracts, point, - cachedUrbitWallet.current.ownership.keys.address + urbitWallet.ownership.keys.address ), ]); const noPermissions = !isOwner && !isTransferProxy; // notify the user, but allow login regardless + // TODO: how should the warning work now that we generate onSubmit? if (noPermissions) { addWarning({ point: 'This wallet is not the owner or transfer proxy for this point.', }); } + + setUrbitWallet(Just(urbitWallet)); + setPointCursor(Just(patp2dec(values.point))); } catch (error) { console.error(error); return { @@ -86,34 +112,7 @@ export default function Ticket({ className, goHome }) { }; } }, - [addWarning, contracts] - ); - - const validateForm = useCallback( - (values, errors) => { - if (errors.point) { - addWarning({ point: null }); - return errors; - } - - let ticket; - if (values.useShards) { - if (errors.shard1 || errors.shard2 || errors.shard3) { - return errors; - } - - ticket = kg.combine([values.shard1, values.shard2, values.shard3]); - } else { - if (errors.ticket) { - return errors; - } - - ticket = values.ticket; - } - - return validateFormAsync(values, ticket); - }, - [addWarning, validateFormAsync] + [addWarning, contracts, setPointCursor, setUrbitWallet] ); const validate = useMemo( @@ -134,18 +133,6 @@ export default function Ticket({ className, goHome }) { [validateForm] ); - const onValues = useCallback( - ({ valid, values }) => { - if (valid) { - setUrbitWallet(Just(cachedUrbitWallet.current)); - setPointCursor(Just(patp2dec(values.point))); - } else { - setUrbitWallet(Nothing()); - } - }, - [setPointCursor, setUrbitWallet] - ); - const initialValues = useMemo( () => ({ point: impliedPoint || '', @@ -159,7 +146,7 @@ export default function Ticket({ className, goHome }) { {({ handleSubmit }) => ( @@ -210,7 +197,9 @@ export default function Ticket({ className, goHome }) { - + + Continue + )} diff --git a/src/views/Login/Trezor.js b/src/views/Login/Trezor.js index cea04f19a..28de1ae87 100644 --- a/src/views/Login/Trezor.js +++ b/src/views/Login/Trezor.js @@ -22,7 +22,7 @@ import BridgeForm from 'form/BridgeForm'; import Condition from 'form/Condition'; import FormError from 'form/FormError'; -import ContinueButton from './ContinueButton'; +import SubmitButton from 'form/SubmitButton'; import { FORM_ERROR } from 'final-form'; import { HdPathInput } from 'form/Inputs'; @@ -136,7 +136,7 @@ export default function Trezor({ className, goHome }) { - + Authenticate From bd2be652861e93d03fc556644e21fdfe5439dbad Mon Sep 17 00:00:00 2001 From: "Matt Condon (shrugs)" Date: Mon, 12 Aug 2019 19:17:05 +0200 Subject: [PATCH 34/43] feat: insert ticket dashes for formatting patq --- src/form/Inputs.js | 11 +++++++-- src/form/formatters.js | 34 ++++++++++++++++++++++++++-- src/indigo-react/components/Input.js | 9 ++++++-- src/lib/lib.js | 3 +++ 4 files changed, 51 insertions(+), 6 deletions(-) diff --git a/src/form/Inputs.js b/src/form/Inputs.js index 9f6fc0ad2..3239968e2 100644 --- a/src/form/Inputs.js +++ b/src/form/Inputs.js @@ -7,6 +7,7 @@ import { convertToNumber, buildFormatter, downcase, + ensurePatQDashes, } from 'form/formatters'; import { DEFAULT_HD_PATH } from 'lib/wallet'; import InputSigil from 'components/InputSigil'; @@ -21,7 +22,12 @@ const PLACEHOLDER_PRIVATE_KEY = '0x12345abcdee6beb2f323fab48b432925c9785808d33a6ca6d7ba00b45e9370c3'; const PLACEHOLDER_EMAIL = 'Email Address'; -const formatPat = buildFormatter([downcase, prependSig]); +const TICKET_MAX_BYTE_LEN = 32; // tickets can be as large as 32 bytes +const formatPat = buildFormatter([ + downcase, + prependSig, + ensurePatQDashes(TICKET_MAX_BYTE_LEN), +]); export function TicketInput({ name, ...rest }) { const { @@ -38,8 +44,9 @@ export function TicketInput({ name, ...rest }) { return ( value.replace(/[^~-]+/g, '••••••')} placeholder={PLACEHOLDER_TICKET} autoCapitalize="none" autoCorrect="off" diff --git a/src/form/formatters.js b/src/form/formatters.js index 092e4df8c..30b159ae0 100644 --- a/src/form/formatters.js +++ b/src/form/formatters.js @@ -1,6 +1,7 @@ -import { compose } from 'lib/lib'; +import { compose, strSplice } from 'lib/lib'; -export const buildFormatter = (formatters = []) => compose(...formatters); +export const buildFormatter = (formatters = []) => + compose(...formatters.reverse()); export const prependSig = s => { if (!s || s.length === 0) { @@ -10,6 +11,35 @@ export const prependSig = s => { return s.charAt(0) === '~' ? s : `~${s}`; }; +/** + * inserts a - every 7 characters + * (must be preceeded by prependSig to work correctly) + * @param {number} maxByteLength the maximum number of patq blocks to have in a string + */ +export const ensurePatQDashes = maxByteLength => s => { + if (!s || s.length === 0) { + return s || ''; + } + + // ensure there's a dash every 6 + 1 characters + const dashAt = i => i * (6 + 1); + const maxBlocks = maxByteLength / 2; + + for (let i = 1; i !== maxBlocks; i++) { + const dashIndex = dashAt(i); + + if (s.length - 1 < dashIndex) { + return s; + } + + if (s.charAt(dashIndex) !== '-') { + s = strSplice(s, dashIndex, '-'); + } + } + + return s; +}; + export const convertToNumber = s => { try { return parseInt(s, 10); diff --git a/src/indigo-react/components/Input.js b/src/indigo-react/components/Input.js index d43ca90a8..2e2cc5a9a 100644 --- a/src/indigo-react/components/Input.js +++ b/src/indigo-react/components/Input.js @@ -1,7 +1,6 @@ import React from 'react'; import cn from 'classnames'; import { useField } from 'react-final-form'; - import Flex from './Flex'; import { ErrorText } from './Typography'; @@ -15,6 +14,7 @@ export default function Input({ disabled = false, mono = false, warning, + obscure, // callbacks onEnter, @@ -32,6 +32,9 @@ export default function Input({ disabled = disabled || submitting; + // choose the base dom component + const BaseComponent = type === 'textarea' ? 'textarea' : 'input'; + // notify parent of enter keypress iff not disabled and passing // TODO: integrate this into react-final-form submission // const onKeyPress = useCallback( @@ -39,6 +42,8 @@ export default function Input({ // [disabled, valid] // eslint-disable-line react-hooks/exhaustive-deps // ); + // console.log(input); + return ( Array.from(Array(n), (_, i) => i); const fill = (n, v) => Array.from(Array(n), () => v); +const strSplice = (arr, i, val) => `${arr.slice(0, i)}${val}${arr.slice(i)}`; + const isValidGalaxy = name => { let point; try { @@ -48,6 +50,7 @@ export { defaultTo, seq, fill, + strSplice, isValidGalaxy, randomPatq, patpStringLength, From 7b9591f3328be5877ee49132ca554b8a9adf4180 Mon Sep 17 00:00:00 2001 From: "Matt Condon (shrugs)" Date: Tue, 13 Aug 2019 09:18:02 +0200 Subject: [PATCH 35/43] fix: re-include console.error call in Keystore.js --- src/views/Login/Keystore.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/views/Login/Keystore.js b/src/views/Login/Keystore.js index d2b45e428..098287b5c 100644 --- a/src/views/Login/Keystore.js +++ b/src/views/Login/Keystore.js @@ -44,7 +44,7 @@ export default function Keystore({ className, goHome }) { const wallet = new EthereumWallet(privateKey); setWallet(Just(wallet)); } catch (error) { - // console.error(error); + console.error(error); return { [FORM_ERROR]: "Couldn't decrypt wallet. You may have entered an incorrect password.", From d72cd54a999cd27a508884af43506128084e4a44 Mon Sep 17 00:00:00 2001 From: "Matt Condon (shrugs)" Date: Tue, 13 Aug 2019 09:24:21 +0200 Subject: [PATCH 36/43] fix: expand ensurePatFormat to cover prependSig case --- src/form/Inputs.js | 10 ++-------- src/form/formatters.js | 26 ++++++++++---------------- src/lib/useImpliedPoint.js | 4 ++-- src/lib/useImpliedTicket.js | 4 ++-- 4 files changed, 16 insertions(+), 28 deletions(-) diff --git a/src/form/Inputs.js b/src/form/Inputs.js index 3239968e2..0c6df6872 100644 --- a/src/form/Inputs.js +++ b/src/form/Inputs.js @@ -3,11 +3,10 @@ import { Input, AccessoryIcon } from 'indigo-react'; import { useField } from 'react-final-form'; import { - prependSig, convertToNumber, buildFormatter, downcase, - ensurePatQDashes, + ensurePatFormat, } from 'form/formatters'; import { DEFAULT_HD_PATH } from 'lib/wallet'; import InputSigil from 'components/InputSigil'; @@ -22,12 +21,7 @@ const PLACEHOLDER_PRIVATE_KEY = '0x12345abcdee6beb2f323fab48b432925c9785808d33a6ca6d7ba00b45e9370c3'; const PLACEHOLDER_EMAIL = 'Email Address'; -const TICKET_MAX_BYTE_LEN = 32; // tickets can be as large as 32 bytes -const formatPat = buildFormatter([ - downcase, - prependSig, - ensurePatQDashes(TICKET_MAX_BYTE_LEN), -]); +const formatPat = buildFormatter([downcase, ensurePatFormat]); export function TicketInput({ name, ...rest }) { const { diff --git a/src/form/formatters.js b/src/form/formatters.js index 30b159ae0..69dd90686 100644 --- a/src/form/formatters.js +++ b/src/form/formatters.js @@ -1,39 +1,33 @@ import { compose, strSplice } from 'lib/lib'; +const TICKET_MAX_BYTE_LEN = 32; // tickets can be as large as 32 bytes + export const buildFormatter = (formatters = []) => compose(...formatters.reverse()); -export const prependSig = s => { - if (!s || s.length === 0) { - return s || ''; - } - - return s.charAt(0) === '~' ? s : `~${s}`; -}; - /** - * inserts a - every 7 characters - * (must be preceeded by prependSig to work correctly) - * @param {number} maxByteLength the maximum number of patq blocks to have in a string + * inserts a ~ or - every 7 characters */ -export const ensurePatQDashes = maxByteLength => s => { +export const ensurePatFormat = s => { if (!s || s.length === 0) { return s || ''; } // ensure there's a dash every 6 + 1 characters const dashAt = i => i * (6 + 1); - const maxBlocks = maxByteLength / 2; + const maxBlocks = TICKET_MAX_BYTE_LEN / 2; - for (let i = 1; i !== maxBlocks; i++) { + for (let i = 0; i !== maxBlocks; i++) { const dashIndex = dashAt(i); if (s.length - 1 < dashIndex) { return s; } - if (s.charAt(dashIndex) !== '-') { - s = strSplice(s, dashIndex, '-'); + const sep = i === 0 ? '~' : '-'; + + if (s.charAt(dashIndex) !== sep) { + s = strSplice(s, dashIndex, sep); } } diff --git a/src/lib/useImpliedPoint.js b/src/lib/useImpliedPoint.js index f136c0896..015863dc2 100644 --- a/src/lib/useImpliedPoint.js +++ b/src/lib/useImpliedPoint.js @@ -1,13 +1,13 @@ import * as ob from 'urbit-ob'; -import { prependSig } from 'form/formatters'; +import { ensurePatFormat } from 'form/formatters'; /** * pull the suggested point from the subdomain */ export default function useImpliedPoint() { const subdomain = window.location.host.split('.')[0]; - const patp = prependSig(subdomain); + const patp = ensurePatFormat(subdomain); const isValid = ob.isValidPatp(patp); return isValid ? patp : null; diff --git a/src/lib/useImpliedTicket.js b/src/lib/useImpliedTicket.js index 2fc9afc9c..748023c11 100644 --- a/src/lib/useImpliedTicket.js +++ b/src/lib/useImpliedTicket.js @@ -1,13 +1,13 @@ import * as ob from 'urbit-ob'; -import { prependSig } from 'form/formatters'; +import { ensurePatFormat } from 'form/formatters'; /** * pull the suggested ticket from the #hash in the url */ export default function useImpliedTicket() { const hash = window.location.hash.slice(1); - const ticket = prependSig(hash); + const ticket = ensurePatFormat(hash); const isValid = ob.isValidPatq(ticket); return isValid ? ticket : null; From d5c7a6f97d206f6523bab31e729f4c71981337f7 Mon Sep 17 00:00:00 2001 From: "Matt Condon (shrugs)" Date: Tue, 13 Aug 2019 09:27:59 +0200 Subject: [PATCH 37/43] chore: cleanup/comment ensurePatFormat function --- src/form/formatters.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/form/formatters.js b/src/form/formatters.js index 69dd90686..7b972590b 100644 --- a/src/form/formatters.js +++ b/src/form/formatters.js @@ -1,31 +1,35 @@ import { compose, strSplice } from 'lib/lib'; const TICKET_MAX_BYTE_LEN = 32; // tickets can be as large as 32 bytes +const PAT_BLOCK_CHAR_LENGTH = 6; // pat{p,q} blocks are 6 characters long export const buildFormatter = (formatters = []) => compose(...formatters.reverse()); /** - * inserts a ~ or - every 7 characters + * inserts a ~ or - before every pat block */ export const ensurePatFormat = s => { + // bail if falsy or empty string, nothing to do if (!s || s.length === 0) { return s || ''; } // ensure there's a dash every 6 + 1 characters - const dashAt = i => i * (6 + 1); + const dashAt = i => i * (PAT_BLOCK_CHAR_LENGTH + 1); const maxBlocks = TICKET_MAX_BYTE_LEN / 2; + // for every index that may need a separator for (let i = 0; i !== maxBlocks; i++) { const dashIndex = dashAt(i); + // if the string is too short to have a separator here, we're done if (s.length - 1 < dashIndex) { return s; } + // insert the separator at the correct index if not already there const sep = i === 0 ? '~' : '-'; - if (s.charAt(dashIndex) !== sep) { s = strSplice(s, dashIndex, sep); } From c625ba55786e77d3d84b0833ca88782999fe435c Mon Sep 17 00:00:00 2001 From: "Matt Condon (shrugs)" Date: Tue, 13 Aug 2019 09:28:29 +0200 Subject: [PATCH 38/43] chore: remove dangling unused variables --- src/views/Login/Ticket.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/views/Login/Ticket.js b/src/views/Login/Ticket.js index dc0fecd1c..d9d2f0adb 100644 --- a/src/views/Login/Ticket.js +++ b/src/views/Login/Ticket.js @@ -1,5 +1,5 @@ -import React, { useCallback, useMemo, useRef } from 'react'; -import { Just, Nothing } from 'folktale/maybe'; +import React, { useCallback, useMemo } from 'react'; +import { Just } from 'folktale/maybe'; import cn from 'classnames'; import * as azimuth from 'azimuth-js'; import * as kg from 'urbit-key-generation/dist/index'; From ad8630b90775d4c1f05219d652219e1aa5383f39 Mon Sep 17 00:00:00 2001 From: "Matt Condon (shrugs)" Date: Tue, 13 Aug 2019 09:31:59 +0200 Subject: [PATCH 39/43] feat: unilateraly decide to use email@example.com as email placeholder --- src/form/Inputs.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/form/Inputs.js b/src/form/Inputs.js index 0c6df6872..8a5719707 100644 --- a/src/form/Inputs.js +++ b/src/form/Inputs.js @@ -19,7 +19,7 @@ const PLACEHOLDER_TICKET = '~sampel-ticlyt-migfun-falmel'; const PLACEHOLDER_ADDRESS = '0x12345abcdeDB11D175F123F6891AA64F01c24F7d'; const PLACEHOLDER_PRIVATE_KEY = '0x12345abcdee6beb2f323fab48b432925c9785808d33a6ca6d7ba00b45e9370c3'; -const PLACEHOLDER_EMAIL = 'Email Address'; +const PLACEHOLDER_EMAIL = 'email@example.com'; const formatPat = buildFormatter([downcase, ensurePatFormat]); From 0791f78b28a7835a99d5c593d76138cd5e5fa68f Mon Sep 17 00:00:00 2001 From: "Matt Condon (shrugs)" Date: Tue, 13 Aug 2019 10:08:03 +0200 Subject: [PATCH 40/43] feat: implement double-confirm when warnings exist --- src/form/helpers.js | 4 ++ src/views/Activate/ActivateCode.js | 89 +++++++++++++++++++++--------- src/views/Login/Ticket.js | 73 +++++++++++++----------- 3 files changed, 107 insertions(+), 59 deletions(-) create mode 100644 src/form/helpers.js diff --git a/src/form/helpers.js b/src/form/helpers.js new file mode 100644 index 000000000..d1f189e54 --- /dev/null +++ b/src/form/helpers.js @@ -0,0 +1,4 @@ +import { some } from 'lodash'; + +// we have warnings if some of the values are truthy +export const hasWarnings = warnings => some(warnings, v => !!v); diff --git a/src/views/Activate/ActivateCode.js b/src/views/Activate/ActivateCode.js index 30114c66f..063fc656c 100644 --- a/src/views/Activate/ActivateCode.js +++ b/src/views/Activate/ActivateCode.js @@ -1,35 +1,41 @@ -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback, useMemo, useRef } from 'react'; import { Just } from 'folktale/maybe'; import * as azimuth from 'azimuth-js'; import { Grid, H4 } from 'indigo-react'; +import { FORM_ERROR } from 'final-form'; import View from 'components/View'; import { ForwardButton } from 'components/Buttons'; import Passport from 'components/Passport'; +import WarningBox from 'components/WarningBox'; +import FooterButton from 'components/FooterButton'; +import { useNetwork } from 'store/network'; import { useHistory } from 'store/history'; import * as need from 'lib/need'; import { ROUTE_NAMES } from 'lib/routeNames'; -import FooterButton from 'components/FooterButton'; import { DEFAULT_HD_PATH, walletFromMnemonic } from 'lib/wallet'; -import { useNetwork } from 'store/network'; import { generateWallet } from 'lib/invite'; import { generateTemporaryOwnershipWallet } from 'lib/walletgen'; -import { useActivateFlow } from './ActivateFlow'; import { useLocalRouter } from 'lib/LocalRouter'; import useImpliedTicket from 'lib/useImpliedTicket'; import timeout from 'lib/timeout'; import useHasDisclaimed from 'lib/useHasDisclaimed'; import useBreakpoints from 'lib/useBreakpoints'; -import WarningBox from 'components/WarningBox'; import BridgeForm from 'form/BridgeForm'; import SubmitButton from 'form/SubmitButton'; import { TicketInput } from 'form/Inputs'; -import { composeValidator, buildPatqValidator } from 'form/validators'; +import { + composeValidator, + buildPatqValidator, + hasErrors, +} from 'form/validators'; import FormError from 'form/FormError'; -import { FORM_ERROR } from 'final-form'; + +import { useActivateFlow } from './ActivateFlow'; +import { hasWarnings } from 'form/helpers'; export default function ActivateCode() { const history = useHistory(); @@ -37,6 +43,7 @@ export default function ActivateCode() { const { contracts } = useNetwork(); const impliedTicket = useImpliedTicket(); const [hasDisclaimed] = useHasDisclaimed(); + const warnings = useRef({}); const { setDerivedWallet, @@ -61,11 +68,19 @@ export default function ActivateCode() { if (!hasDisclaimed) { push(names.DISCLAIMER); } - }, [names, push, hasDisclaimed]); + }, [hasDisclaimed, names.DISCLAIMER, names.PASSPORT, push]); + + const validateForm = useCallback((values, errors) => { + warnings.current.ticket = null; + + if (hasErrors(errors)) { + return errors; + } + }, []); const validate = useMemo( - () => composeValidator({ ticket: buildPatqValidator() }), - [] + () => composeValidator({ ticket: buildPatqValidator() }, validateForm), + [validateForm] ); // set our state on submission @@ -92,10 +107,9 @@ export default function ActivateCode() { if (incoming.length > 0) { if (incoming.length > 1) { - // TODO: putting a warning here doesn't make sense since the user - // will be immediately redirected away — what do? - // 'This invite code has multiple points available.\n' + - // "Once you've activated this point, activate the next with the same process."; + warnings.current.ticket = + 'This invite code has multiple points available.\n' + + "Once you've activated this point, activate the next with the same process."; } const point = parseInt(incoming[0], 10); @@ -114,6 +128,14 @@ export default function ActivateCode() { [contracts, setDerivedPoint, setDerivedWallet, setInviteWallet] ); + const afterSubmit = useCallback(() => { + if (hasWarnings(warnings.current)) { + return; + } + + goToPassport(); + }, [goToPassport]); + const initialValues = useMemo(() => ({ ticket: impliedTicket || '' }), [ impliedTicket, ]); @@ -128,9 +150,9 @@ export default function ActivateCode() { - {({ validating, submitting, handleSubmit }) => ( + {({ validating, submitting, submitSucceeded, handleSubmit }) => ( <> - - {validating - ? 'Deriving...' - : submitting - ? 'Generating...' - : 'Go'} - + {submitSucceeded ? ( + + Continue Activation + + ) : ( + + {validating + ? 'Deriving...' + : submitting + ? 'Generating...' + : 'Go'} + + )} {!activationAllowed && ( @@ -163,6 +197,7 @@ export default function ActivateCode() { )} + Login diff --git a/src/views/Login/Ticket.js b/src/views/Login/Ticket.js index d9d2f0adb..ac669f9c9 100644 --- a/src/views/Login/Ticket.js +++ b/src/views/Login/Ticket.js @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback, useMemo, useRef } from 'react'; import { Just } from 'folktale/maybe'; import cn from 'classnames'; import * as azimuth from 'azimuth-js'; @@ -28,9 +28,9 @@ import { buildPointValidator, } from 'form/validators'; import FormError from 'form/FormError'; - import SubmitButton from 'form/SubmitButton'; -import useSetState from 'lib/useSetState'; +import { hasWarnings } from 'form/helpers'; +import { ForwardButton } from 'components/Buttons'; export default function Ticket({ className, goHome }) { useLoginView(WALLET_TYPES.TICKET); @@ -39,27 +39,25 @@ export default function Ticket({ className, goHome }) { const { setUrbitWallet } = useWallet(); const { setPointCursor } = usePointCursor(); const impliedPoint = useImpliedPoint(); - const [warnings, addWarning] = useSetState(); + const warnings = useRef({}); + + const validateForm = useCallback((values, errors) => { + warnings.current.point = null; + + if (errors.point) { + return errors; + } - const validateForm = useCallback( - (values, errors) => { - if (errors.point) { - addWarning({ point: null }); + if (values.useShards) { + if (errors.shard1 || errors.shard2 || errors.shard3) { return errors; } - - if (values.useShards) { - if (errors.shard1 || errors.shard2 || errors.shard3) { - return errors; - } - } else { - if (errors.ticket) { - return errors; - } + } else { + if (errors.ticket) { + return errors; } - }, - [addWarning] - ); + } + }, []); const onSubmit = useCallback( async values => { @@ -93,12 +91,9 @@ export default function Ticket({ className, goHome }) { const noPermissions = !isOwner && !isTransferProxy; // notify the user, but allow login regardless - // TODO: how should the warning work now that we generate onSubmit? if (noPermissions) { - addWarning({ - point: - 'This wallet is not the owner or transfer proxy for this point.', - }); + warnings.current.point = + 'This wallet is not the owner or transfer proxy for this point.'; } setUrbitWallet(Just(urbitWallet)); @@ -112,9 +107,17 @@ export default function Ticket({ className, goHome }) { }; } }, - [addWarning, contracts, setPointCursor, setUrbitWallet] + [contracts, setPointCursor, setUrbitWallet] ); + const afterSubmit = useCallback(() => { + if (hasWarnings(warnings.current)) { + return; + } + + goHome(); + }, [goHome]); + const validate = useMemo( () => composeValidator( @@ -147,15 +150,15 @@ export default function Ticket({ className, goHome }) { - {({ handleSubmit }) => ( + {({ handleSubmit, submitSucceeded }) => ( <> @@ -197,9 +200,15 @@ export default function Ticket({ className, goHome }) { - - Continue - + {submitSucceeded ? ( + + Login Anyway + + ) : ( + + Continue + + )} )} From 7b505052afc0d73d0b8cecc0055d1dcfb0d7fc93 Mon Sep 17 00:00:00 2001 From: "Matt Condon (shrugs)" Date: Tue, 13 Aug 2019 10:52:51 +0200 Subject: [PATCH 41/43] fix: prevent 'Login Anyway' reflow in Ticket.js --- src/views/Login/Ticket.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/views/Login/Ticket.js b/src/views/Login/Ticket.js index ac669f9c9..ab87bcd1a 100644 --- a/src/views/Login/Ticket.js +++ b/src/views/Login/Ticket.js @@ -201,7 +201,12 @@ export default function Ticket({ className, goHome }) { {submitSucceeded ? ( - + Login Anyway ) : ( From 557ffe7c9ea94220c0a4922a6cfcb250157a7cb6 Mon Sep 17 00:00:00 2001 From: "Matt Condon (shrugs)" Date: Wed, 14 Aug 2019 11:57:28 +0200 Subject: [PATCH 42/43] fix: much better logic for warning double-confirmations --- src/form/FormError.js | 24 ++++++-- src/form/SubmitButton.js | 31 +++++++++- src/form/helpers.js | 7 ++- src/indigo-react/components/CheckboxInput.js | 4 +- src/indigo-react/components/Input.js | 11 +--- src/indigo-react/components/SelectInput.js | 4 +- src/indigo-react/components/ToggleInput.js | 4 +- src/views/Activate/ActivateCode.js | 62 ++++++++------------ src/views/Login/Ticket.js | 60 +++++++------------ 9 files changed, 105 insertions(+), 102 deletions(-) diff --git a/src/form/FormError.js b/src/form/FormError.js index 5f6b35da8..9b244a2a9 100644 --- a/src/form/FormError.js +++ b/src/form/FormError.js @@ -1,22 +1,34 @@ import React from 'react'; import { useFormState } from 'react-final-form'; import { ErrorText } from 'indigo-react'; -import { FORM_ERROR } from 'final-form'; + +import { WARNING } from './helpers'; export default function FormError(props) { - const { submitError, errors, dirtySinceLastSubmit } = useFormState({ + const { + submitError, + submitErrors, + error: validationError, + dirtySinceLastSubmit, + } = useFormState({ subscription: { submitError: true, + submitErrors: true, errors: true, dirtySinceLastSubmit: true, }, }); - const formError = errors[FORM_ERROR]; - const showFormError = !!formError; + const showValidationError = !!validationError; + const showSubmitError = !!submitError && !dirtySinceLastSubmit; - return showSubmitError || showFormError ? ( - {submitError || formError} + const warning = submitErrors && submitErrors[WARNING]; + const showWarning = !!warning && !dirtySinceLastSubmit; + + return showSubmitError || showValidationError || showWarning ? ( + + {submitError || validationError || warning} + ) : null; } diff --git a/src/form/SubmitButton.js b/src/form/SubmitButton.js index 4b42cc84a..891780b08 100644 --- a/src/form/SubmitButton.js +++ b/src/form/SubmitButton.js @@ -5,6 +5,7 @@ import { useFormState } from 'react-final-form'; import { ForwardButton } from 'components/Buttons'; import { blinkIf } from 'components/Blinky'; +import { onlyHasWarning } from './helpers'; export default function SubmitButton({ as: As = ForwardButton, @@ -20,13 +21,35 @@ export default function SubmitButton({ hasValidationErrors, hasSubmitErrors, dirtySinceLastSubmit, + submitErrors, + submitSucceeded, } = useFormState(); + const onlyWarningInSubmitErrors = onlyHasWarning(submitErrors); + + // can submit if: + // 1) is valid + // OR has submit errors AND is dirty or only has a warning (double conf) + // 2) AND is not validating + // 3) AND has no validation errors + // 4) AND is not actively submitting + // 5) AND submit has not yet succeeded const canSubmit = - (valid || (hasSubmitErrors && dirtySinceLastSubmit)) && + (valid || + (hasSubmitErrors && + (dirtySinceLastSubmit || onlyWarningInSubmitErrors))) && !validating && !hasValidationErrors && - !submitting; + !submitting && + !submitSucceeded; + + // show the warning action text if + // 1) we have that warning at all + // 2) we can submit or are submitting + const showWarningSubmitText = + onlyWarningInSubmitErrors && + !dirtySinceLastSubmit && + (canSubmit || submitting); return ( - {children} + {typeof children === 'function' + ? children(showWarningSubmitText) + : children} ); } diff --git a/src/form/helpers.js b/src/form/helpers.js index d1f189e54..c41548c05 100644 --- a/src/form/helpers.js +++ b/src/form/helpers.js @@ -1,4 +1,5 @@ -import { some } from 'lodash'; +export const WARNING = 'warn'; -// we have warnings if some of the values are truthy -export const hasWarnings = warnings => some(warnings, v => !!v); +// if the object only has the WARNING key +export const onlyHasWarning = obj => + !!obj && Object.keys(obj).length === 1 && !!obj[WARNING]; diff --git a/src/indigo-react/components/CheckboxInput.js b/src/indigo-react/components/CheckboxInput.js index 352e17bee..7664fc829 100644 --- a/src/indigo-react/components/CheckboxInput.js +++ b/src/indigo-react/components/CheckboxInput.js @@ -14,10 +14,10 @@ export default function CheckboxInput({ }) { const { input, - meta: { submitting }, + meta: { submitting, submitSucceeded }, } = useField(name, { type: 'checkbox' }); - disabled = disabled || submitting; + disabled = disabled || submitting || submitSucceeded; return ( - {warning && ( - - {warning} - - )} - {touched && !active && error && ( {error} diff --git a/src/indigo-react/components/SelectInput.js b/src/indigo-react/components/SelectInput.js index 5fe92fc5f..0c8a5d4c8 100644 --- a/src/indigo-react/components/SelectInput.js +++ b/src/indigo-react/components/SelectInput.js @@ -20,12 +20,12 @@ export default function SelectInput({ }) { const { input, - meta: { active, error, submitting, touched, valid }, + meta: { active, error, submitting, submitSucceeded, touched, valid }, } = useField(name, { type: 'select', }); - disabled = disabled || submitting; + disabled = disabled || submitting || submitSucceeded; const [isOpen, setIsOpen] = useState(false); const ref = useRef(); diff --git a/src/indigo-react/components/ToggleInput.js b/src/indigo-react/components/ToggleInput.js index cda0c464a..0f067719e 100644 --- a/src/indigo-react/components/ToggleInput.js +++ b/src/indigo-react/components/ToggleInput.js @@ -19,10 +19,10 @@ export default function ToggleInput({ }) { const { input, - meta: { submitting }, + meta: { submitting, submitSucceeded }, } = useField(name, { type: 'checkbox' }); - disabled = disabled || submitting; + disabled = disabled || submitting || submitSucceeded; return ( { - warnings.current.ticket = null; + didWarn.current = false; if (hasErrors(errors)) { return errors; @@ -106,10 +106,14 @@ export default function ActivateCode() { const incoming = [...owned, ...transferring]; if (incoming.length > 0) { - if (incoming.length > 1) { - warnings.current.ticket = - 'This invite code has multiple points available.\n' + - "Once you've activated this point, activate the next with the same process."; + if (incoming.length > 1 && !didWarn.current) { + didWarn.current = true; + return { + [WARNING]: + 'This invite code has multiple points available. ' + + "Once you've activated this point, " + + 'activate the next with the same process.', + }; } const point = parseInt(incoming[0], 10); @@ -128,14 +132,6 @@ export default function ActivateCode() { [contracts, setDerivedPoint, setDerivedWallet, setInviteWallet] ); - const afterSubmit = useCallback(() => { - if (hasWarnings(warnings.current)) { - return; - } - - goToPassport(); - }, [goToPassport]); - const initialValues = useMemo(() => ({ ticket: impliedTicket || '' }), [ impliedTicket, ]); @@ -150,9 +146,9 @@ export default function ActivateCode() { - {({ validating, submitting, submitSucceeded, handleSubmit }) => ( + {({ validating, submitting, handleSubmit }) => ( <> - {submitSucceeded ? ( - - Continue Activation - - ) : ( - - {validating + + {isWarning => + validating ? 'Deriving...' : submitting ? 'Generating...' - : 'Go'} - - )} + : isWarning + ? 'Continue Activation' + : 'Go' + } + {!activationAllowed && ( diff --git a/src/views/Login/Ticket.js b/src/views/Login/Ticket.js index ab87bcd1a..d06b3b20f 100644 --- a/src/views/Login/Ticket.js +++ b/src/views/Login/Ticket.js @@ -29,8 +29,7 @@ import { } from 'form/validators'; import FormError from 'form/FormError'; import SubmitButton from 'form/SubmitButton'; -import { hasWarnings } from 'form/helpers'; -import { ForwardButton } from 'components/Buttons'; +import { WARNING } from 'form/helpers'; export default function Ticket({ className, goHome }) { useLoginView(WALLET_TYPES.TICKET); @@ -39,10 +38,10 @@ export default function Ticket({ className, goHome }) { const { setUrbitWallet } = useWallet(); const { setPointCursor } = usePointCursor(); const impliedPoint = useImpliedPoint(); - const warnings = useRef({}); + const didWarn = useRef(false); const validateForm = useCallback((values, errors) => { - warnings.current.point = null; + didWarn.current = false; if (errors.point) { return errors; @@ -90,10 +89,13 @@ export default function Ticket({ className, goHome }) { ]); const noPermissions = !isOwner && !isTransferProxy; - // notify the user, but allow login regardless - if (noPermissions) { - warnings.current.point = - 'This wallet is not the owner or transfer proxy for this point.'; + // warn the user + if (noPermissions && !didWarn.current) { + didWarn.current = true; + return { + [WARNING]: + 'This wallet is not the owner or transfer proxy for this point.', + }; } setUrbitWallet(Just(urbitWallet)); @@ -110,14 +112,6 @@ export default function Ticket({ className, goHome }) { [contracts, setPointCursor, setUrbitWallet] ); - const afterSubmit = useCallback(() => { - if (hasWarnings(warnings.current)) { - return; - } - - goHome(); - }, [goHome]); - const validate = useMemo( () => composeValidator( @@ -150,16 +144,11 @@ export default function Ticket({ className, goHome }) { - {({ handleSubmit, submitSucceeded }) => ( + {({ handleSubmit, submitting }) => ( <> - + - {submitSucceeded ? ( - - Login Anyway - - ) : ( - - Continue - - )} + + {isWarning => + submitting + ? 'Logging in...' + : isWarning + ? 'Login Anyway' + : 'Continue' + } + )} From b019e78f1abce7ca528c4af11725921fe4f9fe04 Mon Sep 17 00:00:00 2001 From: "Matt Condon (shrugs)" Date: Thu, 15 Aug 2019 14:03:51 +0200 Subject: [PATCH 43/43] feat: tidy up InviteEmails.js and cover a few error edge cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NOTE: the render function diff will be large because of the indentation change within the `FieldArray` component. + update the buildValidator usage to allow additional (optionally async) validators per-field and in buildArrayValidator + fix: FormError was listening to a wrong error value in a subscription + commented more about FormError logic for clarity + pull Input.js error logic forward + fix FF-specific box-shadow css issue on required & invalid inputs + for plurality, we want to use the plural form for 0 counts as well (i.e. '0 errors' vs '0 error') + simulate a delay when stubbing useMailer + switch back to using `name` as the key for invites, receipts, and errors in InviteEmails.js, which fixes the accessory bug (accessories were not updating when they should have) + use the new WARNING pattern for messaging about errors during submission while still allowing submission anyway + add copy clarifying that the form can still be submitted + we now only send the valid invites, avoiding a `throw` case + we only tell the user about valid invites that were sent + if there was a warning generating invites, we allow the user to send the invites anyway known issues that could/should be handled in a future PR: - InviteEmails error handling still needs some 👀 - InviteEmails Inputs don't respond to the `error` state tracked within InviteEmails, only the error state within final-form's data-model - you can "Send Invites" with 0 valid invites. Nothing bad happens (nothing happens at all, really — you get a success state with '0 invites were successfully sent') --- src/form/FormError.js | 2 +- src/form/SubmitButton.js | 5 +- src/form/validators.js | 21 +- src/indigo-react/components/Input.js | 21 +- src/lib/pluralize.js | 2 +- src/lib/useMailer.js | 4 +- src/style/indigo-proposals.scss | 4 + src/views/Invite/InviteEmail.js | 287 ++++++++++++++------------- 8 files changed, 198 insertions(+), 148 deletions(-) diff --git a/src/form/FormError.js b/src/form/FormError.js index 9b244a2a9..0ed3e9ba4 100644 --- a/src/form/FormError.js +++ b/src/form/FormError.js @@ -14,7 +14,7 @@ export default function FormError(props) { subscription: { submitError: true, submitErrors: true, - errors: true, + error: true, dirtySinceLastSubmit: true, }, }); diff --git a/src/form/SubmitButton.js b/src/form/SubmitButton.js index 891780b08..043b50bde 100644 --- a/src/form/SubmitButton.js +++ b/src/form/SubmitButton.js @@ -29,7 +29,10 @@ export default function SubmitButton({ // can submit if: // 1) is valid - // OR has submit errors AND is dirty or only has a warning (double conf) + // OR has submit errors + // AND + // a) is dirty + // b) OR only has a warning (double conf) // 2) AND is not validating // 3) AND has no validation errors // 4) AND is not actively submitting diff --git a/src/form/validators.js b/src/form/validators.js index 4a691fbf9..f489b6659 100644 --- a/src/form/validators.js +++ b/src/form/validators.js @@ -17,7 +17,7 @@ import { import isPromise from 'lib/isPromise'; // iterate over validators, exiting early if there's an error -const buildValidator = (validators = []) => value => { +const buildValidator = (validators = [], validate) => value => { for (const validator of validators) { try { const error = validator(value); @@ -29,11 +29,22 @@ const buildValidator = (validators = []) => value => { return error.message; } } + + if (validate) { + // the final validate function can optionally return a promise + return validate(value); + } }; // maps a validator across an array of values -export const buildArrayValidator = validator => async values => - await Promise.all(values.map(validator)); +export const buildArrayValidator = validator => values => { + const errorsOrPromises = values.map(validator); + if (some(errorsOrPromises, isPromise)) { + return Promise.all(errorsOrPromises); + } + + return errorsOrPromises; +}; // error object has errors if some of its fields are // 1) an array with any defined values @@ -78,8 +89,8 @@ export const buildAddressValidator = () => ]); export const buildNumberValidator = (min = 0) => buildValidator([validateGreaterThan(min)]); -export const buildEmailValidator = (validators = []) => - buildValidator([validateNotEmpty, validateEmail, ...validators]); +export const buildEmailValidator = validate => + buildValidator([validateNotEmpty, validateEmail], validate); // the form validator is the composition of all of the field validators // plus an additional form validator function diff --git a/src/indigo-react/components/Input.js b/src/indigo-react/components/Input.js index c4e16c507..c09e75de8 100644 --- a/src/indigo-react/components/Input.js +++ b/src/indigo-react/components/Input.js @@ -26,7 +26,16 @@ export default function Input({ }) { const { input, - meta: { active, error, submitting, submitSucceeded, touched, valid }, + meta: { + active, + error, + submitError, + dirtySinceLastSubmit, + submitting, + submitSucceeded, + touched, + valid, + }, } = useField(name, config); disabled = disabled || submitting || submitSucceeded; @@ -41,7 +50,9 @@ export default function Input({ // [disabled, valid] // eslint-disable-line react-hooks/exhaustive-deps // ); - // console.log(input); + const showError = !!error; + const showSubmitError = !!submitError && !dirtySinceLastSubmit; + const indicateError = touched && !active && (showError || showSubmitError); return ( - {touched && !active && error && ( + {indicateError && ( - {error} + {error || submitError} )} diff --git a/src/lib/pluralize.js b/src/lib/pluralize.js index 8ff660873..3b6c37735 100644 --- a/src/lib/pluralize.js +++ b/src/lib/pluralize.js @@ -1,5 +1,5 @@ export default (count, singular, plural) => { - const isPlural = count > 1; + const isPlural = count === 0 || count > 1; if (!plural) { return `${count} ${singular}${isPlural ? 's' : ''}`; } diff --git a/src/lib/useMailer.js b/src/lib/useMailer.js index 3c55c5472..a947c0932 100644 --- a/src/lib/useMailer.js +++ b/src/lib/useMailer.js @@ -1,16 +1,18 @@ import { useCallback, useRef } from 'react'; import { hasReceived, sendMail } from './inviteMail'; +import timeout from './timeout'; const STUB_MAILER = process.env.REACT_APP_STUB_MAILER === 'true'; -export default function useMailer(emails) { +export default function useMailer() { const cache = useRef({}); const getHasReceived = useCallback( async email => { if (!cache.current[email]) { if (STUB_MAILER) { + await timeout(350); // simulate request cache.current[email] = false; } else { cache.current[email] = await hasReceived(email); diff --git a/src/style/indigo-proposals.scss b/src/style/indigo-proposals.scss index f14497e29..0188a91be 100644 --- a/src/style/indigo-proposals.scss +++ b/src/style/indigo-proposals.scss @@ -37,6 +37,10 @@ user-select: none; } +.bs-none { + box-shadow: none !important; +} + .auto-rows-min { grid-auto-rows: min-content; } diff --git a/src/views/Invite/InviteEmail.js b/src/views/Invite/InviteEmail.js index 5ba1b49a8..8bf7712b5 100644 --- a/src/views/Invite/InviteEmail.js +++ b/src/views/Invite/InviteEmail.js @@ -56,6 +56,7 @@ import { import { FORM_ERROR } from 'final-form'; import SubmitButton from 'form/SubmitButton'; import FormError from 'form/FormError'; +import { WARNING, onlyHasWarning } from 'form/helpers'; const INITIAL_VALUES = { emails: [''] }; @@ -72,6 +73,8 @@ const STATUS = { FAILURE: 'FAILURE', }; +const nameForEmailField = i => `emails[${i}]`; + const buttonText = (status, count) => { switch (status) { case STATUS.INPUT: @@ -105,7 +108,7 @@ export default function InviteEmail() { const { getHasReceived, sendMail } = useMailer(); const { gasPrice } = useSuggestedGasPrice(networkType); - const cachedEmails = useRef([]); + const cachedEmails = useRef({}); const { availableInvites } = getInvites(point); const maxInvitesToSend = availableInvites.matchWith({ @@ -158,9 +161,7 @@ export default function InviteEmail() { () => composeValidator( { - emails: buildArrayValidator( - buildEmailValidator([validateHasReceived]) - ), + emails: buildArrayValidator(buildEmailValidator(validateHasReceived)), }, validateForm ), @@ -218,6 +219,7 @@ export default function InviteEmail() { // NB(shrugs) - must be processed in serial because main thread, etc let errorCount = 0; for (let i = 0; i < values.emails.length; i++) { + const name = nameForEmailField(i); try { const email = values.emails[i]; const planet = planets[i]; @@ -249,20 +251,20 @@ export default function InviteEmail() { const rawTx = hexify(signedTx.serialize()); - addInvite({ [email]: { email, ticket, signedTx, rawTx } }); + addInvite({ [name]: { email, ticket, signedTx, rawTx } }); } catch (error) { console.error(error); errorCount++; + addError({ [name]: 'Error generating invite.' }); } } if (errorCount > 0) { return { - [FORM_ERROR]: `There ${pluralize( + [WARNING]: `There ${pluralize(errorCount, 'was', 'were')} ${pluralize( errorCount, - 'was', - 'were' - )} ${pluralize(errorCount, 'error')} while generating wallets.`, + 'error' + )} while generating wallets. You can still send the invites that generated correctly.`, }; } }, @@ -278,11 +280,17 @@ export default function InviteEmail() { networkType, gasPrice, addInvite, + addError, ] ); const sendInvites = useCallback(async () => { - const emails = cachedEmails.current; + // compute all of the names of the fields + const allFieldNames = Object.keys(cachedEmails.current); + // only emails we're sending are the valid ones with invites + const names = allFieldNames.filter(name => !!invites[name]); + const emails = allFieldNames.map(name => cachedEmails.current[name]); + const _web3 = web3.getOrElse(null); const _wallet = wallet.getOrElse(null); if (!_web3 || !_wallet) { @@ -295,7 +303,7 @@ export default function InviteEmail() { point, _wallet.address, toWei((gasPrice * GAS_LIMIT * emails.length).toString(), 'gwei'), - emails.map(email => invites[email].rawTx), + names.map(name => invites[name].rawTx), (address, minBalance, balance) => setNeedFunds({ address, minBalance, balance }), () => setNeedFunds(undefined) @@ -306,8 +314,8 @@ export default function InviteEmail() { let unsentInvites = []; let orphanedInvites = []; - const txAndMailings = emails.map(async email => { - const invite = invites[email]; + const txAndMailings = names.map(async name => { + const invite = invites[name]; try { const txHash = await sendSignedTransaction( _web3, @@ -334,7 +342,7 @@ export default function InviteEmail() { orphanedInvites.push(invite); } - addReceipt({ [email]: true }); + addReceipt({ [name]: true }); }); await Promise.all(txAndMailings); @@ -363,20 +371,28 @@ export default function InviteEmail() { wallet, point, gasPrice, - clearReceipts, invites, + clearReceipts, addReceipt, sendMail, ]); const onSubmit = useCallback( async values => { - cachedEmails.current = values.emails; + cachedEmails.current = values.emails.reduce((memo, email, i) => { + memo[nameForEmailField(i)] = email; + return memo; + }, {}); setStatus(STATUS.GENERATING); const errors = await generateInvites(values); - if (errors) { + if (hasErrors(errors)) { + if (onlyHasWarning(errors)) { + setStatus(STATUS.CAN_SEND); + } + return errors; } + setStatus(STATUS.CAN_SEND); }, [generateInvites] @@ -409,130 +425,133 @@ export default function InviteEmail() { validate={validate} onSubmit={onSubmit} initialValues={INITIAL_VALUES}> - {({ handleSubmit, valid, values }) => ( + {({ handleSubmit }) => ( - {({ fields }) => ( - <> - - {/* use hidden class instead of removing component from dom */} - {/* in order to avoid janky reflow */} - fields.push('')} - disabled={!canInput || fields.length >= maxInvitesToSend} - className={cn({ hidden: isDone })} - solid> - + - - - - {isDone ? ( - <> - - - {pluralize(fields.length, 'invite')} - {' '} - {pluralize(fields.length, 'has', 'have')} been - successfully sent - - - {fields.map(name => ( - - - {({ input: { value } }) => value} - + {({ fields }) => { + const sentInviteNames = Object.keys(receipts); + return ( + <> + + {/* use hidden class instead of removing component from dom */} + {/* in order to avoid janky reflow */} + fields.push('')} + disabled={!canInput || fields.length >= maxInvitesToSend} + className={cn({ hidden: isDone })} + solid> + + + + + + {isDone ? ( + <> + + + {pluralize(sentInviteNames.length, 'invite')} + {' '} + {pluralize(sentInviteNames.length, 'has', 'have')} been + successfully sent - ))} - - ) : ( - <> - {fields.map((name, i) => { - const isFirst = i === 0; - return ( - setHovered({ [name]: true })} - onMouseLeave={() => setHovered({ [name]: false })}> - + + {sentInviteNames.map(name => ( + - {({ meta: { active } }) => { - return ( - <> - {!isFirst && - (active || hovered[name]) && - canInput && ( - - fields.remove(i)} - solid - secondary> - - - - - )} - - ); - }} + {({ input: { value } }) => value} - ); - })} - - {canInput ? ( - - {buttonText(status, fields.length)} - - ) : ( - - {buttonText(status, fields.length)} - - )} - - {needFunds && ( - - - Your ownership address {needFunds.address} needs at - least {fromWei(needFunds.minBalance)} ETH and - currently has {fromWei(needFunds.balance)} ETH. - Waiting until the account has enough funds. - - - )} + ))} + + ) : ( + <> + {fields.map((name, i) => { + const isFirst = i === 0; + return ( + setHovered({ [name]: true })} + onMouseLeave={() => setHovered({ [name]: false })}> + + + {({ meta: { active } }) => { + return ( + <> + {!isFirst && + (active || hovered[name]) && + canInput && ( + + fields.remove(i)} + solid + secondary> + - + + + )} + + ); + }} + + + ); + })} + + {canInput ? ( + + {buttonText(status, fields.length)} + + ) : ( + + {buttonText(status, fields.length)} + + )} + + {needFunds && ( + + + Your ownership address {needFunds.address} needs at + least {fromWei(needFunds.minBalance)} ETH and + currently has {fromWei(needFunds.balance)} ETH. + Waiting until the account has enough funds. + + + )} - + - {generalError && ( - - {generalError} - - )} - - )} - - )} + {generalError && ( + + {generalError} + + )} + + )} + + ); + }} )}