on master help sign in
feat: tidy up InviteEmails.js and cover a few error edge cases

NOTE: the render function diff will be large because of the indentation change within the `FieldArray` component. + update the buildValidator usage to allow additional (optionally async) validators per-field and in buildArrayValidator + fix: FormError was listening to a wrong error value in a subscription + commented more about FormError logic for clarity + pull Input.js error logic forward + fix FF-specific box-shadow css issue on required & invalid inputs + for plurality, we want to use the plural form for 0 counts as well (i.e. '0 errors' vs '0 error') + simulate a delay when stubbing useMailer + switch back to using `name` as the key for invites, receipts, and errors in InviteEmails.js, which fixes the accessory bug (accessories were not updating when they should have) + use the new WARNING pattern for messaging about errors during submission while still allowing submission anyway + add copy clarifying that the form can still be submitted + we now only send the valid invites, avoiding a `throw` case + we only tell the user about valid invites that were sent + if there was a warning generating invites, we allow the user to send the invites anyway known issues that could/should be handled in a future PR: - InviteEmails error handling still needs some ๐Ÿ‘€ - InviteEmails Inputs don't respond to the `error` state tracked within InviteEmails, only the error state within final-form's data-model - you can "Send Invites" with 0 valid invites. Nothing bad happens (nothing happens at all, really โ€” you get a success state with '0 invites were successfully sent')

commitb019e78f1abce7ca528c4af11725921fe4f9fe04 authorMatt Condon (shrugs) <mattgcondon@gmail.com> dateThu 15 Aug 2019, 14:03 (7 years ago) parent557ffe7
8 files changed+198โˆ’148
src/form/FormError.js+1โˆ’1 src/form/SubmitButton.js+4โˆ’1 src/form/validators.js+16โˆ’5 src/indigo-react/components/Input.js+16โˆ’5 src/lib/pluralize.js+1โˆ’1 src/lib/useMailer.js+3โˆ’1 src/style/indigo-proposals.scss+4 src/views/Invite/InviteEmail.js+153โˆ’134
src/form/FormError.js+1โˆ’1
@@ -14,7 +14,7 @@ export default function FormError(props) {
1414 subscription: {
1515 submitError: true,
1616 submitErrors: true,
17โˆ’ errors: true,
17+ error: true,
1818 dirtySinceLastSubmit: true,
1919 },
2020 });
src/form/SubmitButton.js+4โˆ’1
@@ -29,7 +29,10 @@ export default function SubmitButton({
2929
3030 // can submit if:
3131 // 1) is valid
32โˆ’ // OR has submit errors AND is dirty or only has a warning (double conf)
32+ // OR has submit errors
33+ // AND
34+ // a) is dirty
35+ // b) OR only has a warning (double conf)
3336 // 2) AND is not validating
3437 // 3) AND has no validation errors
3538 // 4) AND is not actively submitting
src/form/validators.js+16โˆ’5
@@ -17,7 +17,7 @@ import {
1717import isPromise from 'lib/isPromise';
1818
1919// iterate over validators, exiting early if there's an error
20โˆ’const buildValidator = (validators = []) => value => {
20+const buildValidator = (validators = [], validate) => value => {
2121 for (const validator of validators) {
2222 try {
2323 const error = validator(value);
@@ -29,11 +29,22 @@ const buildValidator = (validators = []) => value => {
2929 return error.message;
3030 }
3131 }
32+
33+ if (validate) {
34+ // the final validate function can optionally return a promise
35+ return validate(value);
36+ }
3237};
3338
3439// maps a validator across an array of values
35โˆ’export const buildArrayValidator = validator => async values =>
36โˆ’ await Promise.all(values.map(validator));
40+export const buildArrayValidator = validator => values => {
41+ const errorsOrPromises = values.map(validator);
42+ if (some(errorsOrPromises, isPromise)) {
43+ return Promise.all(errorsOrPromises);
44+ }
45+
46+ return errorsOrPromises;
47+};
3748
3849// error object has errors if some of its fields are
3950// 1) an array with any defined values
@@ -78,8 +89,8 @@ export const buildAddressValidator = () =>
7889 ]);
7990export const buildNumberValidator = (min = 0) =>
8091 buildValidator([validateGreaterThan(min)]);
81โˆ’export const buildEmailValidator = (validators = []) =>
82โˆ’ buildValidator([validateNotEmpty, validateEmail, ...validators]);
92+export const buildEmailValidator = validate =>
93+ buildValidator([validateNotEmpty, validateEmail], validate);
8394
8495// the form validator is the composition of all of the field validators
8596// plus an additional form validator function
src/indigo-react/components/Input.js+16โˆ’5
@@ -26,7 +26,16 @@ export default function Input({
2626}) {
2727 const {
2828 input,
29โˆ’ meta: { active, error, submitting, submitSucceeded, touched, valid },
29+ meta: {
30+ active,
31+ error,
32+ submitError,
33+ dirtySinceLastSubmit,
34+ submitting,
35+ submitSucceeded,
36+ touched,
37+ valid,
38+ },
3039 } = useField(name, config);
3140
3241 disabled = disabled || submitting || submitSucceeded;
@@ -41,7 +50,9 @@ export default function Input({
4150 // [disabled, valid] // eslint-disable-line react-hooks/exhaustive-deps
4251 // );
4352
44โˆ’ // console.log(input);
53+ const showError = !!error;
54+ const showSubmitError = !!submitError && !dirtySinceLastSubmit;
55+ const indicateError = touched && !active && (showError || showSubmitError);
4556
4657 return (
4758 <Flex
@@ -70,7 +81,7 @@ export default function Input({
7081 // NOTE: 24px = 12px * 2 (from p3 styling)
7182 style={type === 'textarea' ? { minHeight: 'calc(1rem + 24px)' } : {}}
7283 className={cn(
73โˆ’ 'b b1 p3 outline-none',
84+ 'b b1 p3 outline-none bs-none',
7485 { mono },
7586 {
7687 'bg-white': !disabled,
@@ -107,9 +118,9 @@ export default function Input({
107118 )}
108119 </Flex.Item>
109120
110โˆ’ {touched && !active && error && (
121+ {indicateError && (
111122 <Flex.Item as={ErrorText} className="mv1">
112โˆ’ {error}
123+ {error || submitError}
113124 </Flex.Item>
114125 )}
115126 </Flex>
src/lib/pluralize.js+1โˆ’1
@@ -1,5 +1,5 @@
11export default (count, singular, plural) => {
2โˆ’ const isPlural = count > 1;
2+ const isPlural = count === 0 || count > 1;
33 if (!plural) {
44 return `${count} ${singular}${isPlural ? 's' : ''}`;
55 }
src/lib/useMailer.js+3โˆ’1
@@ -1,16 +1,18 @@
11import { useCallback, useRef } from 'react';
22
33import { hasReceived, sendMail } from './inviteMail';
4+import timeout from './timeout';
45
56const STUB_MAILER = process.env.REACT_APP_STUB_MAILER === 'true';
67
7โˆ’export default function useMailer(emails) {
8+export default function useMailer() {
89 const cache = useRef({});
910
1011 const getHasReceived = useCallback(
1112 async email => {
1213 if (!cache.current[email]) {
1314 if (STUB_MAILER) {
15+ await timeout(350); // simulate request
1416 cache.current[email] = false;
1517 } else {
1618 cache.current[email] = await hasReceived(email);
src/style/indigo-proposals.scss+4
@@ -37,6 +37,10 @@
3737 user-select: none;
3838}
3939
40+.bs-none {
41+ box-shadow: none !important;
42+}
43+
4044.auto-rows-min {
4145 grid-auto-rows: min-content;
4246}
src/views/Invite/InviteEmail.js+153โˆ’134
@@ -56,6 +56,7 @@ import {
5656import { FORM_ERROR } from 'final-form';
5757import SubmitButton from 'form/SubmitButton';
5858import FormError from 'form/FormError';
59+import { WARNING, onlyHasWarning } from 'form/helpers';
5960
6061const INITIAL_VALUES = { emails: [''] };
6162
@@ -72,6 +73,8 @@ const STATUS = {
7273 FAILURE: 'FAILURE',
7374};
7475
76+const nameForEmailField = i => `emails[${i}]`;
77+
7578const buttonText = (status, count) => {
7679 switch (status) {
7780 case STATUS.INPUT:
@@ -105,7 +108,7 @@ export default function InviteEmail() {
105108 const { getHasReceived, sendMail } = useMailer();
106109 const { gasPrice } = useSuggestedGasPrice(networkType);
107110
108โˆ’ const cachedEmails = useRef([]);
111+ const cachedEmails = useRef({});
109112
110113 const { availableInvites } = getInvites(point);
111114 const maxInvitesToSend = availableInvites.matchWith({
@@ -158,9 +161,7 @@ export default function InviteEmail() {
158161 () =>
159162 composeValidator(
160163 {
161โˆ’ emails: buildArrayValidator(
162โˆ’ buildEmailValidator([validateHasReceived])
163โˆ’ ),
164+ emails: buildArrayValidator(buildEmailValidator(validateHasReceived)),
164165 },
165166 validateForm
166167 ),
@@ -218,6 +219,7 @@ export default function InviteEmail() {
218219 // NB(shrugs) - must be processed in serial because main thread, etc
219220 let errorCount = 0;
220221 for (let i = 0; i < values.emails.length; i++) {
222+ const name = nameForEmailField(i);
221223 try {
222224 const email = values.emails[i];
223225 const planet = planets[i];
@@ -249,20 +251,20 @@ export default function InviteEmail() {
249251
250252 const rawTx = hexify(signedTx.serialize());
251253
252โˆ’ addInvite({ [email]: { email, ticket, signedTx, rawTx } });
254+ addInvite({ [name]: { email, ticket, signedTx, rawTx } });
253255 } catch (error) {
254256 console.error(error);
255257 errorCount++;
258+ addError({ [name]: 'Error generating invite.' });
256259 }
257260 }
258261
259262 if (errorCount > 0) {
260263 return {
261โˆ’ [FORM_ERROR]: `There ${pluralize(
264+ [WARNING]: `There ${pluralize(errorCount, 'was', 'were')} ${pluralize(
262265 errorCount,
263โˆ’ 'was',
264โˆ’ 'were'
265โˆ’ )} ${pluralize(errorCount, 'error')} while generating wallets.`,
266+ 'error'
267+ )} while generating wallets. You can still send the invites that generated correctly.`,
266268 };
267269 }
268270 },
@@ -278,11 +280,17 @@ export default function InviteEmail() {
278280 networkType,
279281 gasPrice,
280282 addInvite,
283+ addError,
281284 ]
282285 );
283286
284287 const sendInvites = useCallback(async () => {
285โˆ’ const emails = cachedEmails.current;
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]);
292+ const emails = allFieldNames.map(name => cachedEmails.current[name]);
293+
286294 const _web3 = web3.getOrElse(null);
287295 const _wallet = wallet.getOrElse(null);
288296 if (!_web3 || !_wallet) {
@@ -295,7 +303,7 @@ export default function InviteEmail() {
295303 point,
296304 _wallet.address,
297305 toWei((gasPrice * GAS_LIMIT * emails.length).toString(), 'gwei'),
298โˆ’ emails.map(email => invites[email].rawTx),
306+ names.map(name => invites[name].rawTx),
299307 (address, minBalance, balance) =>
300308 setNeedFunds({ address, minBalance, balance }),
301309 () => setNeedFunds(undefined)
@@ -306,8 +314,8 @@ export default function InviteEmail() {
306314
307315 let unsentInvites = [];
308316 let orphanedInvites = [];
309โˆ’ const txAndMailings = emails.map(async email => {
310โˆ’ const invite = invites[email];
317+ const txAndMailings = names.map(async name => {
318+ const invite = invites[name];
311319 try {
312320 const txHash = await sendSignedTransaction(
313321 _web3,
@@ -334,7 +342,7 @@ export default function InviteEmail() {
334342 orphanedInvites.push(invite);
335343 }
336344
337โˆ’ addReceipt({ [email]: true });
345+ addReceipt({ [name]: true });
338346 });
339347
340348 await Promise.all(txAndMailings);
@@ -363,20 +371,28 @@ export default function InviteEmail() {
363371 wallet,
364372 point,
365373 gasPrice,
366โˆ’ clearReceipts,
367374 invites,
375+ clearReceipts,
368376 addReceipt,
369377 sendMail,
370378 ]);
371379
372380 const onSubmit = useCallback(
373381 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+ }, {});
375386 setStatus(STATUS.GENERATING);
376387 const errors = await generateInvites(values);
377โˆ’ if (errors) {
388+ if (hasErrors(errors)) {
389+ if (onlyHasWarning(errors)) {
390+ setStatus(STATUS.CAN_SEND);
391+ }
392+
378393 return errors;
379394 }
395+
380396 setStatus(STATUS.CAN_SEND);
381397 },
382398 [generateInvites]
@@ -409,130 +425,133 @@ export default function InviteEmail() {
409425 validate={validate}
410426 onSubmit={onSubmit}
411427 initialValues={INITIAL_VALUES}>
412โˆ’ {({ handleSubmit, valid, values }) => (
428+ {({ handleSubmit }) => (
413429 <FieldArray name="emails">
414โˆ’ {({ fields }) => (
415โˆ’ <>
416โˆ’ <Grid.Item full as={Flex} justify="end">
417โˆ’ {/* use hidden class instead of removing component from dom */}
418โˆ’ {/* in order to avoid janky reflow */}
419โˆ’ <IconButton
420โˆ’ onClick={() => fields.push('')}
421โˆ’ disabled={!canInput || fields.length >= maxInvitesToSend}
422โˆ’ className={cn({ hidden: isDone })}
423โˆ’ solid>
424โˆ’ +
425โˆ’ </IconButton>
426โˆ’ </Grid.Item>
427โˆ’
428โˆ’ {isDone ? (
429โˆ’ <>
430โˆ’ <Grid.Item as={Text} className="f5" full>
431โˆ’ <Highlighted>
432โˆ’ {pluralize(fields.length, 'invite')}
433โˆ’ </Highlighted>{' '}
434โˆ’ {pluralize(fields.length, 'has', 'have')} been
435โˆ’ 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>
430+ {({ fields }) => {
431+ const sentInviteNames = Object.keys(receipts);
432+ return (
433+ <>
434+ <Grid.Item full as={Flex} justify="end">
435+ {/* use hidden class instead of removing component from dom */}
436+ {/* in order to avoid janky reflow */}
437+ <IconButton
438+ onClick={() => fields.push('')}
439+ disabled={!canInput || fields.length >= maxInvitesToSend}
440+ className={cn({ hidden: isDone })}
441+ solid>
442+ +
443+ </IconButton>
444+ </Grid.Item>
445+
446+ {isDone ? (
447+ <>
448+ <Grid.Item as={Text} className="f5" full>
449+ <Highlighted>
450+ {pluralize(sentInviteNames.length, 'invite')}
451+ </Highlighted>{' '}
452+ {pluralize(sentInviteNames.length, 'has', 'have')} been
453+ successfully sent
443454 </Grid.Item>
444โˆ’ ))}
445โˆ’ </>
446โˆ’ ) : (
447โˆ’ <>
448โˆ’ {fields.map((name, i) => {
449โˆ’ const isFirst = i === 0;
450โˆ’ return (
451โˆ’ <Grid.Item
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โˆ’ />
455+
456+ {sentInviteNames.map(name => (
457+ <Grid.Item as={HelpText} key={name} full>
465458 <Field name={name}>
466โˆ’ {({ meta: { active } }) => {
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โˆ’ }}
459+ {({ input: { value } }) => value}
487460 </Field>
488461 </Grid.Item>
489โˆ’ );
490โˆ’ })}
491โˆ’
492โˆ’ {canInput ? (
493โˆ’ <Grid.Item
494โˆ’ full
495โˆ’ as={SubmitButton}
496โˆ’ handleSubmit={handleSubmit}
497โˆ’ accessory={`${visualProgress}/${fields.length}`}>
498โˆ’ {buttonText(status, fields.length)}
499โˆ’ </Grid.Item>
500โˆ’ ) : (
501โˆ’ <Grid.Item
502โˆ’ full
503โˆ’ as={LoadableButton}
504โˆ’ className="mt4"
505โˆ’ disabled={!canSend}
506โˆ’ accessory={`${visualProgress}/${fields.length}`}
507โˆ’ onClick={doSend}
508โˆ’ success={canSend}
509โˆ’ solid>
510โˆ’ {buttonText(status, fields.length)}
511โˆ’ </Grid.Item>
512โˆ’ )}
513โˆ’
514โˆ’ {needFunds && (
515โˆ’ <Grid.Item full>
516โˆ’ <Highlighted warning>
517โˆ’ Your ownership address {needFunds.address} needs at
518โˆ’ least {fromWei(needFunds.minBalance)} ETH and
519โˆ’ currently has {fromWei(needFunds.balance)} ETH.
520โˆ’ Waiting until the account has enough funds.
521โˆ’ </Highlighted>
522โˆ’ </Grid.Item>
523โˆ’ )}
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+ </>
503+ );
504+ }}
505+ </Field>
506+ </Grid.Item>
507+ );
508+ })}
509+
510+ {canInput ? (
511+ <Grid.Item
512+ full
513+ as={SubmitButton}
514+ handleSubmit={handleSubmit}
515+ accessory={`${visualProgress}/${fields.length}`}>
516+ {buttonText(status, fields.length)}
517+ </Grid.Item>
518+ ) : (
519+ <Grid.Item
520+ full
521+ as={LoadableButton}
522+ className="mt4"
523+ disabled={!canSend}
524+ accessory={`${visualProgress}/${fields.length}`}
525+ onClick={doSend}
526+ success={canSend}
527+ solid>
528+ {buttonText(status, fields.length)}
529+ </Grid.Item>
530+ )}
531+
532+ {needFunds && (
533+ <Grid.Item full>
534+ <Highlighted warning>
535+ Your ownership address {needFunds.address} needs at
536+ least {fromWei(needFunds.minBalance)} ETH and
537+ currently has {fromWei(needFunds.balance)} ETH.
538+ Waiting until the account has enough funds.
539+ </Highlighted>
540+ </Grid.Item>
541+ )}
524542
525โˆ’ <Grid.Item full as={FormError} />
543+ <Grid.Item full as={FormError} />
526544
527โˆ’ {generalError && (
528โˆ’ <Grid.Item full as={ErrorText}>
529โˆ’ {generalError}
530โˆ’ </Grid.Item>
531โˆ’ )}
532โˆ’ </>
533โˆ’ )}
534โˆ’ </>
535โˆ’ )}
545+ {generalError && (
546+ <Grid.Item full as={ErrorText}>
547+ {generalError}
548+ </Grid.Item>
549+ )}
550+ </>
551+ )}
552+ </>
553+ );
554+ }}
536555 </FieldArray>
537556 )}
538557 </BridgeForm>