on master help sign in
checkpoint: 90% done with inviteemail.js
21d1c95Matt Condon (shrugs)
package-lock.json+24−1
@@ -2168,7 +2168,7 @@
2168 "dev": true
2168 "dev": true
2169 },
2169 },
2170 "PaperCollateralRenderer": {
2170 "PaperCollateralRenderer": {
2171− "version": "github:urbit/PaperCollateralRenderer#27bd1381529618fb067a35575f8191d04ecfe10b",
2171+ "version": "github:urbit/PaperCollateralRenderer#8478aaf397e12769171b7b14332c47e4457e1d46",
2172 "from": "github:urbit/PaperCollateralRenderer#rc-immediate",
2172 "from": "github:urbit/PaperCollateralRenderer#rc-immediate",
2173 "requires": {
2173 "requires": {
2174 "babel-polyfill": "^6.26.0",
2174 "babel-polyfill": "^6.26.0",
@@ -6934,6 +6934,11 @@
6934 "@babel/runtime": "^7.3.1"
6934 "@babel/runtime": "^7.3.1"
6935 }
6935 }
6936 },
6936 },
6937+ "final-form-arrays": {
6938+ "version": "3.0.1",
6939+ "resolved": "https://registry.npmjs.org/final-form-arrays/-/final-form-arrays-3.0.1.tgz",
6940+ "integrity": "sha512-GKXecufCNCjDcz1+3peL21LuuTlApoxCcnpOnmfeJfC3xAlFKGdytYMfifP7W1IEWTGC8twTv3zItESkej8qpg=="
6941+ },
6937 "final-form-set-field-data": {
6942 "final-form-set-field-data": {
6938 "version": "1.0.2",
6943 "version": "1.0.2",
6939 "resolved": "https://registry.npmjs.org/final-form-set-field-data/-/final-form-set-field-data-1.0.2.tgz",
6944 "resolved": "https://registry.npmjs.org/final-form-set-field-data/-/final-form-set-field-data-1.0.2.tgz",
@@ -14551,6 +14556,24 @@
14551 }
14556 }
14552 }
14557 }
14553 },
14558 },
14559+ "react-final-form-arrays": {
14560+ "version": "3.1.0",
14561+ "resolved": "https://registry.npmjs.org/react-final-form-arrays/-/react-final-form-arrays-3.1.0.tgz",
14562+ "integrity": "sha512-eJdAlhTKzlDD/d1wedD592a99eJNGO0e9GzY++RLN99P23cMGKSzCmsiGWLPwpY0H/C/LmNSL4XzWyH/aZembA==",
14563+ "requires": {
14564+ "@babel/runtime": "^7.4.5"
14565+ },
14566+ "dependencies": {
14567+ "@babel/runtime": {
14568+ "version": "7.5.5",
14569+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.5.5.tgz",
14570+ "integrity": "sha512-28QvEGyQyNkB0/m2B4FU7IEZGK2NUrcMtT6BZEFALTguLk+AUT6ofsHtPk5QyjAdUkpMJ+/Em+quwz4HOt30AQ==",
14571+ "requires": {
14572+ "regenerator-runtime": "^0.13.2"
14573+ }
14574+ }
14575+ }
14576+ },
14554 "react-hot-loader": {
14577 "react-hot-loader": {
14555 "version": "4.12.0",
14578 "version": "4.12.0",
14556 "resolved": "https://registry.npmjs.org/react-hot-loader/-/react-hot-loader-4.12.0.tgz",
14579 "resolved": "https://registry.npmjs.org/react-hot-loader/-/react-hot-loader-4.12.0.tgz",
package.json+2
@@ -30,6 +30,7 @@
30 "ethereumjs-tx": "^1.3.7",
30 "ethereumjs-tx": "^1.3.7",
31 "file-saver": "^2.0.0",
31 "file-saver": "^2.0.0",
32 "final-form": "^4.18.2",
32 "final-form": "^4.18.2",
33+ "final-form-arrays": "^3.0.1",
33 "final-form-set-field-data": "^1.0.2",
34 "final-form-set-field-data": "^1.0.2",
34 "folktale": "^2.3.1",
35 "folktale": "^2.3.1",
35 "jszip": "^3.1.5",
36 "jszip": "^3.1.5",
@@ -42,6 +43,7 @@
42 "react-app-rewire-hot-loader": "^2.0.1",
43 "react-app-rewire-hot-loader": "^2.0.1",
43 "react-dom": "^16.8.6",
44 "react-dom": "^16.8.6",
44 "react-final-form": "^6.3.0",
45 "react-final-form": "^6.3.0",
46+ "react-final-form-arrays": "^3.1.0",
45 "react-hot-loader": "^4.12.0",
47 "react-hot-loader": "^4.12.0",
46 "react-scripts": "3.0.1",
48 "react-scripts": "3.0.1",
47 "react-teleporter": "^1.1.0",
49 "react-teleporter": "^1.1.0",
src/components/ViewHeader.js+1−1
@@ -3,5 +3,5 @@ import cn from 'classnames';
3import { H5 } from 'indigo-react';
3import { H5 } from 'indigo-react';
4
4
5export default function ViewHeader({ children, className }) {
5export default function ViewHeader({ children, className }) {
6− return <H5 className={cn(className, 'mv4')}>{children}</H5>;
6+ return <H5 className={cn(className, 'mb4')}>{children}</H5>;
7}
7}
src/form/BridgeForm.js+2−1
@@ -1,13 +1,14 @@
1import React from 'react';
1import React from 'react';
2import { Form } from 'react-final-form';
2import { Form } from 'react-final-form';
3import setFieldData from 'final-form-set-field-data';
3import setFieldData from 'final-form-set-field-data';
4+import arrayMutators from 'final-form-arrays';
4
5
5import ValuesHandler from './ValuesHandler';
6import ValuesHandler from './ValuesHandler';
6import ValidationPauser from './ValidationPauser';
7import ValidationPauser from './ValidationPauser';
7
8
8export default function BridgeForm({ children, onValues, ...rest }) {
9export default function BridgeForm({ children, onValues, ...rest }) {
9 return (
10 return (
10− <Form mutators={{ setFieldData }} {...rest}>
11+ <Form mutators={{ setFieldData, ...arrayMutators }} {...rest}>
11 {formProps => (
12 {formProps => (
12 <>
13 <>
13 <ValidationPauser />
14 <ValidationPauser />
src/form/Inputs.js+36−7
@@ -14,9 +14,11 @@ import {
14 validateHexString,
14 validateHexString,
15 validateHexLength,
15 validateHexLength,
16 validateEthereumAddress,
16 validateEthereumAddress,
17+ validateGreaterThan,
18+ validateEmail,
17} from 'lib/validators';
19} from 'lib/validators';
18import { compose } from 'lib/lib';
20import { compose } from 'lib/lib';
19−import { prependSig } from 'lib/transformers';
21+import { prependSig, convertToNumber } from 'lib/transformers';
20import { DEFAULT_HD_PATH } from 'lib/wallet';
22import { DEFAULT_HD_PATH } from 'lib/wallet';
21import InputSigil from 'components/InputSigil';
23import InputSigil from 'components/InputSigil';
22
24
@@ -28,6 +30,7 @@ const PLACEHOLDER_TICKET = '~sampel-ticlyt-migfun-falmel';
28const PLACEHOLDER_ADDRESS = '0x12345abcdeDB11D175F123F6891AA64F01c24F7d';
30const PLACEHOLDER_ADDRESS = '0x12345abcdeDB11D175F123F6891AA64F01c24F7d';
29const PLACEHOLDER_PRIVATE_KEY =
31const PLACEHOLDER_PRIVATE_KEY =
30 '0x12345abcdee6beb2f323fab48b432925c9785808d33a6ca6d7ba00b45e9370c3';
32 '0x12345abcdee6beb2f323fab48b432925c9785808d33a6ca6d7ba00b45e9370c3';
33+const PLACEHOLDER_EMAIL = 'Email Address';
31
34
32const buildValidator = (
35const buildValidator = (
33 validators = [],
36 validators = [],
@@ -54,12 +57,11 @@ export const buildCheckboxValidator = mustBe =>
54export const buildPassphraseValidator = () => buildValidator([]);
57export const buildPassphraseValidator = () => buildValidator([]);
55// TODO: validate hdpath format
58// TODO: validate hdpath format
56export const buildHdPathValidator = () => buildValidator([validateNotEmpty]);
59export const buildHdPathValidator = () => buildValidator([validateNotEmpty]);
57−export const buildPointValidator = (size = 4) =>
60+export const buildPointValidator = (size = 4, validate) =>
58− buildValidator([
61+ buildValidator(
59− validatePoint,
60− validateMaximumPatpByteLength(size),
62+ [validatePoint, validateMaximumPatpByteLength(size), validateNotEmpty],
61− validateNotEmpty,
63+ validate
62− ]);
64+ );
63export const buildSelectValidator = options =>
65export const buildSelectValidator = options =>
64 buildValidator([validateOneOf(options.map(option => option.value))]);
66 buildValidator([validateOneOf(options.map(option => option.value))]);
65export const buildHexValidator = length =>
67export const buildHexValidator = length =>
@@ -76,6 +78,10 @@ export const buildAddressValidator = () =>
76 validateHexString,
78 validateHexString,
77 validateNotEmpty,
79 validateNotEmpty,
78 ]);
80 ]);
81+export const buildNumberValidator = (min = 0) =>
82+ buildValidator([validateGreaterThan(min)]);
83+export const buildEmailValidator = () =>
84+ buildValidator([validateEmail, validateNotEmpty]);
79
85
80// the default form validator just returns field-level validations
86// the default form validator just returns field-level validations
81const kDefaultFormValidator = (values, errors) => errors;
87const kDefaultFormValidator = (values, errors) => errors;
@@ -227,3 +233,26 @@ export function AddressInput({ ...rest }) {
227 />
233 />
228 );
234 );
229}
235}
236+
237+export function NumberInput({ ...rest }) {
238+ return (
239+ <Input
240+ type="number"
241+ label="Number"
242+ autoComplete="off"
243+ config={{ format: convertToNumber }}
244+ {...rest}
245+ />
246+ );
247+}
248+
249+export function EmailInput({ ...rest }) {
250+ return (
251+ <Input
252+ type="email"
253+ placeholder={PLACEHOLDER_EMAIL}
254+ autoComplete="off"
255+ {...rest}
256+ />
257+ );
258+}
src/indigo-react/index.js−2
@@ -11,5 +11,3 @@ export { default as IconButton } from './components/IconButton';
11export { default as LinkButton } from './components/LinkButton';
11export { default as LinkButton } from './components/LinkButton';
12
12
13export * from './components/Typography';
13export * from './components/Typography';
14−
15−export * from './lib';
src/indigo-react/lib/index.jsdeleted−1
@@ -1 +0,0 @@
1−export { default as useForm } from './useForm';
src/indigo-react/lib/useForm.jsdeleted−264
@@ -1,264 +0,0 @@
1−import { useCallback, useMemo } from 'react';
2−import { keyBy, get, every, some } from 'lodash';
3−
4−import { compose } from 'lib/lib';
5−import { kDefaultValidator } from 'lib/validators';
6−import useSetState from 'lib/useSetState';
7−import useDeepEqualReference from 'lib/useDeepEqualReference';
8−
9−// interface InputConfig {
10−// name: string;
11−// validators: Function[];
12−// transformers: Function[];
13−// initialValue: string;
14−// autoFocus: boolean;
15−// disabled: boolean;
16−// ...extra,
17−// }
18−
19−const defaultsFor = (configs, mapper) =>
20− configs.reduce(
21− (memo, config) => ({ ...memo, [config.name]: mapper(config) }),
22− {}
23− );
24−
25−/**
26− * useForm manages a set of inputs for rendering them in a loop
27− */
28−export default function useForm(inputConfigs = []) {
29− const configs = useDeepEqualReference(inputConfigs);
30−
31− const byName = useMemo(() => keyBy(configs, 'name'), [configs]);
32−
33− // track values
34− const [values, _setValues, clearValues] = useSetState(() =>
35− defaultsFor(configs, config =>
36− config.initialValue === undefined ? '' : config.initialValue
37− )
38− );
39− // ^ NB(shrugs): because we're not syncing the initialValue of _additional_
40− // configs that are added, we won't be able to set an initialValue for a
41− // dynamic form that then triggers validation. for initial configs,
42− // initialValue can be a validated string and everything will be happy,
43− // but for dynamically added input configs, we won't get validation, etc
44− // until there's a traditional state update.
45−
46− // track focused states
47− const [focused, setFocused, clearFocused] = useSetState(() =>
48− defaultsFor(configs, config => config.autoFocus && !config.disabled)
49− );
50− const focuses = useMemo(
51− () => configs.map(config => focused[config.name] || false), //
52− [configs, focused]
53− );
54−
55− // track whether or not input has been focused
56− const [hasBeenFocused, setHasBeenFocused, clearHasBeenFocused] = useSetState(
57− {}
58− );
59−
60− // track whether or not the input has been touched
61− const [hasBeenTouched, setHasBeenTouched, clearHasBeenTouched] = useSetState(
62− {}
63− );
64−
65− // build fn that transforms a value by input name
66− const transform = useCallback(
67− (name, value) => compose(...get(byName, [name, 'transformers'], []))(value),
68− [byName]
69− );
70−
71− // set a value (and transform it)
72− const setValue = useCallback(
73− (name, value) => _setValues({ [name]: transform(name, value) }),
74− [_setValues, transform]
75− );
76−
77− // reset the form
78− const reset = useCallback(() => {
79− clearFocused();
80− clearHasBeenFocused();
81− clearHasBeenTouched();
82− clearValues();
83− }, [clearFocused, clearHasBeenFocused, clearHasBeenTouched, clearValues]);
84−
85− // build fn that validates a value by input name
86− const validate = useCallback(
87− (name, value) =>
88− compose(
89− ...get(byName, [name, 'validators'], []),
90− kDefaultValidator
91− )(value),
92− [byName]
93− );
94−
95− const getValue = useCallback(
96− (name, e) => {
97− if (byName[name].type === 'checkbox') {
98− return e.target.checked;
99− }
100−
101− return e.target.value;
102− },
103− [byName]
104− );
105−
106− // on change, transform and set value
107− const onChange = useCallback(
108− name => e => {
109− setValue(name, getValue(name, e));
110− setHasBeenTouched({ [name]: true });
111− }, //
112− [setValue, getValue, setHasBeenTouched]
113− );
114−
115− // on focus, update focus
116− const onFocus = useCallback(
117− name => e => setFocused({ [name]: true }), //
118− [setFocused]
119− );
120−
121− // on blur, defocus and set has been focused
122− const onBlur = useCallback(
123− name => e => {
124− setFocused({ [name]: false });
125− setHasBeenFocused({ [name]: true });
126− },
127− [setFocused, setHasBeenFocused]
128− );
129−
130− // memo-compute validations of curent values
131− const validations = useMemo(
132− () => configs.map(config => validate(config.name, values[config.name])), //
133− [configs, validate, values]
134− );
135−
136− // memo-compute the set of (perhaps changed by validation) data
137− const datas = useMemo(() => validations.map(v => v.data), [validations]);
138− const syncPasses = useMemo(
139− () => validations.map(v => v.pass, [validations]),
140− [validations]
141− );
142−
143− // the input has errored if it
144− // 1) did not pass validation and has an error text
145− // 2) or has a specific error from parent state
146− // and then we store that in errorTexts[i]
147− // <string | falsy>[]
148− const errorTexts = useMemo(
149− () =>
150− configs.map((config, i) => validations[i].error || config.error || false),
151− [configs, validations]
152− );
153−
154− // we should hint at an error if it
155− // 1) has been focused before
156− // 2) and has an error
157− // boolean[]
158− const hintErrors = useMemo(
159− () =>
160− configs.map(
161− (config, i) => hasBeenFocused[config.name] && !!errorTexts[i]
162− ),
163− [configs, errorTexts, hasBeenFocused]
164− );
165−
166− // we should block on error (with potential reflow) if it
167− // 1) has been touched
168− // 2) and has an error
169− // consumers should use this value to show error text and block on operations
170− // <string | falsy>[]
171− const errors = useMemo(
172− () =>
173− configs.map((config, i) => hasBeenTouched[config.name] && errorTexts[i]),
174− [configs, errorTexts, hasBeenTouched]
175− );
176−
177− // this input has passed if
178− // 1) it has passed local validation
179− // 2) there are no errors
180− const passes = useMemo(
181− () => validations.map((v, i) => v.pass && !errorTexts[i]),
182− [validations, errorTexts]
183− );
184−
185− // visibly tell the user that their input has passed if
186− // 1) they are or have interacted with the input before
187− // 2) and it has passed as defined above
188− const visiblePasses = useMemo(
189− () =>
190− configs.map(
191− (config, i) =>
192− (focused[config.name] || hasBeenFocused[config.name]) && //
193− passes[i]
194− ),
195− [configs, hasBeenFocused, focused, passes]
196− );
197−
198− // generate the list of input states
199− const inputs = useMemo(
200− () =>
201− configs.map(
202− ({ name, error, autoFocus, disabled, initialValue, ...rest }, i) => {
203− const value =
204− datas[i] !== undefined
205− ? datas[i]
206− : initialValue !== undefined
207− ? initialValue
208− : '';
209− return {
210− // Input props
211− name,
212− value: value,
213− data: passes[i] ? datas[i] : undefined,
214− pass: passes[i],
215− syncPass: syncPasses[i],
216− visiblyPassed: visiblePasses[i],
217− error: errors[i],
218− hintError: hintErrors[i],
219− focused: focuses[i],
220− touched: hasBeenTouched[name],
221− autoFocus: autoFocus && !disabled,
222− disabled,
223− ...rest,
224− // DOM Properties for <input />
225− bind: {
226− value,
227− checked: !!datas[i],
228− onChange: onChange(name),
229− onFocus: onFocus(name),
230− onBlur: onBlur(name),
231− autoFocus: autoFocus && !disabled,
232− },
233− };
234− }
235− ),
236− [
237− configs,
238− datas,
239− passes,
240− syncPasses,
241− visiblePasses,
242− errors,
243− hintErrors,
244− focuses,
245− hasBeenTouched,
246− onChange,
247− onFocus,
248− onBlur,
249− ]
250− );
251−
252− // did all of the inputs pass validation?
253− const pass = useMemo(() => every(passes), [passes]);
254− // did any of the inputs error?
255− const error = useMemo(() => some(errors), [errors]);
256−
257− return {
258− inputs,
259− pass,
260− error,
261− setValue,
262− reset,
263− };
264−}
src/indigo-react/lib/useOnClickOutside.js−1
@@ -1,7 +1,6 @@
1import { useEffect } from 'react';
1import { useEffect } from 'react';
2
2
3// via https:// usehooks.com/useOnClickOutside/
3// via https:// usehooks.com/useOnClickOutside/
4−
5export default function useOnClickOutside(ref, handler) {
4export default function useOnClickOutside(ref, handler) {
6 useEffect(() => {
5 useEffect(() => {
7 const listener = event => {
6 const listener = event => {
src/lib/useArray.jsdeleted−48
@@ -1,48 +0,0 @@
1−import { useCallback, useState } from 'react';
2−import { identity } from 'lodash';
3−
4−/**
5− * Manages an immutable array of items.
6− */
7−export default function useArray(initialItems = [], itemBuilder = identity) {
8− const [items, _setItems] = useState(initialItems);
9−
10− const append = useCallback(
11− item => _setItems(items => [...items, itemBuilder(item)]),
12− [_setItems, itemBuilder]
13− );
14−
15− const removeAt = useCallback(
16− i =>
17− _setItems(items => {
18− const newItems = [...items.slice(0, i), ...items.slice(i + 1)];
19− return newItems;
20− }),
21− [_setItems]
22− );
23−
24− const updateAt = useCallback(
25− (i, update = {}) =>
26− _setItems(items => [
27− ...items.slice(0, i),
28− {
29− ...items[i],
30− ...update,
31− },
32− ...items.slice(i + 1),
33− ]),
34− [_setItems]
35− );
36−
37− const clear = useCallback(() => _setItems([]), [_setItems]);
38−
39− return [
40− items,
41− {
42− append,
43− removeAt,
44− updateAt,
45− clear,
46− },
47− ];
48−}
src/lib/useConstant.jsnew file+13
@@ -0,0 +1,13 @@
1+import { useRef } from 'react';
2+
3+// inspired by, but simpler version of:
4+// https://github.com/Andarist/use-constant/blob/master/src/index.ts
5+export default function useConstant(fn) {
6+ const ref = useRef();
7+
8+ if (!ref.current) {
9+ ref.current = fn();
10+ }
11+
12+ return ref.current;
13+}
src/lib/useInputs.jsdeleted−152
@@ -1,152 +0,0 @@
1−import React, { useCallback, useMemo } from 'react';
2−import { AccessoryIcon, useForm } from 'indigo-react';
3−import { identity } from 'lodash';
4−
5−import { DEFAULT_HD_PATH } from 'lib/wallet';
6−import {
7− validateNotEmpty,
8− validateMnemonic,
9− validatePoint,
10− validateTicket,
11− validateEmail,
12− validateLength,
13− validateHexString,
14− validateOneOf,
15− validateMaximumPatpByteLength,
16− validateEthereumAddress,
17− validateNotNullAddress,
18− validateGreaterThan,
19−} from 'lib/validators';
20−import { prependSig, convertToNumber } from 'lib/transformers';
21−
22−import InputSigil from 'components/InputSigil';
23−
24−const PLACEHOLDER_POINT = '~sampel-ponnym';
25−const PLACEHOLDER_HD_PATH = DEFAULT_HD_PATH;
26−const PLACEHOLDER_MNEMONIC =
27− 'example crew supreme gesture quantum web media hazard theory mercy wing kitten';
28−const PLACEHOLDER_TICKET = '~sampel-ticlyt-migfun-falmel';
29−const PLACEHOLDER_ADDRESS = '0x12345abcdeDB11D175F123F6891AA64F01c24F7d';
30−const PLACEHOLDER_PRIVATE_KEY =
31− '0x12345abcdee6beb2f323fab48b432925c9785808d33a6ca6d7ba00b45e9370c3';
32−
33−// pulls out the first input from a useForm() call
34−function useFirstOf({ inputs, setValue, ...rest }, mapper = identity) {
35− // ask the mapper function for any values to overwrite
36− const input = {
37− ...inputs[0],
38− ...mapper(inputs[0]),
39− };
40− // memoize the setValue callback for manually setting the value
41− // in response to some imperative event
42− const _setValue = useCallback(value => setValue(input.name, value), [
43− setValue,
44− input.name,
45− ]);
46−
47− // provide the first input as the first element
48− // _and_ as the second element to facilitate destructuring
49− // and then provide the pass/error/setValue/etc properties at the end
50− return [
51− input,
52− input,
53− {
54− ...rest,
55− setValue: _setValue,
56− },
57− ];
58−}
59−
60−const kPointTransformers = [prependSig];
61−export function usePointInput({ size = 4, validators = [], ...rest }) {
62− const _validators = useMemo(
63− () => [
64− ...validators,
65− validatePoint,
66− validateMaximumPatpByteLength(size),
67− validateNotEmpty,
68− ],
69− [size, validators]
70− );
71−
72− return useFirstOf(
73− useForm([
74− {
75− type: 'text',
76− label: 'Point',
77− placeholder: PLACEHOLDER_POINT,
78− validators: _validators,
79− transformers: kPointTransformers,
80− mono: true,
81− ...rest,
82− },
83− ]),
84− ({ error, pass, focused, value }) => ({
85− accessory: value ? (
86− <InputSigil
87− patp={value}
88− size={44}
89− margin={8}
90− pass={pass}
91− focused={focused}
92− error={error}
93− />
94− ) : null,
95− })
96− );
97−}
98−
99−export function useGalaxyInput(props) {
100− return usePointInput({
101− label: 'Galaxy Name',
102− size: 1,
103− ...props,
104− });
105−}
106−
107−const kAddressValidators = [
108− validateEthereumAddress,
109− validateNotNullAddress,
110− validateNotEmpty,
111−];
112−export function useAddressInput({ ...rest }) {
113− return useFirstOf(
114− useForm([
115− {
116− type: 'string',
117− label: 'Ethereum Address',
118− placeholder: PLACEHOLDER_ADDRESS,
119− autoComplete: 'off',
120− validators: kAddressValidators,
121− mono: true,
122− ...rest,
123− },
124− ])
125− );
126−}
127−
128−const kNumberTransformers = [convertToNumber];
129−const kNumberValidators = [validateGreaterThan(0)];
130−export function useNumberInput({ ...rest }) {
131− return useFirstOf(
132− useForm([
133− {
134− type: 'number',
135− label: 'Number',
136− autoComplete: 'off',
137− transformers: kNumberTransformers,
138− validators: kNumberValidators,
139− ...rest,
140− },
141− ])
142− );
143−}
144−
145−const kEmailValidators = [validateEmail, validateNotEmpty];
146−export const buildEmailInputConfig = extra => ({
147− type: 'email',
148− autoComplete: 'off',
149− validators: kEmailValidators,
150− initialValue: '',
151− ...extra,
152−});
src/lib/useMailer.js+16−29
@@ -1,43 +1,27 @@
1−import { useCallback } from 'react';
1+import { useCallback, useRef } from 'react';
2−import { Just, Nothing } from 'folktale/maybe';
3
2
4import { hasReceived, sendMail } from './inviteMail';
3import { hasReceived, sendMail } from './inviteMail';
5−import useSetState from './useSetState';
6
4
7const STUB_MAILER = process.env.REACT_APP_STUB_MAILER === 'true';
5const STUB_MAILER = process.env.REACT_APP_STUB_MAILER === 'true';
8
6
9−function useHasReceivedCache() {
7+export default function useMailer(emails) {
10− const [cache, addToCache] = useSetState();
8+ const cache = useRef({});
11
9
12 const getHasReceived = useCallback(
10 const getHasReceived = useCallback(
13− email => cache[email] || Nothing(), //
14− [cache]
15− );
16−
17− const syncHasReceivedForEmail = useCallback(
18 async email => {
11 async email => {
19− if (Just.hasInstance(getHasReceived(email))) {
12+ if (!cache.current[email]) {
20− // never update the cache after we know about it
21− return;
22− }
23−
24− if (STUB_MAILER) {
13+ if (STUB_MAILER) {
25− // always allow sending emails when stubbing
14+ cache.current[email] = false;
26− return addToCache({ [email]: Just(false) });
15+ } else {
16+ cache.current[email] = await hasReceived(email);
17+ }
27 }
18 }
28
19
29− const _hasReceived = await hasReceived(email);
20+ return cache.current[email];
30− addToCache({ [email]: Just(_hasReceived) });
31 },
21 },
32− [getHasReceived, addToCache]
22+ [cache]
33 );
23 );
34
24
35− return { getHasReceived, syncHasReceivedForEmail };
36−}
37−
38−export default function useMailer(emails) {
39− const hasReceivedCache = useHasReceivedCache(emails);
40−
41 // prefix to avoid clobbering sendMail import
25 // prefix to avoid clobbering sendMail import
42 // also throws if return value is false
26 // also throws if return value is false
43 const _sendMail = useCallback(async (email, ticket, sender, rawTx) => {
27 const _sendMail = useCallback(async (email, ticket, sender, rawTx) => {
@@ -46,8 +30,11 @@ export default function useMailer(emails) {
46 return true;
30 return true;
47 }
31 }
48
32
49− return await sendMail(email, ticket, sender, rawTx);
33+ const success = await sendMail(email, ticket, sender, rawTx);
34+ if (!success) {
35+ throw new Error('Failed to send mail');
36+ }
50 }, []);
37 }, []);
51
38
52− return { ...hasReceivedCache, sendMail: _sendMail };
39+ return { getHasReceived, sendMail: _sendMail };
53}
40}
src/views/Admin/AdminNetworkingKeys.js+3
@@ -39,6 +39,7 @@ import {
39} from 'form/Inputs';
39} from 'form/Inputs';
40import BridgeForm from 'form/BridgeForm';
40import BridgeForm from 'form/BridgeForm';
41import Condition from 'form/Condition';
41import Condition from 'form/Condition';
42+import FormError from 'form/FormError';
42
43
43const chainKeyProp = name => d =>
44const chainKeyProp = name => d =>
44 d[name] === CURVE_ZERO_ADDR ? Nothing() : Just(d[name]);
45 d[name] === CURVE_ZERO_ADDR ? Nothing() : Just(d[name]);
@@ -365,6 +366,8 @@ export default function AdminNetworkingKeys() {
365 </BridgeForm>
366 </BridgeForm>
366 )}
367 )}
367
368
369+ <Grid.Item full as={FormError} />
370+
368 <Grid.Item
371 <Grid.Item
369 full
372 full
370 as={InlineEthereumTransaction}
373 as={InlineEthereumTransaction}
src/views/Admin/AdminSetProxy.js+10−7
@@ -28,6 +28,7 @@ import {
28 AddressInput,
28 AddressInput,
29} from 'form/Inputs';
29} from 'form/Inputs';
30import BridgeForm from 'form/BridgeForm';
30import BridgeForm from 'form/BridgeForm';
31+import FormError from 'form/FormError';
31
32
32const proxyFromDetails = (details, contracts, proxyType) => {
33const proxyFromDetails = (details, contracts, proxyType) => {
33 switch (proxyType) {
34 switch (proxyType) {
@@ -218,14 +219,16 @@ export default function AdminSetProxy() {
218 )}
219 )}
219 </>
220 </>
220 )}
221 )}
221− </BridgeForm>
222
222
223− <Grid.Item
223+ <Grid.Item full as={FormError} />
224+
225+ <Grid.Item
224− full
226+ full
225− as={InlineEthereumTransaction}
227+ as={InlineEthereumTransaction}
226− {...bind}
228+ {...bind}
227− onReturn={() => pop()}
229+ onReturn={() => pop()}
228− />
230+ />
231+ </BridgeForm>
229 </Grid>
232 </Grid>
230 );
233 );
231}
234}
src/views/Admin/AdminTransfer.js+58−34
@@ -1,6 +1,6 @@
1−import React, { useCallback, useEffect } from 'react';
1+import React, { useCallback, useMemo } from 'react';
2import cn from 'classnames';
2import cn from 'classnames';
3−import { Grid, Text, Input } from 'indigo-react';
3+import { Grid, Text } from 'indigo-react';
4import * as azimuth from 'azimuth-js';
4import * as azimuth from 'azimuth-js';
5
5
6import { useNetwork } from 'store/network';
6import { useNetwork } from 'store/network';
@@ -9,13 +9,19 @@ import { usePointCache } from 'store/pointCache';
9
9
10import * as need from 'lib/need';
10import * as need from 'lib/need';
11import { useLocalRouter } from 'lib/LocalRouter';
11import { useLocalRouter } from 'lib/LocalRouter';
12−import { useAddressInput } from 'lib/useInputs';
13import useCurrentPointName from 'lib/useCurrentPointName';
12import useCurrentPointName from 'lib/useCurrentPointName';
14import useEthereumTransaction from 'lib/useEthereumTransaction';
13import useEthereumTransaction from 'lib/useEthereumTransaction';
15import { GAS_LIMITS } from 'lib/constants';
14import { GAS_LIMITS } from 'lib/constants';
16
15
17import ViewHeader from 'components/ViewHeader';
16import ViewHeader from 'components/ViewHeader';
18import InlineEthereumTransaction from 'components/InlineEthereumTransaction';
17import InlineEthereumTransaction from 'components/InlineEthereumTransaction';
18+import {
19+ AddressInput,
20+ composeValidator,
21+ buildAddressValidator,
22+} from 'form/Inputs';
23+import BridgeForm from 'form/BridgeForm';
24+import FormError from 'form/FormError';
19
25
20function useTransfer() {
26function useTransfer() {
21 const { contracts } = useNetwork();
27 const { contracts } = useNetwork();
@@ -47,46 +53,64 @@ export default function AdminTransfer() {
47 bind,
53 bind,
48 } = useTransfer();
54 } = useTransfer();
49
55
50− const [addressInput, { pass, data: address }] = useAddressInput({
56+ const validate = useMemo(
51− name: 'address',
57+ () => composeValidator({ address: buildAddressValidator() }),
52− label: `Ethereum Address`,
58+ []
53− disabled: inputsLocked,
54− });
59+ );
55
60
56− useEffect(() => {
61+ const onValues = useCallback(
57− if (pass) {
62+ ({ valid, values }) => {
63+ if (valid) {
58− construct(address);
64+ construct(values.address);
59− } else {
65+ } else {
60− unconstruct();
66+ unconstruct();
61− }
67+ }
68+ },
62− }, [pass, address, construct, unconstruct]);
69+ [construct, unconstruct]
70+ );
63
71
64 return (
72 return (
65 <Grid>
73 <Grid>
66 <Grid.Item full as={ViewHeader}>
74 <Grid.Item full as={ViewHeader}>
67 Transfer Point
75 Transfer Point
68 </Grid.Item>
76 </Grid.Item>
69− <Grid.Item
70− full
71− as={Text}
72− className={cn('f5', {
73− green3: completed,
74− })}>
75− {completed
76− ? `${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}.`
77− : `Transfer ${name} to a new owner.`}
78− </Grid.Item>
79
77
78+ <BridgeForm validate={validate} onSubmit={() => {}} onValues={onValues}>
79+ {({ handleSubmit, values }) => (
80+ <>
81+ <Grid.Item
82+ full
83+ as={Text}
84+ className={cn('f5', {
85+ green3: completed,
86+ })}>
87+ {completed
88+ ? `${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}.`
89+ : `Transfer ${name} to a new owner.`}
90+ </Grid.Item>
91+
80− {!completed && (
92+ {!completed && (
93+ <Grid.Item
94+ full
95+ as={AddressInput}
81− <Grid.Item full as={Input} {...addressInput} className="mv4" />
96+ className="mv4"
97+ name="address"
98+ label="Ethereum Address"
99+ disabled={inputsLocked}
100+ />
82− )}
101+ )}
102+
103+ <Grid.Item full as={FormError} />
83
104
84− <Grid.Item
105+ <Grid.Item
85− full
106+ full
86− as={InlineEthereumTransaction}
107+ as={InlineEthereumTransaction}
87− {...bind}
108+ {...bind}
88− onReturn={() => pop()}
109+ onReturn={() => pop()}
89− />
110+ />
111+ </>
112+ )}
113+ </BridgeForm>
90 </Grid>
114 </Grid>
91 );
115 );
92}
116}
src/views/Admin/Reticket/ReticketVerify.js+4
@@ -12,6 +12,7 @@ import {
12 buildTicketValidator,
12 buildTicketValidator,
13} from 'form/Inputs';
13} from 'form/Inputs';
14import SubmitButton from 'form/SubmitButton';
14import SubmitButton from 'form/SubmitButton';
15+import FormError from 'form/FormError';
15
16
16const STUB_VERIFY_TICKET = isDevelopment;
17const STUB_VERIFY_TICKET = isDevelopment;
17
18
@@ -50,6 +51,9 @@ export default function ReticketVerify({ newWallet }) {
50 name="ticket"
51 name="ticket"
51 label="New master ticket"
52 label="New master ticket"
52 />
53 />
54+
55+ <Grid.Item full as={FormError} />
56+
53 <Grid.Item full as={SubmitButton} handleSubmit={handleSubmit}>
57 <Grid.Item full as={SubmitButton} handleSubmit={handleSubmit}>
54 Verify & Reticket
58 Verify & Reticket
55 </Grid.Item>
59 </Grid.Item>
src/views/CreateGalaxy.js+84−93
@@ -1,5 +1,4 @@
1−import React, { useCallback, useEffect, useState } from 'react';
1+import React, { useCallback, useMemo, useState } from 'react';
2−import { Nothing, Just } from 'folktale/maybe';
3import cn from 'classnames';
2import cn from 'classnames';
4import { Grid, Text, Input } from 'indigo-react';
3import { Grid, Text, Input } from 'indigo-react';
5import * as azimuth from 'azimuth-js';
4import * as azimuth from 'azimuth-js';
@@ -8,7 +7,6 @@ import { useNetwork } from 'store/network';
8import { usePointCache } from 'store/pointCache';
7import { usePointCache } from 'store/pointCache';
9
8
10import * as need from 'lib/need';
9import * as need from 'lib/need';
11−import { useAddressInput, useGalaxyInput } from 'lib/useInputs';
12import useEthereumTransaction from 'lib/useEthereumTransaction';
10import useEthereumTransaction from 'lib/useEthereumTransaction';
13import { GAS_LIMITS } from 'lib/constants';
11import { GAS_LIMITS } from 'lib/constants';
14import patp2dec from 'lib/patp2dec';
12import patp2dec from 'lib/patp2dec';
@@ -18,6 +16,14 @@ import { useLocalRouter } from 'lib/LocalRouter';
18import ViewHeader from 'components/ViewHeader';
16import ViewHeader from 'components/ViewHeader';
19import InlineEthereumTransaction from 'components/InlineEthereumTransaction';
17import InlineEthereumTransaction from 'components/InlineEthereumTransaction';
20import View from 'components/View';
18import View from 'components/View';
19+import BridgeForm from 'form/BridgeForm';
20+import {
21+ PointInput,
22+ composeValidator,
23+ buildPointValidator,
24+ buildAddressValidator,
25+} from 'form/Inputs';
26+import FormError from 'form/FormError';
21
27
22function useCreateGalaxy() {
28function useCreateGalaxy() {
23 const { contracts } = useNetwork();
29 const { contracts } = useNetwork();
@@ -45,9 +51,6 @@ export default function CreateGalaxy() {
45 const { contracts } = useNetwork();
51 const { contracts } = useNetwork();
46 const _contracts = need.contracts(contracts);
52 const _contracts = need.contracts(contracts);
47
53
48− const [error, setError] = useState();
49− const [isAvailable, setIsAvailable] = useState(Nothing());
50−
51 const {
54 const {
52 construct,
55 construct,
53 unconstruct,
56 unconstruct,
@@ -56,75 +59,40 @@ export default function CreateGalaxy() {
56 bind,
59 bind,
57 } = useCreateGalaxy();
60 } = useCreateGalaxy();
58
61
59− const [
62+ const validateGalaxy = useCallback(
60− galaxyNameInput,
63+ async galaxyName => {
61− { pass: validGalaxyName, syncPass: syncValidGalaxyName, value: galaxyName },
62− // ^ we use value: here so our effect runs onChange
63− ] = useGalaxyInput({
64− name: 'galaxy',
65− disabled: inputsLocked,
66− autoFocus: true,
67− error:
68− error ||
69− isAvailable.matchWith({
70− Nothing: () => 'Loading availability...', // TODO: make async loading?
71− Just: p => (p.value ? undefined : 'This galaxy is already owned.'),
72− }),
73− });
74−
75− const [ownerInput, { pass: validOwner, data: owner }] = useAddressInput({
76− name: 'owner',
77− label: `Ethereum Address`,
78− disabled: inputsLocked,
79− });
80−
81− useEffect(() => {
82− if (validGalaxyName && validOwner) {
83− construct(patp2dec(galaxyName), owner);
84− } else {
85− unconstruct();
86− }
87− }, [owner, construct, unconstruct, validGalaxyName, validOwner, galaxyName]);
88−
89− useEffect(() => {
90− if (!syncValidGalaxyName || inputsLocked) {
91− return;
92− }
93−
94− setError();
95− setIsAvailable(Nothing());
96−
97− let cancelled = false;
98−
99− (async () => {
100− try {
101− const currentOwner = await azimuth.azimuth.getOwner(
64+ const currentOwner = await azimuth.azimuth.getOwner(
102− _contracts,
65+ _contracts,
103− patp2dec(galaxyName)
66+ patp2dec(galaxyName)
104− );
67+ );
105−
68+
106− const isAvailable = isZeroAddress(currentOwner);
69+ const isAvailable = isZeroAddress(currentOwner);
107−
70+ if (!isAvailable) {
108− if (cancelled) {
71+ return 'This galaxy is already spawned and owned.';
109− return;
110− }
111−
112− setIsAvailable(Just(isAvailable));
113− } catch (error) {
114− console.error(error);
115− setError(error.message);
116− setIsAvailable(Just(false));
117 }
72 }
73+ },
74+ [_contracts]
118− })();
75+ );
119
76
120− return () => (cancelled = true);
77+ const validate = useMemo(
78+ () =>
79+ composeValidator({
80+ galaxyName: buildPointValidator(1, validateGalaxy),
81+ owner: buildAddressValidator(),
121− }, [
82+ }),
122− _contracts,
83+ [validateGalaxy]
123− galaxyName,
84+ );
124− inputsLocked,
85+
125− setIsAvailable,
86+ const onValues = useCallback(
126− syncValidGalaxyName,
87+ ({ valid, values }) => {
88+ if (valid) {
89+ construct(patp2dec(values.galaxyName), values.owner);
90+ } else {
91+ unconstruct();
92+ }
93+ },
94+ [construct, unconstruct]
127− ]);
95+ );
128
96
129 return (
97 return (
130 <View pop={pop} inset>
98 <View pop={pop} inset>
@@ -133,26 +101,49 @@ export default function CreateGalaxy() {
133 Create a Galaxy
101 Create a Galaxy
134 </Grid.Item>
102 </Grid.Item>
135
103
104+ <BridgeForm validate={validate} onSubmit={() => {}} onValues={onValues}>
105+ {({ handleSubmit, values }) => (
106+ <>
136− {completed && (
107+ {completed && (
137− <Grid.Item
108+ <Grid.Item
138− full
109+ full
139− as={Text}
110+ as={Text}
140− className={cn('f5', {
111+ className={cn('f5', {
141− green3: completed,
112+ green3: completed,
142− })}>
113+ })}>
143− {galaxyName} has been created and can be claimed by {owner}.
114+ {values.galaxyName} has been created and can be claimed by{' '}
115+ {values.owner}.
144− </Grid.Item>
116+ </Grid.Item>
145− )}
117+ )}
146−
118+
119+ <Grid.Item
120+ full
121+ as={PointInput}
147− <Grid.Item full as={Input} {...galaxyNameInput} className="mt4" />
122+ className="mt4"
123+ name="galaxyName"
124+ label="Galaxy Name"
125+ disabled={inputsLocked}
126+ />
127+ <Grid.Item
128+ full
129+ as={Input}
148− <Grid.Item full as={Input} {...ownerInput} className="mb4" />
130+ className="mb4"
149−
131+ name="owner"
132+ label="Ethereum Address"
133+ disabled={inputsLocked}
134+ />
135+
150− <Grid.Item
136+ <Grid.Item full as={FormError} />
137+
138+ <Grid.Item
151− full
139+ full
152− as={InlineEthereumTransaction}
140+ as={InlineEthereumTransaction}
153− {...bind}
141+ {...bind}
154− onReturn={() => pop()}
142+ onReturn={() => pop()}
155− />
143+ />
144+ </>
145+ )}
146+ </BridgeForm>
156 </Grid>
147 </Grid>
157 </View>
148 </View>
158 );
149 );
src/views/Invite/InviteEmail.js+287−299
@@ -1,20 +1,19 @@
1−import React, { useCallback, useEffect, useMemo, useState } from 'react';
1+import React, { useCallback, useEffect, useRef, useState } from 'react';
2import cn from 'classnames';
2import cn from 'classnames';
3import * as ob from 'urbit-ob';
3import * as ob from 'urbit-ob';
4import * as azimuth from 'azimuth-js';
4import * as azimuth from 'azimuth-js';
5import {
5import {
6 Grid,
6 Grid,
7 Flex,
7 Flex,
8− Input,
9 IconButton,
8 IconButton,
10 HelpText,
9 HelpText,
11 Text,
10 Text,
12 ErrorText,
11 ErrorText,
13 AccessoryIcon,
12 AccessoryIcon,
14− useForm,
15} from 'indigo-react';
13} from 'indigo-react';
16import { uniq } from 'lodash';
14import { uniq } from 'lodash';
17import { fromWei, toWei } from 'web3-utils';
15import { fromWei, toWei } from 'web3-utils';
16+import { FieldArray } from 'react-final-form-arrays';
18
17
19import { usePointCursor } from 'store/pointCursor';
18import { usePointCursor } from 'store/pointCursor';
20import { useNetwork } from 'store/network';
19import { useNetwork } from 'store/network';
@@ -29,8 +28,6 @@ import {
29 hexify,
28 hexify,
30} from 'lib/txn';
29} from 'lib/txn';
31import * as tank from 'lib/tank';
30import * as tank from 'lib/tank';
32−import useArray from 'lib/useArray';
33−import { buildEmailInputConfig } from 'lib/useInputs';
34import { MIN_PLANET, GAS_LIMITS, DEFAULT_GAS_PRICE_GWEI } from 'lib/constants';
31import { MIN_PLANET, GAS_LIMITS, DEFAULT_GAS_PRICE_GWEI } from 'lib/constants';
35import * as need from 'lib/need';
32import * as need from 'lib/need';
36import * as wg from 'lib/walletgen';
33import * as wg from 'lib/walletgen';
@@ -40,6 +37,14 @@ import useMailer from 'lib/useMailer';
40
37
41import LoadableButton from 'components/LoadableButton';
38import LoadableButton from 'components/LoadableButton';
42import Highlighted from 'components/Highlighted';
39import Highlighted from 'components/Highlighted';
40+import BridgeForm from 'form/BridgeForm';
41+import { Field } from 'react-final-form';
42+import { EmailInput, buildEmailValidator } from 'form/Inputs';
43+import { FORM_ERROR } from 'final-form';
44+import SubmitButton from 'form/SubmitButton';
45+import FormError from 'form/FormError';
46+
47+const INITIAL_VALUES = { emails: [''] };
43
48
44const GAS_LIMIT = GAS_LIMITS.GIFT_PLANET;
49const GAS_LIMIT = GAS_LIMITS.GIFT_PLANET;
45const INVITE_COST = toWei(
50const INVITE_COST = toWei(
@@ -76,21 +81,14 @@ const buttonText = (status, count) => {
76 }
81 }
77};
82};
78
83
79−// world's simplest uid
80−let id = 0;
81−const buildInputConfig = (extra = {}) =>
82− buildEmailInputConfig({
83− name: `email-${id++}`,
84− placeholder: 'Email Address',
85− ...extra,
86− });
87−
88const buildAccessoryFor = (dones, errors) => name => {
84const buildAccessoryFor = (dones, errors) => name => {
89 if (dones[name]) return <AccessoryIcon.Success />;
85 if (dones[name]) return <AccessoryIcon.Success />;
90 if (errors[name]) return <AccessoryIcon.Failure />;
86 if (errors[name]) return <AccessoryIcon.Failure />;
91 return <AccessoryIcon.Pending />;
87 return <AccessoryIcon.Pending />;
92};
88};
93
89
90+const emailFormatValidator = buildEmailValidator();
91+
94// TODO: test with tank, successful txs
92// TODO: test with tank, successful txs
95export default function InviteEmail() {
93export default function InviteEmail() {
96 // TODO: resumption after error?
94 // TODO: resumption after error?
@@ -99,7 +97,9 @@ export default function InviteEmail() {
99 const { syncInvites, getInvites } = usePointCache();
97 const { syncInvites, getInvites } = usePointCache();
100 const { pointCursor } = usePointCursor();
98 const { pointCursor } = usePointCursor();
101 const point = need.point(pointCursor);
99 const point = need.point(pointCursor);
102− const { getHasReceived, syncHasReceivedForEmail, sendMail } = useMailer();
100+ const { getHasReceived, sendMail } = useMailer();
101+
102+ const cachedEmails = useRef([]);
103
103
104 const { availableInvites } = getInvites(point);
104 const { availableInvites } = getInvites(point);
105 const maxInvitesToSend = availableInvites.matchWith({
105 const maxInvitesToSend = availableInvites.matchWith({
@@ -107,15 +107,6 @@ export default function InviteEmail() {
107 Just: p => p.value,
107 Just: p => p.value,
108 });
108 });
109
109
110− // manage the array of input configs
111− const [
112− inputConfigs,
113− { append: appendInput, removeAt: removeInputAt },
114− ] = useArray(
115− [buildInputConfig({ label: 'Email Address', autoFocus: true })],
116− buildInputConfig
117− );
118−
119 // manage per-input state
110 // manage per-input state
120 const [hovered, setHovered] = useSetState();
111 const [hovered, setHovered] = useSetState();
121 const [invites, addInvite, clearInvites] = useSetState();
112 const [invites, addInvite, clearInvites] = useSetState();
@@ -135,30 +126,40 @@ export default function InviteEmail() {
135 const isFailed = status === STATUS.FAILURE;
126 const isFailed = status === STATUS.FAILURE;
136 const isDone = status === STATUS.SUCCESS;
127 const isDone = status === STATUS.SUCCESS;
137
128
138− // add disabled, error info to input configs
129+ const validate = useCallback(
139− const dynamicConfigs = useMemo(
130+ async values => {
140− () =>
131+ // compute the list of valid emails
141− inputConfigs.map(config => {
132+ const emails = values.emails.filter(d => !!d);
142− config.disabled = !canInput;
133+ if (uniq(emails).length !== emails.length) {
143− const hasReceivedError = getHasReceived(config.name).matchWith({
134+ return { [FORM_ERROR]: 'Duplicate email.' };
144− Nothing: () => null, // loading
135+ }
145− Just: p => p.value && HAS_RECEIVED_TEXT,
146− });
147− config.error = hasReceivedError || errors[config.name];
148− return config;
149− }),
150− [inputConfigs, errors, canInput, getHasReceived]
151− );
152
136
153− // construct the state of the set of inputs we're rendering below
137+ let errors = {};
154− const { inputs, pass } = useForm(dynamicConfigs);
138+ for (let i = 0; i < values.emails.length; i++) {
155− // did all of the inputs pass inspection (and there are no general errors)?
139+ const name = `emails[${i}]`;
156− const allPass = pass && !generalError;
140+ const email = values.emails[i];
157− // the form is submittable iff passing and input is allowed
141+
158− const canGenerate = allPass && canInput;
142+ // check individual email validity
159− // additional inputs can be added iff input is allowed and we have enough
143+ // TODO: validate each email individually using similar pattern from form/Inputs
160− // invites to send
144+ // and then reutrn that
161− const canAddInvite = canInput && inputConfigs.length < maxInvitesToSend;
145+ const error = await emailFormatValidator(email);
146+ if (error) {
147+ errors[name] = error;
148+ continue;
149+ }
150+
151+ const hasReceived = await getHasReceived(email);
152+ if (hasReceived) {
153+ errors[name] = HAS_RECEIVED_TEXT;
154+ continue;
155+ }
156+ }
157+
158+ console.log(errors);
159+ return errors;
160+ },
161+ [getHasReceived]
162+ );
162
163
163 // progress is [0, .length] of invites or receipts, as we're generating them
164 // progress is [0, .length] of invites or receipts, as we're generating them
164 const progress = isGenerating
165 const progress = isGenerating
@@ -176,125 +177,102 @@ export default function InviteEmail() {
176 return () => null;
177 return () => null;
177 })();
178 })();
178
179
179− const generateInvites = useCallback(async () => {
180+ const generateInvites = useCallback(
181+ async values => {
182+ debugger;
180− const _contracts = contracts.getOrElse(null);
183+ const _contracts = contracts.getOrElse(null);
181− const _web3 = web3.getOrElse(null);
184+ const _web3 = web3.getOrElse(null);
182− const _wallet = wallet.getOrElse(null);
185+ const _wallet = wallet.getOrElse(null);
183− if (!_contracts || !_web3 || !_wallet) {
186+ if (!_contracts || !_web3 || !_wallet) {
184− // not using need because we want a custom error
187+ // not using need because we want a custom error
185− throw new Error('Internal Error: Missing Contracts/Web3/Wallet');
188+ throw new Error('Internal Error: Missing Contracts/Web3/Wallet');
186− }
187−
188− //TODO want to do this on-input, but that gets weird. see #188
189− let knowAll = true;
190− let alreadyReceived = [];
191− await Promise.all(
192− inputs.map(async input => {
193− const email = input.data;
194− getHasReceived(email).matchWith({
195− Nothing: () => {
196− knowAll = false;
197− },
198− Just: p => {
199− if (p.value) alreadyReceived.push(email);
200− },
201− });
202− })
203− );
204− if (!knowAll) {
205− throw new Error('No word yet from email service...');
206− }
207− if (alreadyReceived.length > 0) {
208− throw new Error(
209− 'The following recipients already own a point: ' +
210− alreadyReceived.join(', ')
211− );
212− }
189+ }
213−
214− const nonce = await _web3.eth.getTransactionCount(_wallet.address);
215− const chainId = await _web3.eth.net.getId();
216− const planets = await azimuth.delegatedSending.getPlanetsToSend(
217− _contracts,
218− point,
219− inputs.length
220− );
221−
222− // account for the race condition where invites got used up while we were
223− // composing our target list
224− if (planets.length < inputs.length) {
225− // resync invites to the cache, since they're out of date
226− syncInvites(point);
227
190
228− throw new Error(
191+ const nonce = await _web3.eth.getTransactionCount(_wallet.address);
229− `Can currently only send ${planets.length} invites. ` +
192+ const chainId = await _web3.eth.net.getId();
230− `Please remove invites until you are within the limit.`
193+ const planets = await azimuth.delegatedSending.getPlanetsToSend(
194+ _contracts,
195+ point,
196+ values.emails.length
231 );
197 );
232− }
233
198
234− clearInvites();
199+ // account for the race condition where invites got used up while we were
235− // NB(shrugs) - must be processed in serial because main thread, etc
200+ // composing our target list
236− let errorCount = 0;
201+ if (planets.length < values.emails.length) {
237− for (let i = 0; i < inputs.length; i++) {
202+ // resync invites to the cache, since they're out of date
238− const input = inputs[i];
203+ syncInvites(point);
239− try {
204+
240− const { data: email, name } = inputs[i];
205+ return {
206+ [FORM_ERROR]:
207+ `Can currently only send ${planets.length} invites. ` +
208+ `Please remove invites until you are within the limit.`,
209+ };
210+ }
241
211
212+ clearInvites();
213+ // NB(shrugs) - must be processed in serial because main thread, etc
214+ let errorCount = 0;
215+ for (let i = 0; i < values.emails.length; i++) {
216+ try {
217+ const email = values.emails[i];
218+
242− const { ticket, owner } = await wg.generateTemporaryTicketAndWallet(
219+ const { ticket, owner } = await wg.generateTemporaryTicketAndWallet(
243− MIN_PLANET
220+ MIN_PLANET
244− // we're always giving planets, so generate a ticket of the correct size
221+ // we're always giving planets, so generate a ticket of the correct size
245− );
222+ );
223+
224+ const inviteTx = azimuth.delegatedSending.sendPoint(
225+ _contracts,
226+ point,
227+ planets[i],
228+ owner.keys.address
229+ );
230+ const signedTx = await signTransaction({
231+ wallet: _wallet,
232+ walletType,
233+ walletHdPath,
234+ networkType,
235+ // TODO: ^ make a useTransactionSigner to encapsulate this logic
236+ txn: inviteTx,
237+ gasPrice: DEFAULT_GAS_PRICE_GWEI.toString(),
238+ gasLimit: GAS_LIMIT.toString(),
239+ nonce: nonce + i,
240+ chainId,
241+ });
242+ const rawTx = hexify(signedTx.serialize());
243+
244+ addInvite({ [email]: { email, ticket, signedTx, rawTx } });
245+ } catch (error) {
246+ console.error(error);
247+ errorCount++;
248+ return {
249+ [FORM_ERROR]: `Wallet Error: unable to generate invite wallets.`,
250+ };
251+ }
252+ }
246
253
247− const inviteTx = azimuth.delegatedSending.sendPoint(
254+ if (errorCount > 0) {
248− _contracts,
255+ throw new Error(
249− point,
256+ `There ${pluralize(errorCount, 'was', 'were')} ${pluralize(
250− planets[i],
257+ errorCount,
251− owner.keys.address
258+ 'error'
259+ )} while generating wallets.`
252 );
260 );
253− const signedTx = await signTransaction({
254− wallet: _wallet,
255− walletType,
256− walletHdPath,
257− networkType,
258− // TODO: ^ make a useTransactionSigner to encapsulate this logic
259− txn: inviteTx,
260− gasPrice: DEFAULT_GAS_PRICE_GWEI.toString(),
261− gasLimit: GAS_LIMIT.toString(),
262− nonce: nonce + i,
263− chainId,
264− });
265− const rawTx = hexify(signedTx.serialize());
266−
267− addInvite({ [name]: { email, ticket, signedTx, rawTx } });
268− } catch (error) {
269− console.error(error);
270− errorCount++;
271− addError({ [input.name]: `Wallet Error: ${input.email}` });
272 }
261 }
273− }
262+ },
274−
275− if (errorCount > 0) {
276− throw new Error(
277− `There ${pluralize(errorCount, 'was', 'were')} ${pluralize(
278− errorCount,
279− 'error'
280− )} while generating wallets.`
281− );
282− }
283− }, [
263+ [
284− contracts,
264+ contracts,
285− web3,
265+ web3,
286− wallet,
266+ wallet,
287− inputs,
288− point,
267+ point,
289− clearInvites,
268+ clearInvites,
290− getHasReceived,
291− addError,
292− syncInvites,
269+ syncInvites,
293− walletType,
270+ walletType,
294− walletHdPath,
271+ walletHdPath,
295− networkType,
272+ networkType,
296− addInvite,
273+ addInvite,
274+ ]
297− ]);
275+ );
298
276
299 const sendInvites = useCallback(async () => {
277 const sendInvites = useCallback(async () => {
300 const _web3 = web3.getOrElse(null);
278 const _web3 = web3.getOrElse(null);
@@ -308,8 +286,8 @@ export default function InviteEmail() {
308 _web3,
286 _web3,
309 point,
287 point,
310 _wallet.address,
288 _wallet.address,
311− INVITE_COST * inputs.length,
289+ INVITE_COST * cachedEmails.current.length,
312− inputs.map(input => invites[input.name].rawTx),
290+ cachedEmails.current.map(email => invites[email].rawTx),
313 (address, minBalance, balance) =>
291 (address, minBalance, balance) =>
314 setNeedFunds({ address, minBalance, balance }),
292 setNeedFunds({ address, minBalance, balance }),
315 () => setNeedFunds(undefined)
293 () => setNeedFunds(undefined)
@@ -320,8 +298,8 @@ export default function InviteEmail() {
320
298
321 let unsentInvites = [];
299 let unsentInvites = [];
322 let orphanedInvites = [];
300 let orphanedInvites = [];
323− const txAndMailings = inputs.map(async input => {
301+ const txAndMailings = cachedEmails.current.map(async email => {
324− const invite = invites[input.name];
302+ const invite = invites[email];
325 try {
303 try {
326 const txHash = await sendSignedTransaction(
304 const txHash = await sendSignedTransaction(
327 _web3,
305 _web3,
@@ -339,19 +317,18 @@ export default function InviteEmail() {
339 }
317 }
340
318
341 try {
319 try {
342− const success = await sendMail(
320+ await sendMail(
343 invite.email,
321 invite.email,
344 invite.ticket,
322 invite.ticket,
345 ob.patp(point),
323 ob.patp(point),
346 invite.rawTx
324 invite.rawTx
347 );
325 );
348− if (!success) throw new Error('Failed to send mail');
349 } catch (error) {
326 } catch (error) {
350 console.error(error);
327 console.error(error);
351 orphanedInvites.push(invite);
328 orphanedInvites.push(invite);
352 }
329 }
353
330
354− addReceipt({ [input.name]: true });
331+ addReceipt({ [email]: true });
355 });
332 });
356
333
357 await Promise.all(txAndMailings);
334 await Promise.all(txAndMailings);
@@ -375,47 +352,30 @@ export default function InviteEmail() {
375 if (errorString !== '') {
352 if (errorString !== '') {
376 throw new Error(errorString);
353 throw new Error(errorString);
377 }
354 }
378− }, [
355+ }, [web3, addReceipt, clearReceipts, invites, point, wallet, sendMail]);
379− web3,
356+
380− inputs,
357+ const onSubmit = useCallback(
381− addReceipt,
358+ async values => {
382− clearReceipts,
359+ setStatus(STATUS.GENERATING);
383− invites,
360+ await generateInvites(values);
384− point,
361+ cachedEmails.current = values.emails;
385− wallet,
362+ setStatus(STATUS.CAN_SEND);
386− sendMail,
363+ },
364+ [generateInvites]
387− ]);
365+ );
388−
366+
389− const onClick = useCallback(async () => {
367+ const doSend = useCallback(async () => {
390 setGeneralError(null);
368 setGeneralError(null);
391 try {
369 try {
392− if (canGenerate) {
393− setStatus(STATUS.GENERATING);
394− await generateInvites();
395− setStatus(STATUS.CAN_SEND);
396− } else if (canSend) {
397− setStatus(STATUS.SENDING);
370+ setStatus(STATUS.SENDING);
398− await sendInvites();
371+ await sendInvites();
399− setStatus(STATUS.SUCCESS);
372+ setStatus(STATUS.SUCCESS);
400− }
401 } catch (error) {
373 } catch (error) {
402 console.error(error);
374 console.error(error);
403 setGeneralError(error);
375 setGeneralError(error);
404 setStatus(STATUS.FAILURE);
376 setStatus(STATUS.FAILURE);
405 }
377 }
406− }, [canGenerate, canSend, generateInvites, sendInvites]);
378+ }, [sendInvites]);
407−
408− // when inputs update, check to see if any of them are duplicates
409− useEffect(() => {
410− // compute the list of valid emails
411− const emails = inputs.map(i => i.data).filter(d => !!d);
412− if (uniq(emails).length !== emails.length) {
413− setGeneralError(new Error(`Duplicate email.`));
414− } else {
415− setGeneralError(null);
416− setStatus(STATUS.INPUT);
417− }
418− }, [inputs]);
419
379
420 // when we transition to done, sync invites because we just sent some
380 // when we transition to done, sync invites because we just sent some
421 // and therefore know that state has changed
381 // and therefore know that state has changed
@@ -425,109 +385,137 @@ export default function InviteEmail() {
425 }
385 }
426 }, [isDone, syncInvites, point]);
386 }, [isDone, syncInvites, point]);
427
387
428− useEffect(() => {
429− for (const input of inputs) {
430− if (input.pass) {
431− syncHasReceivedForEmail(input.data);
432− }
433− }
434− }, [inputs, syncHasReceivedForEmail]);
435−
436 return (
388 return (
437 <Grid gap={3}>
389 <Grid gap={3}>
390+ <BridgeForm
391+ validate={validate}
392+ onSubmit={onSubmit}
393+ initialValues={INITIAL_VALUES}>
394+ {({ handleSubmit, valid, values }) => (
395+ <FieldArray name="emails">
396+ {({ fields }) => (
397+ <>
438− <Grid.Item full as={Flex} justify="end">
398+ <Grid.Item full as={Flex} justify="end">
439− {/* use hidden class instead of removing component from dom */}
399+ {/* use hidden class instead of removing component from dom */}
440− {/* in order to avoid janky reflow */}
400+ {/* in order to avoid janky reflow */}
441− <IconButton
401+ <IconButton
442− onClick={() => appendInput()}
402+ onClick={() => fields.push('')}
443− disabled={!canAddInvite}
403+ disabled={!canInput || fields.length >= maxInvitesToSend}
444− className={cn({ hidden: isDone })}
404+ className={cn({ hidden: isDone })}
445− solid>
405+ solid>
446− +
406+ +
447− </IconButton>
407+ </IconButton>
448− </Grid.Item>
408+ </Grid.Item>
449−
409+
450− {isDone && (
410+ {isDone && (
451− <>
411+ <>
452− <Grid.Item as={Text} className="f5" full>
412+ <Grid.Item as={Text} className="f5" full>
413+ <Highlighted>
453− <Highlighted>{pluralize(inputs.length, 'invite')}</Highlighted>{' '}
414+ {pluralize(fields.length, 'invite')}
415+ </Highlighted>{' '}
454− {pluralize(inputs.length, 'has', 'have')} been successfully sent
416+ {pluralize(fields.length, 'has', 'have')} been
417+ successfully sent
455− </Grid.Item>
418+ </Grid.Item>
456− {inputs.map(input => (
419+
420+ {fields.map(name => (
457− <Grid.Item as={HelpText} key={input.name} full>
421+ <Grid.Item as={HelpText} key={name} full>
458− {invites[input.name].email}
422+ <Field name={name}>{({ value }) => value}</Field>
459− </Grid.Item>
423+ </Grid.Item>
460− ))}
424+ ))}
461− </>
425+ </>
462− )}
426+ )}
463−
427+
464− {!isDone && (
428+ {!isDone && (
465− <>
429+ <>
466− {/* email inputs */}
430+ {fields.map((name, i) => {
467− {inputs.map((input, i) => {
468− const isFirst = i === 0;
431+ const isFirst = i === 0;
469− return (
432+ return (
470− <Grid.Item
433+ <Grid.Item
471− key={input.name}
434+ key={name}
435+ full
472− as={Grid}
436+ as={Grid}
473− gap={3}
437+ gap={3}
474− onMouseOver={() => setHovered({ [input.name]: true })}
438+ onMouseOver={() => setHovered({ [name]: true })}
475− onMouseLeave={() => setHovered({ [input.name]: false })}
439+ onMouseLeave={() => setHovered({ [name]: false })}>
476− full>
477− <Grid.Item
440+ <Grid.Item
478− as={Input}
479− cols={[1, 11]}
441+ cols={[1, 11]}
480− {...input}
442+ as={EmailInput}
443+ name={name}
444+ label={isFirst ? 'Email Address' : undefined}
481− accessory={accessoryFor(input.name)}
445+ accessory={accessoryFor(name)}
482− />
446+ />
447+ <Field name={name}>
448+ {({ meta: { active } }) => {
449+ return (
450+ <>
483− {!isFirst &&
451+ {!isFirst &&
484− (input.focused || hovered[input.name]) &&
452+ (active || hovered[name]) &&
453+ canInput && (
454+ <Grid.Item
455+ cols={[11, 13]}
456+ justifySelf="end"
457+ alignSelf="center">
458+ <IconButton
459+ onClick={() => fields.remove(i)}
460+ solid
461+ secondary>
462+ -
463+ </IconButton>
464+ </Grid.Item>
465+ )}
466+ </>
467+ );
468+ }}
469+ </Field>
470+ </Grid.Item>
471+ );
472+ })}
473+
485− canInput && (
474+ {canInput && (
475+ <Grid.Item
476+ full
477+ as={SubmitButton}
478+ handleSubmit={handleSubmit}>
479+ {buttonText(status, fields.length)}
480+ </Grid.Item>
481+ )}
482+
486 <Grid.Item
483 <Grid.Item
487− cols={[11, 13]}
484+ full
488− justifySelf="end"
485+ as={LoadableButton}
489− alignSelf="center">
486+ disabled={!(canInput || canSend || valid)}
490− <IconButton
487+ accessory={`${visualProgress}/${fields.length}`}
491− onClick={() => removeInputAt(i)}
488+ onClick={doSend}
489+ success={canSend}
492− solid
490+ solid>
493− secondary>
491+ {buttonText(status, fields.length)}
494− -
495− </IconButton>
496 </Grid.Item>
492 </Grid.Item>
497− )}
493+
498− </Grid.Item>
499− );
500− })}
501−
502− <Grid.Item
503− full
504− as={LoadableButton}
505− disabled={!canGenerate && !canSend}
506− accessory={`${visualProgress}/${inputs.length}`}
507− onClick={onClick}
508− success={canSend}
509− solid>
510− {buttonText(status, inputs.length)}
511− </Grid.Item>
512−
513− {needFunds && (
494+ {needFunds && (
514− <Grid.Item full>
495+ <Grid.Item full>
515− <Highlighted warning>
496+ <Highlighted warning>
516− Your ownership address {needFunds.address} needs at least{' '}
497+ Your ownership address {needFunds.address} needs at
517− {fromWei(needFunds.minBalance)} ETH and currently has{' '}
498+ least {fromWei(needFunds.minBalance)} ETH and
518− {fromWei(needFunds.balance)} ETH. Waiting until the account has
499+ currently has {fromWei(needFunds.balance)} ETH.
519− enough funds.
500+ Waiting until the account has enough funds.
520− </Highlighted>
501+ </Highlighted>
521− </Grid.Item>
502+ </Grid.Item>
522− )}
503+ )}
523−
504+
505+ <Grid.Item full as={FormError} />
506+
524− {generalError && (
507+ {generalError && (
525− <Grid.Item full>
508+ <Grid.Item full>
526− <ErrorText>{generalError.message.toString()}</ErrorText>
509+ <ErrorText>{generalError.message.toString()}</ErrorText>
527− </Grid.Item>
510+ </Grid.Item>
511+ )}
512+ </>
528− )}
513+ )}
529− </>
514+ </>
530− )}
515+ )}
516+ </FieldArray>
517+ )}
518+ </BridgeForm>
531 </Grid>
519 </Grid>
532 );
520 );
533}
521}
src/views/IssueChild.js+91−84
@@ -1,7 +1,6 @@
1−import React, { useCallback, useEffect, useMemo, useState } from 'react';
1+import React, { useCallback, useMemo, useState } from 'react';
2−import { Nothing, Just } from 'folktale/maybe';
3import cn from 'classnames';
2import cn from 'classnames';
4−import { Grid, Text, Input } from 'indigo-react';
3+import { Grid, Text } from 'indigo-react';
5import * as azimuth from 'azimuth-js';
4import * as azimuth from 'azimuth-js';
6import * as ob from 'urbit-ob';
5import * as ob from 'urbit-ob';
7
6
@@ -10,18 +9,25 @@ import { usePointCache } from 'store/pointCache';
10import { usePointCursor } from 'store/pointCursor';
9import { usePointCursor } from 'store/pointCursor';
11
10
12import * as need from 'lib/need';
11import * as need from 'lib/need';
13−import { useAddressInput, usePointInput } from 'lib/useInputs';
14import useEthereumTransaction from 'lib/useEthereumTransaction';
12import useEthereumTransaction from 'lib/useEthereumTransaction';
15import { GAS_LIMITS } from 'lib/constants';
13import { GAS_LIMITS } from 'lib/constants';
16import patp2dec from 'lib/patp2dec';
14import patp2dec from 'lib/patp2dec';
17−import useLifecycle from 'lib/useLifecycle';
18−import { validateNameInNumberSet } from 'lib/validators';
19import { getSpawnCandidate } from 'lib/child';
15import { getSpawnCandidate } from 'lib/child';
20import { useLocalRouter } from 'lib/LocalRouter';
16import { useLocalRouter } from 'lib/LocalRouter';
17+import useConstant from 'lib/useConstant';
21
18
22import ViewHeader from 'components/ViewHeader';
19import ViewHeader from 'components/ViewHeader';
23import InlineEthereumTransaction from 'components/InlineEthereumTransaction';
20import InlineEthereumTransaction from 'components/InlineEthereumTransaction';
24import View from 'components/View';
21import View from 'components/View';
22+import {
23+ composeValidator,
24+ buildPointValidator,
25+ PointInput,
26+ AddressInput,
27+ buildAddressValidator,
28+} from 'form/Inputs';
29+import BridgeForm from 'form/BridgeForm';
30+import FormError from 'form/FormError';
25
31
26function useIssueChild() {
32function useIssueChild() {
27 const { contracts } = useNetwork();
33 const { contracts } = useNetwork();
@@ -55,7 +61,11 @@ export default function IssueChild() {
55 const _contracts = need.contracts(contracts);
61 const _contracts = need.contracts(contracts);
56 const _point = parseInt(need.point(pointCursor), 10);
62 const _point = parseInt(need.point(pointCursor), 10);
57
63
58− const [availablePoints, setAvailablePoints] = useState(Nothing());
64+ const availablePointsPromise = useConstant(() =>
65+ azimuth.azimuth
66+ .getUnspawnedChildren(_contracts, _point)
67+ .then(points => new Set(points))
68+ );
59
69
60 const candidates = useMemo(() => {
70 const candidates = useMemo(() => {
61 const getCandidate = () => ob.patp(getSpawnCandidate(_point));
71 const getCandidate = () => ob.patp(getSpawnCandidate(_point));
@@ -72,62 +82,37 @@ export default function IssueChild() {
72 bind,
82 bind,
73 } = useIssueChild();
83 } = useIssueChild();
74
84
75− const validators = useMemo(
85+ const validatePoint = useCallback(
76− () => [
86+ async name => {
77− validateNameInNumberSet(
87+ const point = patp2dec(name);
78− availablePoints.getOrElse(new Set()),
88+ const hasPoint = (await availablePointsPromise).has(point);
89+
90+ if (!hasPoint) {
79− 'This point cannot be spawned.'
91+ return 'This point cannot be spawned.';
80− ),
81− ],
82− [availablePoints]
83− );
84− const [
85− pointNameInput,
86− { pass: validPointName, value: pointName },
87− // ^ we use value: here so our effect runs onChange
88− ] = usePointInput({
89− name: 'point',
90− disabled: inputsLocked,
91− autoFocus: true,
92− validators,
93− error: availablePoints.matchWith({
94− Nothing: () => 'Loading availability...',
95− Just: () => undefined,
96− }),
97− });
98−
99− const [ownerInput, { pass: validOwner, data: owner }] = useAddressInput({
100− name: 'owner',
101− label: `Ethereum Address`,
102− disabled: inputsLocked,
103− });
104−
105− useEffect(() => {
106− if (validPointName && validOwner) {
107− construct(patp2dec(pointName), owner);
108− } else {
109− unconstruct();
110− }
111− }, [owner, construct, unconstruct, validPointName, validOwner, pointName]);
112−
113− useLifecycle(() => {
114− let mounted = true;
115−
116− (async () => {
117− const availablePoints = await azimuth.azimuth.getUnspawnedChildren(
118− _contracts,
119− _point
120− );
121−
122− if (!mounted) {
123− return;
124 }
92 }
93+ },
94+ [availablePointsPromise]
95+ );
125
96
126− setAvailablePoints(Just(new Set(availablePoints)));
97+ const validate = useMemo(
98+ () =>
99+ composeValidator({
100+ point: buildPointValidator(4, validatePoint),
101+ owner: buildAddressValidator(),
102+ }),
103+ [validatePoint]
127− })();
104+ );
128
105
129− return () => (mounted = false);
106+ const onValues = useCallback(
107+ ({ valid, values }) => {
108+ if (valid) {
109+ construct(patp2dec(values.point), values.owner);
110+ } else {
111+ unconstruct();
112+ }
113+ },
114+ [construct, unconstruct]
130− });
115+ );
131
116
132 return (
117 return (
133 <View pop={pop} inset>
118 <View pop={pop} inset>
@@ -143,30 +128,52 @@ export default function IssueChild() {
143 </Grid.Item>
128 </Grid.Item>
144 )}
129 )}
145
130
131+ <BridgeForm validate={validate} onSubmit={() => {}} onValues={onValues}>
132+ {({ handleSubmit, values }) => (
133+ <>
146− {completed && (
134+ {completed && (
147− <Grid.Item
135+ <Grid.Item
148− full
136+ full
149− as={Text}
137+ as={Text}
150− className={cn('f5', {
138+ className={cn('f5', {
151− green3: completed,
139+ green3: completed,
152− })}>
140+ })}>
153− {pointName} has been spawned and can be claimed by {owner}.
141+ {values.point} has been spawned and can be claimed by{' '}
142+ {values.owner}.
154− </Grid.Item>
143+ </Grid.Item>
155− )}
144+ )}
156−
145+
157− {!completed && (
146+ {!completed && (
158− <>
147+ <>
148+ <Grid.Item
149+ full
150+ as={PointInput}
151+ name="point"
152+ disabled={inputsLocked}
159− <Grid.Item full as={Input} {...pointNameInput} className="mt4" />
153+ className="mt4"
154+ />
155+ <Grid.Item
156+ full
157+ as={AddressInput}
160− <Grid.Item full as={Input} {...ownerInput} className="mb4" />
158+ className="mb4"
159+ name="owner"
160+ label="Ethereum Address"
161+ disabled={inputsLocked}
162+ />
161− </>
163+ </>
162− )}
164+ )}
163−
165+
164− <Grid.Item
166+ <Grid.Item full as={FormError} />
167+
168+ <Grid.Item
165− full
169+ full
166− as={InlineEthereumTransaction}
170+ as={InlineEthereumTransaction}
167− {...bind}
171+ {...bind}
168− onReturn={() => pop()}
172+ onReturn={() => pop()}
169− />
173+ />
174+ </>
175+ )}
176+ </BridgeForm>
170 </Grid>
177 </Grid>
171 </View>
178 </View>
172 );
179 );
src/views/Party/PartySetPoolSize.js+79−49
@@ -1,6 +1,6 @@
1−import React, { useCallback, useEffect } from 'react';
1+import React, { useCallback, useMemo } from 'react';
2import cn from 'classnames';
2import cn from 'classnames';
3−import { Grid, Text, Input } from 'indigo-react';
3+import { Grid, Text } from 'indigo-react';
4import * as azimuth from 'azimuth-js';
4import * as azimuth from 'azimuth-js';
5
5
6import { useNetwork } from 'store/network';
6import { useNetwork } from 'store/network';
@@ -8,7 +8,6 @@ import { usePointCursor } from 'store/pointCursor';
8import { usePointCache } from 'store/pointCache';
8import { usePointCache } from 'store/pointCache';
9
9
10import * as need from 'lib/need';
10import * as need from 'lib/need';
11−import { usePointInput, useNumberInput } from 'lib/useInputs';
12import useEthereumTransaction from 'lib/useEthereumTransaction';
11import useEthereumTransaction from 'lib/useEthereumTransaction';
13import { GAS_LIMITS } from 'lib/constants';
12import { GAS_LIMITS } from 'lib/constants';
14import { useLocalRouter } from 'lib/LocalRouter';
13import { useLocalRouter } from 'lib/LocalRouter';
@@ -19,6 +18,15 @@ import patp2dec from 'lib/patp2dec';
19import View from 'components/View';
18import View from 'components/View';
20import useCurrentPermissions from 'lib/useCurrentPermissions';
19import useCurrentPermissions from 'lib/useCurrentPermissions';
21import WarningBox from 'components/WarningBox';
20import WarningBox from 'components/WarningBox';
21+import BridgeForm from 'form/BridgeForm';
22+import {
23+ PointInput,
24+ composeValidator,
25+ NumberInput,
26+ buildNumberValidator,
27+ buildPointValidator,
28+} from 'form/Inputs';
29+import FormError from 'form/FormError';
22
30
23function useSetPoolSize() {
31function useSetPoolSize() {
24 const { contracts } = useNetwork();
32 const { contracts } = useNetwork();
@@ -60,31 +68,25 @@ export default function PartySetPoolSize() {
60 bind,
68 bind,
61 } = useSetPoolSize();
69 } = useSetPoolSize();
62
70
63− const [poolOwnerInput, { pass: validPoint, data: poolOwner }] = usePointInput(
71+ const validate = useMemo(
64− {
72+ () =>
65− name: 'point',
73+ composeValidator({
66− label: 'Point',
74+ poolOwner: buildPointValidator(),
67− disabled: inputsLocked,
75+ poolSize: buildNumberValidator(0),
68− }
76+ }),
77+ []
69 );
78 );
70
79
71− const [
80+ const onValues = useCallback(
72− poolSizeInput,
81+ ({ valid, values }) => {
73− { pass: validPoolSize, data: poolSize },
82+ if (valid) {
74− ] = useNumberInput({
75− name: 'poolsize',
76− label: 'Pool Size',
77− initialValue: 5,
78− disabled: inputsLocked,
79− });
80−
81− useEffect(() => {
82− if (validPoint && validPoolSize) {
83− construct(patp2dec(poolOwner), poolSize);
83+ construct(patp2dec(values.poolOwner), values.poolSize);
84− } else {
84+ } else {
85− unconstruct();
85+ unconstruct();
86− }
86+ }
87+ },
87− }, [poolOwner, construct, validPoint, validPoolSize, poolSize, unconstruct]);
88+ [construct, unconstruct]
89+ );
88
90
89 return (
91 return (
90 <View pop={pop} inset>
92 <View pop={pop} inset>
@@ -92,6 +94,7 @@ export default function PartySetPoolSize() {
92 <Grid.Item full as={ViewHeader}>
94 <Grid.Item full as={ViewHeader}>
93 Set Pool Size
95 Set Pool Size
94 </Grid.Item>
96 </Grid.Item>
97+
95 {!spawnIsDelegatedSending && (
98 {!spawnIsDelegatedSending && (
96 <Grid.Item full as={WarningBox} className="mb4">
99 <Grid.Item full as={WarningBox} className="mb4">
97 The spawn proxy must be set to{' '}
100 The spawn proxy must be set to{' '}
@@ -101,30 +104,57 @@ export default function PartySetPoolSize() {
101 for invitations to be available.
104 for invitations to be available.
102 </Grid.Item>
105 </Grid.Item>
103 )}
106 )}
104− <Grid.Item
105− full
106− as={Text}
107− className={cn('f5', {
108− green3: completed,
109− })}>
110− {completed
111− ? `${poolSize} invites have been allocated to ${poolOwner}`
112− : `Allocate invites to a child point.`}
113− </Grid.Item>
114−
115− {!completed && (
116− <>
117− <Grid.Item full as={Input} {...poolOwnerInput} className="mt4" />
118− <Grid.Item full as={Input} {...poolSizeInput} className="mb4" />
119− </>
120− )}
121
107
108+ <BridgeForm
109+ validate={validate}
110+ onSubmit={() => {}}
111+ onValues={onValues}
112+ initialValues={{ poolSize: 5 }}>
113+ {({ handleSubmit, values }) => (
114+ <>
122− <Grid.Item
115+ <Grid.Item
123− full
116+ full
117+ as={Text}
118+ className={cn('f5', {
119+ green3: completed,
120+ })}>
121+ {completed
122+ ? `${values.poolSize} invites have been allocated to ${values.poolOwner}`
123+ : `Allocate invites to a child point.`}
124+ </Grid.Item>
125+
126+ {!completed && (
127+ <>
128+ <Grid.Item
129+ full
130+ as={PointInput}
131+ className="mt4"
132+ name="poolOwner"
133+ label="Point"
134+ disabled={inputsLocked}
135+ />
136+ <Grid.Item
137+ full
138+ as={NumberInput}
139+ className="mb4"
140+ name="poolSize"
141+ label="Pool Size"
142+ disabled={inputsLocked}
143+ />
144+ </>
145+ )}
146+
147+ <Grid.Item full as={FormError} />
148+
149+ <Grid.Item
150+ full
124− as={InlineEthereumTransaction}
151+ as={InlineEthereumTransaction}
125− {...bind}
152+ {...bind}
126− onReturn={() => pop()}
153+ onReturn={() => pop()}
127− />
154+ />
155+ </>
156+ )}
157+ </BridgeForm>
128 </Grid>
158 </Grid>
129 </View>
159 </View>
130 );
160 );