on master help sign in
feat: tidy up InviteEmails.js and cover a few error edge cases
b019e78Matt Condon (shrugs)
src/form/FormError.js+1−1
@@ -14,7 +14,7 @@ export default function FormError(props) {
14 subscription: {
14 subscription: {
15 submitError: true,
15 submitError: true,
16 submitErrors: true,
16 submitErrors: true,
17− errors: true,
17+ error: true,
18 dirtySinceLastSubmit: true,
18 dirtySinceLastSubmit: true,
19 },
19 },
20 });
20 });
src/form/SubmitButton.js+4−1
@@ -29,7 +29,10 @@ export default function SubmitButton({
29
29
30 // can submit if:
30 // can submit if:
31 // 1) is valid
31 // 1) is valid
32+ // OR has submit errors
33+ // AND
34+ // a) is dirty
32− // OR has submit errors AND is dirty or only has a warning (double conf)
35+ // b) OR only has a warning (double conf)
33 // 2) AND is not validating
36 // 2) AND is not validating
34 // 3) AND has no validation errors
37 // 3) AND has no validation errors
35 // 4) AND is not actively submitting
38 // 4) AND is not actively submitting
src/form/validators.js+16−5
@@ -17,7 +17,7 @@ import {
17import isPromise from 'lib/isPromise';
17import isPromise from 'lib/isPromise';
18
18
19// iterate over validators, exiting early if there's an error
19// iterate over validators, exiting early if there's an error
20−const buildValidator = (validators = []) => value => {
20+const buildValidator = (validators = [], validate) => value => {
21 for (const validator of validators) {
21 for (const validator of validators) {
22 try {
22 try {
23 const error = validator(value);
23 const error = validator(value);
@@ -29,11 +29,22 @@ const buildValidator = (validators = []) => value => {
29 return error.message;
29 return error.message;
30 }
30 }
31 }
31 }
32+
33+ if (validate) {
34+ // the final validate function can optionally return a promise
35+ return validate(value);
36+ }
32};
37};
33
38
34// maps a validator across an array of values
39// maps a validator across an array of values
35−export const buildArrayValidator = validator => async values =>
40+export const buildArrayValidator = validator => values => {
36− await Promise.all(values.map(validator));
41+ const errorsOrPromises = values.map(validator);
42+ if (some(errorsOrPromises, isPromise)) {
43+ return Promise.all(errorsOrPromises);
44+ }
45+
46+ return errorsOrPromises;
47+};
37
48
38// error object has errors if some of its fields are
49// error object has errors if some of its fields are
39// 1) an array with any defined values
50// 1) an array with any defined values
@@ -78,8 +89,8 @@ export const buildAddressValidator = () =>
78 ]);
89 ]);
79export const buildNumberValidator = (min = 0) =>
90export const buildNumberValidator = (min = 0) =>
80 buildValidator([validateGreaterThan(min)]);
91 buildValidator([validateGreaterThan(min)]);
81−export const buildEmailValidator = (validators = []) =>
92+export const buildEmailValidator = validate =>
82− buildValidator([validateNotEmpty, validateEmail, ...validators]);
93+ buildValidator([validateNotEmpty, validateEmail], validate);
83
94
84// the form validator is the composition of all of the field validators
95// the form validator is the composition of all of the field validators
85// plus an additional form validator function
96// plus an additional form validator function
src/indigo-react/components/Input.js+16−5
@@ -26,7 +26,16 @@ export default function Input({
26}) {
26}) {
27 const {
27 const {
28 input,
28 input,
29+ meta: {
30+ active,
31+ error,
32+ submitError,
33+ dirtySinceLastSubmit,
34+ submitting,
29− meta: { active, error, submitting, submitSucceeded, touched, valid },
35+ submitSucceeded,
36+ touched,
37+ valid,
38+ },
30 } = useField(name, config);
39 } = useField(name, config);
31
40
32 disabled = disabled || submitting || submitSucceeded;
41 disabled = disabled || submitting || submitSucceeded;
@@ -41,7 +50,9 @@ export default function Input({
41 // [disabled, valid] // eslint-disable-line react-hooks/exhaustive-deps
50 // [disabled, valid] // eslint-disable-line react-hooks/exhaustive-deps
42 // );
51 // );
43
52
44− // console.log(input);
53+ const showError = !!error;
54+ const showSubmitError = !!submitError && !dirtySinceLastSubmit;
55+ const indicateError = touched && !active && (showError || showSubmitError);
45
56
46 return (
57 return (
47 <Flex
58 <Flex
@@ -70,7 +81,7 @@ export default function Input({
70 // NOTE: 24px = 12px * 2 (from p3 styling)
81 // NOTE: 24px = 12px * 2 (from p3 styling)
71 style={type === 'textarea' ? { minHeight: 'calc(1rem + 24px)' } : {}}
82 style={type === 'textarea' ? { minHeight: 'calc(1rem + 24px)' } : {}}
72 className={cn(
83 className={cn(
73− 'b b1 p3 outline-none',
84+ 'b b1 p3 outline-none bs-none',
74 { mono },
85 { mono },
75 {
86 {
76 'bg-white': !disabled,
87 'bg-white': !disabled,
@@ -107,9 +118,9 @@ export default function Input({
107 )}
118 )}
108 </Flex.Item>
119 </Flex.Item>
109
120
110− {touched && !active && error && (
121+ {indicateError && (
111 <Flex.Item as={ErrorText} className="mv1">
122 <Flex.Item as={ErrorText} className="mv1">
112− {error}
123+ {error || submitError}
113 </Flex.Item>
124 </Flex.Item>
114 )}
125 )}
115 </Flex>
126 </Flex>
src/lib/pluralize.js+1−1
@@ -1,5 +1,5 @@
1export default (count, singular, plural) => {
1export default (count, singular, plural) => {
2− const isPlural = count > 1;
2+ const isPlural = count === 0 || count > 1;
3 if (!plural) {
3 if (!plural) {
4 return `${count} ${singular}${isPlural ? 's' : ''}`;
4 return `${count} ${singular}${isPlural ? 's' : ''}`;
5 }
5 }
src/lib/useMailer.js+3−1
@@ -1,16 +1,18 @@
1import { useCallback, useRef } from 'react';
1import { useCallback, useRef } from 'react';
2
2
3import { hasReceived, sendMail } from './inviteMail';
3import { hasReceived, sendMail } from './inviteMail';
4+import timeout from './timeout';
4
5
5const STUB_MAILER = process.env.REACT_APP_STUB_MAILER === 'true';
6const STUB_MAILER = process.env.REACT_APP_STUB_MAILER === 'true';
6
7
7−export default function useMailer(emails) {
8+export default function useMailer() {
8 const cache = useRef({});
9 const cache = useRef({});
9
10
10 const getHasReceived = useCallback(
11 const getHasReceived = useCallback(
11 async email => {
12 async email => {
12 if (!cache.current[email]) {
13 if (!cache.current[email]) {
13 if (STUB_MAILER) {
14 if (STUB_MAILER) {
15+ await timeout(350); // simulate request
14 cache.current[email] = false;
16 cache.current[email] = false;
15 } else {
17 } else {
16 cache.current[email] = await hasReceived(email);
18 cache.current[email] = await hasReceived(email);
src/style/indigo-proposals.scss+4
@@ -37,6 +37,10 @@
37 user-select: none;
37 user-select: none;
38}
38}
39
39
40+.bs-none {
41+ box-shadow: none !important;
42+}
43+
40.auto-rows-min {
44.auto-rows-min {
41 grid-auto-rows: min-content;
45 grid-auto-rows: min-content;
42}
46}
src/views/Invite/InviteEmail.js+153−134
@@ -56,6 +56,7 @@ import {
56import { FORM_ERROR } from 'final-form';
56import { FORM_ERROR } from 'final-form';
57import SubmitButton from 'form/SubmitButton';
57import SubmitButton from 'form/SubmitButton';
58import FormError from 'form/FormError';
58import FormError from 'form/FormError';
59+import { WARNING, onlyHasWarning } from 'form/helpers';
59
60
60const INITIAL_VALUES = { emails: [''] };
61const INITIAL_VALUES = { emails: [''] };
61
62
@@ -72,6 +73,8 @@ const STATUS = {
72 FAILURE: 'FAILURE',
73 FAILURE: 'FAILURE',
73};
74};
74
75
76+const nameForEmailField = i => `emails[${i}]`;
77+
75const buttonText = (status, count) => {
78const buttonText = (status, count) => {
76 switch (status) {
79 switch (status) {
77 case STATUS.INPUT:
80 case STATUS.INPUT:
@@ -105,7 +108,7 @@ export default function InviteEmail() {
105 const { getHasReceived, sendMail } = useMailer();
108 const { getHasReceived, sendMail } = useMailer();
106 const { gasPrice } = useSuggestedGasPrice(networkType);
109 const { gasPrice } = useSuggestedGasPrice(networkType);
107
110
108− const cachedEmails = useRef([]);
111+ const cachedEmails = useRef({});
109
112
110 const { availableInvites } = getInvites(point);
113 const { availableInvites } = getInvites(point);
111 const maxInvitesToSend = availableInvites.matchWith({
114 const maxInvitesToSend = availableInvites.matchWith({
@@ -158,9 +161,7 @@ export default function InviteEmail() {
158 () =>
161 () =>
159 composeValidator(
162 composeValidator(
160 {
163 {
161− emails: buildArrayValidator(
162− buildEmailValidator([validateHasReceived])
164+ emails: buildArrayValidator(buildEmailValidator(validateHasReceived)),
163− ),
164 },
165 },
165 validateForm
166 validateForm
166 ),
167 ),
@@ -218,6 +219,7 @@ export default function InviteEmail() {
218 // NB(shrugs) - must be processed in serial because main thread, etc
219 // NB(shrugs) - must be processed in serial because main thread, etc
219 let errorCount = 0;
220 let errorCount = 0;
220 for (let i = 0; i < values.emails.length; i++) {
221 for (let i = 0; i < values.emails.length; i++) {
222+ const name = nameForEmailField(i);
221 try {
223 try {
222 const email = values.emails[i];
224 const email = values.emails[i];
223 const planet = planets[i];
225 const planet = planets[i];
@@ -249,20 +251,20 @@ export default function InviteEmail() {
249
251
250 const rawTx = hexify(signedTx.serialize());
252 const rawTx = hexify(signedTx.serialize());
251
253
252− addInvite({ [email]: { email, ticket, signedTx, rawTx } });
254+ addInvite({ [name]: { email, ticket, signedTx, rawTx } });
253 } catch (error) {
255 } catch (error) {
254 console.error(error);
256 console.error(error);
255 errorCount++;
257 errorCount++;
258+ addError({ [name]: 'Error generating invite.' });
256 }
259 }
257 }
260 }
258
261
259 if (errorCount > 0) {
262 if (errorCount > 0) {
260 return {
263 return {
261− [FORM_ERROR]: `There ${pluralize(
264+ [WARNING]: `There ${pluralize(errorCount, 'was', 'were')} ${pluralize(
262 errorCount,
265 errorCount,
263− 'was',
266+ 'error'
264− 'were'
267+ )} while generating wallets. You can still send the invites that generated correctly.`,
265− )} ${pluralize(errorCount, 'error')} while generating wallets.`,
266 };
268 };
267 }
269 }
268 },
270 },
@@ -278,11 +280,17 @@ export default function InviteEmail() {
278 networkType,
280 networkType,
279 gasPrice,
281 gasPrice,
280 addInvite,
282 addInvite,
283+ addError,
281 ]
284 ]
282 );
285 );
283
286
284 const sendInvites = useCallback(async () => {
287 const sendInvites = useCallback(async () => {
288+ // compute all of the names of the fields
289+ const allFieldNames = Object.keys(cachedEmails.current);
290+ // only emails we're sending are the valid ones with invites
291+ const names = allFieldNames.filter(name => !!invites[name]);
285− const emails = cachedEmails.current;
292+ const emails = allFieldNames.map(name => cachedEmails.current[name]);
293+
286 const _web3 = web3.getOrElse(null);
294 const _web3 = web3.getOrElse(null);
287 const _wallet = wallet.getOrElse(null);
295 const _wallet = wallet.getOrElse(null);
288 if (!_web3 || !_wallet) {
296 if (!_web3 || !_wallet) {
@@ -295,7 +303,7 @@ export default function InviteEmail() {
295 point,
303 point,
296 _wallet.address,
304 _wallet.address,
297 toWei((gasPrice * GAS_LIMIT * emails.length).toString(), 'gwei'),
305 toWei((gasPrice * GAS_LIMIT * emails.length).toString(), 'gwei'),
298− emails.map(email => invites[email].rawTx),
306+ names.map(name => invites[name].rawTx),
299 (address, minBalance, balance) =>
307 (address, minBalance, balance) =>
300 setNeedFunds({ address, minBalance, balance }),
308 setNeedFunds({ address, minBalance, balance }),
301 () => setNeedFunds(undefined)
309 () => setNeedFunds(undefined)
@@ -306,8 +314,8 @@ export default function InviteEmail() {
306
314
307 let unsentInvites = [];
315 let unsentInvites = [];
308 let orphanedInvites = [];
316 let orphanedInvites = [];
309− const txAndMailings = emails.map(async email => {
317+ const txAndMailings = names.map(async name => {
310− const invite = invites[email];
318+ const invite = invites[name];
311 try {
319 try {
312 const txHash = await sendSignedTransaction(
320 const txHash = await sendSignedTransaction(
313 _web3,
321 _web3,
@@ -334,7 +342,7 @@ export default function InviteEmail() {
334 orphanedInvites.push(invite);
342 orphanedInvites.push(invite);
335 }
343 }
336
344
337− addReceipt({ [email]: true });
345+ addReceipt({ [name]: true });
338 });
346 });
339
347
340 await Promise.all(txAndMailings);
348 await Promise.all(txAndMailings);
@@ -363,20 +371,28 @@ export default function InviteEmail() {
363 wallet,
371 wallet,
364 point,
372 point,
365 gasPrice,
373 gasPrice,
366− clearReceipts,
367 invites,
374 invites,
375+ clearReceipts,
368 addReceipt,
376 addReceipt,
369 sendMail,
377 sendMail,
370 ]);
378 ]);
371
379
372 const onSubmit = useCallback(
380 const onSubmit = useCallback(
373 async values => {
381 async values => {
374− cachedEmails.current = values.emails;
382+ cachedEmails.current = values.emails.reduce((memo, email, i) => {
383+ memo[nameForEmailField(i)] = email;
384+ return memo;
385+ }, {});
375 setStatus(STATUS.GENERATING);
386 setStatus(STATUS.GENERATING);
376 const errors = await generateInvites(values);
387 const errors = await generateInvites(values);
388+ if (hasErrors(errors)) {
377− if (errors) {
389+ if (onlyHasWarning(errors)) {
390+ setStatus(STATUS.CAN_SEND);
391+ }
392+
378 return errors;
393 return errors;
379 }
394 }
395+
380 setStatus(STATUS.CAN_SEND);
396 setStatus(STATUS.CAN_SEND);
381 },
397 },
382 [generateInvites]
398 [generateInvites]
@@ -409,130 +425,133 @@ export default function InviteEmail() {
409 validate={validate}
425 validate={validate}
410 onSubmit={onSubmit}
426 onSubmit={onSubmit}
411 initialValues={INITIAL_VALUES}>
427 initialValues={INITIAL_VALUES}>
412− {({ handleSubmit, valid, values }) => (
428+ {({ handleSubmit }) => (
413 <FieldArray name="emails">
429 <FieldArray name="emails">
414− {({ fields }) => (
430+ {({ fields }) => {
415− <>
431+ const sentInviteNames = Object.keys(receipts);
432+ return (
433+ <>
416− <Grid.Item full as={Flex} justify="end">
434+ <Grid.Item full as={Flex} justify="end">
417− {/* use hidden class instead of removing component from dom */}
435+ {/* use hidden class instead of removing component from dom */}
418− {/* in order to avoid janky reflow */}
436+ {/* in order to avoid janky reflow */}
419− <IconButton
437+ <IconButton
420− onClick={() => fields.push('')}
438+ onClick={() => fields.push('')}
421− disabled={!canInput || fields.length >= maxInvitesToSend}
439+ disabled={!canInput || fields.length >= maxInvitesToSend}
422− className={cn({ hidden: isDone })}
440+ className={cn({ hidden: isDone })}
423− solid>
441+ solid>
424− +
442+ +
425− </IconButton>
443+ </IconButton>
426− </Grid.Item>
444+ </Grid.Item>
427−
445+
428− {isDone ? (
446+ {isDone ? (
429− <>
447+ <>
430− <Grid.Item as={Text} className="f5" full>
448+ <Grid.Item as={Text} className="f5" full>
431− <Highlighted>
449+ <Highlighted>
432− {pluralize(fields.length, 'invite')}
450+ {pluralize(sentInviteNames.length, 'invite')}
433− </Highlighted>{' '}
451+ </Highlighted>{' '}
434− {pluralize(fields.length, 'has', 'have')} been
452+ {pluralize(sentInviteNames.length, 'has', 'have')} been
435− successfully sent
453+ successfully sent
436− </Grid.Item>
437−
438− {fields.map(name => (
439− <Grid.Item as={HelpText} key={name} full>
440− <Field name={name}>
441− {({ input: { value } }) => value}
442− </Field>
443 </Grid.Item>
454 </Grid.Item>
444− ))}
455+
445− </>
456+ {sentInviteNames.map(name => (
446− ) : (
447− <>
448− {fields.map((name, i) => {
449− const isFirst = i === 0;
450− return (
451− <Grid.Item
457+ <Grid.Item as={HelpText} key={name} full>
452− key={name}
453− full
454− as={Grid}
455− gap={3}
456− onMouseOver={() => setHovered({ [name]: true })}
457− onMouseLeave={() => setHovered({ [name]: false })}>
458− <Grid.Item
459− cols={[1, 11]}
460− as={EmailInput}
461− name={name}
462− label={isFirst ? 'Email Address' : undefined}
463− accessory={accessoryFor(name)}
464− />
465 <Field name={name}>
458 <Field name={name}>
466− {({ meta: { active } }) => {
459+ {({ input: { value } }) => value}
467− return (
468− <>
469− {!isFirst &&
470− (active || hovered[name]) &&
471− canInput && (
472− <Grid.Item
473− cols={[11, 13]}
474− justifySelf="end"
475− alignSelf="center">
476− <IconButton
477− onClick={() => fields.remove(i)}
478− solid
479− secondary>
480− -
481− </IconButton>
482− </Grid.Item>
483− )}
484− </>
485− );
486− }}
487 </Field>
460 </Field>
488 </Grid.Item>
461 </Grid.Item>
462+ ))}
463+ </>
464+ ) : (
465+ <>
466+ {fields.map((name, i) => {
467+ const isFirst = i === 0;
468+ return (
469+ <Grid.Item
470+ key={name}
471+ full
472+ as={Grid}
473+ gap={3}
474+ onMouseOver={() => setHovered({ [name]: true })}
475+ onMouseLeave={() => setHovered({ [name]: false })}>
476+ <Grid.Item
477+ cols={[1, 11]}
478+ as={EmailInput}
479+ name={name}
480+ label={isFirst ? 'Email Address' : undefined}
481+ accessory={accessoryFor(name)}
482+ />
483+ <Field name={name}>
484+ {({ meta: { active } }) => {
485+ return (
486+ <>
487+ {!isFirst &&
488+ (active || hovered[name]) &&
489+ canInput && (
490+ <Grid.Item
491+ cols={[11, 13]}
492+ justifySelf="end"
493+ alignSelf="center">
494+ <IconButton
495+ onClick={() => fields.remove(i)}
496+ solid
497+ secondary>
498+ -
499+ </IconButton>
500+ </Grid.Item>
501+ )}
502+ </>
489− );
503+ );
490− })}
504+ }}
491−
505+ </Field>
506+ </Grid.Item>
507+ );
508+ })}
509+
492− {canInput ? (
510+ {canInput ? (
493− <Grid.Item
511+ <Grid.Item
494− full
512+ full
495− as={SubmitButton}
513+ as={SubmitButton}
496− handleSubmit={handleSubmit}
514+ handleSubmit={handleSubmit}
497− accessory={`${visualProgress}/${fields.length}`}>
515+ accessory={`${visualProgress}/${fields.length}`}>
498− {buttonText(status, fields.length)}
516+ {buttonText(status, fields.length)}
499− </Grid.Item>
517+ </Grid.Item>
500− ) : (
518+ ) : (
501− <Grid.Item
519+ <Grid.Item
502− full
520+ full
503− as={LoadableButton}
521+ as={LoadableButton}
504− className="mt4"
522+ className="mt4"
505− disabled={!canSend}
523+ disabled={!canSend}
506− accessory={`${visualProgress}/${fields.length}`}
524+ accessory={`${visualProgress}/${fields.length}`}
507− onClick={doSend}
525+ onClick={doSend}
508− success={canSend}
526+ success={canSend}
509− solid>
527+ solid>
510− {buttonText(status, fields.length)}
528+ {buttonText(status, fields.length)}
511− </Grid.Item>
529+ </Grid.Item>
512− )}
530+ )}
513−
531+
514− {needFunds && (
532+ {needFunds && (
515− <Grid.Item full>
533+ <Grid.Item full>
516− <Highlighted warning>
534+ <Highlighted warning>
517− Your ownership address {needFunds.address} needs at
535+ Your ownership address {needFunds.address} needs at
518− least {fromWei(needFunds.minBalance)} ETH and
536+ least {fromWei(needFunds.minBalance)} ETH and
519− currently has {fromWei(needFunds.balance)} ETH.
537+ currently has {fromWei(needFunds.balance)} ETH.
520− Waiting until the account has enough funds.
538+ Waiting until the account has enough funds.
521− </Highlighted>
539+ </Highlighted>
522− </Grid.Item>
540+ </Grid.Item>
523− )}
541+ )}
524
542
525− <Grid.Item full as={FormError} />
543+ <Grid.Item full as={FormError} />
526
544
527− {generalError && (
545+ {generalError && (
528− <Grid.Item full as={ErrorText}>
546+ <Grid.Item full as={ErrorText}>
529− {generalError}
547+ {generalError}
530− </Grid.Item>
548+ </Grid.Item>
531− )}
549+ )}
532− </>
550+ </>
533− )}
551+ )}
534− </>
552+ </>
553+ );
535− )}
554+ }}
536 </FieldArray>
555 </FieldArray>
537 )}
556 )}
538 </BridgeForm>
557 </BridgeForm>