on master help sign in
feat: add error messages to input and include (stubbed) hasReceived for mailer
a432d0bMatt Condon (shrugs)
src/indigo-react/components/Input.js+8−2
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect } from 'react';
2import cn from 'classnames';
2import cn from 'classnames';
3
3
4import Flex from './Flex';
4import Flex from './Flex';
5+import { ErrorText } from './Typography';
5
6
6export default function Input({
7export default function Input({
7 // visuals
8 // visuals
@@ -72,7 +73,7 @@ export default function Input({
72 <Flex.Item as="label" className="f6 lh-tall" htmlFor={name}>
73 <Flex.Item as="label" className="f6 lh-tall" htmlFor={name}>
73 {label}
74 {label}
74 </Flex.Item>
75 </Flex.Item>
75− <Flex row>
76+ <Flex.Item as={Flex} row>
76 <Flex.Item
77 <Flex.Item
77 as="input"
78 as="input"
78 {...rest}
79 {...rest}
@@ -114,7 +115,12 @@ export default function Input({
114 {accessory}
115 {accessory}
115 </Flex.Item>
116 </Flex.Item>
116 )}
117 )}
117− </Flex>
118+ </Flex.Item>
119+ {error && (
120+ <Flex.Item as={ErrorText} className="mv1">
121+ {error}
122+ </Flex.Item>
123+ )}
118 </Flex>
124 </Flex>
119 );
125 );
120}
126}
src/indigo-react/components/Typography.js+1
@@ -21,3 +21,4 @@ export const P = typeWith('p');
21export const Breadcrumb = typeWith('span', 'f6');
21export const Breadcrumb = typeWith('span', 'f6');
22export const Text = typeWith('span', 'f5');
22export const Text = typeWith('span', 'f5');
23export const HelpText = typeWith('span', 'f6 gray4');
23export const HelpText = typeWith('span', 'f6 gray4');
24+export const ErrorText = typeWith('span', 'f6 red3');
src/indigo-react/lib/useForm.js+21−19
@@ -99,40 +99,42 @@ export default function useForm(configs = []) {
99 );
99 );
100
100
101 const datas = useMemo(() => validations.map(v => v.data), [validations]);
101 const datas = useMemo(() => validations.map(v => v.data), [validations]);
102− const passes = useMemo(() => validations.map(v => v.pass), [validations]);
103
102
104 // the input has errored if it
103 // the input has errored if it
104+ // 1) has been focused before
105+ // 2)
105− // 1) did not pass validation
106+ // a) did not pass validation and has an error text
106− // 2) has an error text
107+ // b) or has a specific error from parent state
107− // 3) has been focused before
108− // 4) is not currently focused
109− // returning the error text at the end
108+ // (and we implicitly return the error text at the end)
110 const errors = useMemo(
109 const errors = useMemo(
111 () =>
110 () =>
112 configs.map(
111 configs.map(
113 (config, i) =>
112 (config, i) =>
114− config.error ||
113+ hasBeenFocused[config.name] && //
115− (!validations[i].pass &&
114+ (validations[i].error || config.error)
116− hasBeenFocused[config.name] &&
117− !focused[config.name] &&
118− validations[i].error)
119 ),
115 ),
120− [configs, validations, hasBeenFocused, focused]
116+ [configs, validations, hasBeenFocused]
121 );
117 );
122
118
123− // visibly tell the user that their input has passed if
119+ // this input has passed if
124− // 1) it has passed
120+ // 1) it has passed local validation
125 // 2) there are no errors
121 // 2) there are no errors
122+ const passes = useMemo(
123+ () => validations.map((v, i) => v.pass && !errors[i]),
124+ [validations, errors]
125+ );
126+
127+ // visibly tell the user that their input has passed if
126− // 3) they are or have interacted with the input before
128+ // 1) they are or have interacted with the input before
129+ // 2) and it has passed as defined above
127 const visiblePasses = useMemo(
130 const visiblePasses = useMemo(
128 () =>
131 () =>
129 configs.map(
132 configs.map(
130 (config, i) =>
133 (config, i) =>
131− validations[i].pass &&
132− !errors[i] &&
133− (hasBeenFocused[config.name] || focused[config.name])
134+ (focused[config.name] || hasBeenFocused[config.name]) && //
135+ passes[i]
134 ),
136 ),
135− [configs, validations, errors, hasBeenFocused, focused]
137+ [configs, hasBeenFocused, focused, passes]
136 );
138 );
137
139
138 const inputs = useMemo(
140 const inputs = useMemo(
src/lib/useMailer.js+24−12
@@ -1,10 +1,12 @@
1−import { useCallback, useEffect } from 'react';
1+import { useCallback } from 'react';
2import Maybe from 'folktale/maybe';
2import Maybe from 'folktale/maybe';
3
3
4import { hasReceived, sendMail } from './inviteMail';
4import { hasReceived, sendMail } from './inviteMail';
5import useSetState from './useSetState';
5import useSetState from './useSetState';
6
6
7+const kStubMailer = process.env.REACT_APP_STUB_MAILER === 'true';
8+
7−function useHasReceivedCache(emails = []) {
9+function useHasReceivedCache() {
8 const [cache, addToCache] = useSetState();
10 const [cache, addToCache] = useSetState();
9
11
10 const getHasRecieved = useCallback(
12 const getHasRecieved = useCallback(
@@ -12,27 +14,37 @@ function useHasReceivedCache(emails = []) {
12 [cache]
14 [cache]
13 );
15 );
14
16
15− useEffect(() => {
17+ const syncHasReceivedForEmail = useCallback(
16− for (const email of emails) {
18+ async email => {
17 if (Maybe.Just.hasInstance(getHasRecieved(email))) {
19 if (Maybe.Just.hasInstance(getHasRecieved(email))) {
18 // never update the cache after we know about it
20 // never update the cache after we know about it
19 return;
21 return;
20 }
22 }
21
23
22− (async () => {
24+ if (kStubMailer) {
25+ // always allow sending emails when stubbing
23− addToCache({ [email]: await hasReceived(email) });
26+ return addToCache({ [email]: Maybe.Just(false) });
24− })();
27+ }
25− }
26− }, [emails, getHasRecieved, addToCache]);
27
28
29+ const _hasReceived = await hasReceived(email);
30+ addToCache({ [email]: Maybe.Just(_hasReceived) });
31+ },
32+ [getHasRecieved, addToCache]
33+ );
34+
28− return { getHasRecieved };
35+ return { getHasRecieved, syncHasReceivedForEmail };
29}
36}
30
37
31export default function useMailer(emails) {
38export default function useMailer(emails) {
32− const { getHasRecieved } = useHasReceivedCache(emails);
39+ const hasReceivedCache = useHasReceivedCache(emails);
33
40
34 // prefix to avoid clobbering sendMail import
41 // prefix to avoid clobbering sendMail import
35 const _sendMail = useCallback(async (email, ticket, rawTx) => {
42 const _sendMail = useCallback(async (email, ticket, rawTx) => {
43+ if (kStubMailer) {
44+ console.log(`${email} - ${ticket}`);
45+ return;
46+ }
47+
36 const mailSuccess = await sendMail(email, ticket, rawTx);
48 const mailSuccess = await sendMail(email, ticket, rawTx);
37
49
38 if (!mailSuccess) {
50 if (!mailSuccess) {
@@ -40,5 +52,5 @@ export default function useMailer(emails) {
40 }
52 }
41 }, []);
53 }, []);
42
54
43− return { getHasRecieved, sendMail: _sendMail };
55+ return { ...hasReceivedCache, sendMail: _sendMail };
44}
56}
src/views/Invite/InviteEmail.js+23−14
@@ -2,7 +2,15 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
2import cn from 'classnames';
2import cn from 'classnames';
3import Maybe from 'folktale/maybe';
3import Maybe from 'folktale/maybe';
4import * as azimuth from 'azimuth-js';
4import * as azimuth from 'azimuth-js';
5+import {
6+ Grid,
7+ Flex,
8+ Input,
9+ IconButton,
10+ HelpText,
11+ Text,
12+ ErrorText,
5−import { Grid, Flex, Input, IconButton, HelpText, Text } from 'indigo-react';
13+} from 'indigo-react';
6import { uniq } from 'lodash';
14import { uniq } from 'lodash';
7
15
8import * as need from 'lib/need';
16import * as need from 'lib/need';
@@ -35,8 +43,7 @@ import { useWallet } from 'store/wallet';
35import useSetState from 'lib/useSetState';
43import useSetState from 'lib/useSetState';
36import pluralize from 'lib/pluralize';
44import pluralize from 'lib/pluralize';
37import useMailer from 'lib/useMailer';
45import useMailer from 'lib/useMailer';
38−
46+import useRenderCount from 'lib/useRenderCount';
39−const kStubMailer = process.env.REACT_APP_STUB_MAILER === 'true';
40
47
41const GAS_PRICE_GWEI = 20; // we pay the premium for faster ux
48const GAS_PRICE_GWEI = 20; // we pay the premium for faster ux
42const GAS_LIMIT = 350000;
49const GAS_LIMIT = 350000;
@@ -89,7 +96,6 @@ const buildAccessoryFor = (dones, errors) => name => (
89 </Flex>
96 </Flex>
90);
97);
91
98
92−// TODO: how to handle dependency loop between inputConfigs and hasReceived?
93// TODO: test with tank, successful txs
99// TODO: test with tank, successful txs
94// TODO: put accessory inside of input
100// TODO: put accessory inside of input
95export default function InviteEmail() {
101export default function InviteEmail() {
@@ -111,10 +117,11 @@ export default function InviteEmail() {
111 inputConfigs,
117 inputConfigs,
112 { append: appendInput, removeAt: removeInputAt },
118 { append: appendInput, removeAt: removeInputAt },
113 ] = useArray(
119 ] = useArray(
114− [buildInputConfig({ placeholder: 'Email Addresss' })],
120+ [buildInputConfig({ placeholder: 'Email Address' })],
115 buildInputConfig
121 buildInputConfig
116 );
122 );
117
123
124+ const { getHasRecieved, syncHasReceivedForEmail, sendMail } = useMailer();
118 const [hovered, setHovered] = useSetState();
125 const [hovered, setHovered] = useSetState();
119 const [invites, addInvite, clearInvites] = useSetState();
126 const [invites, addInvite, clearInvites] = useSetState();
120 const [receipts, addReceipt, clearReceipts] = useSetState();
127 const [receipts, addReceipt, clearReceipts] = useSetState();
@@ -136,16 +143,19 @@ export default function InviteEmail() {
136 () =>
143 () =>
137 inputConfigs.map(config => {
144 inputConfigs.map(config => {
138 config.disabled = !canInput;
145 config.disabled = !canInput;
146+ const hasReceivedError = getHasRecieved(config.name).matchWith({
147+ Nothing: () => null, // loading
148+ Just: p => p.value && 'This email has already received an invite.',
149+ });
139− config.error = errors[config.name];
150+ config.error = hasReceivedError || errors[config.name];
140 return config;
151 return config;
141 }),
152 }),
142− [inputConfigs, errors, canInput]
153+ [inputConfigs, errors, canInput, getHasRecieved]
143 );
154 );
144 const { inputs, pass } = useForm(dynamicConfigs);
155 const { inputs, pass } = useForm(dynamicConfigs);
145 const emails = useMemo(() => inputs.map(i => i.data).filter(d => !!d), [
156 const emails = useMemo(() => inputs.map(i => i.data).filter(d => !!d), [
146 inputs,
157 inputs,
147 ]);
158 ]);
148− const { getHasRecieved, sendMail } = useMailer(emails);
149
159
150 const canAddInvite = canInput && inputs.length < maxInvitesToSend;
160 const canAddInvite = canInput && inputs.length < maxInvitesToSend;
151 const allPass = pass && !generalError;
161 const allPass = pass && !generalError;
@@ -301,14 +311,10 @@ export default function InviteEmail() {
301 addError({
311 addError({
302 [input.name]: `Transaction Failure for ${invite.email}`,
312 [input.name]: `Transaction Failure for ${invite.email}`,
303 });
313 });
314+ return;
304 }
315 }
305
316
306 try {
317 try {
307− if (kStubMailer) {
308− console.log(`${invite.email} - ${invite.ticket}`);
309− return;
310− }
311−
312 const mailSuccess = await sendMail(
318 const mailSuccess = await sendMail(
313 invite.email,
319 invite.email,
314 invite.ticket,
320 invite.ticket,
@@ -345,6 +351,7 @@ export default function InviteEmail() {
345 point,
351 point,
346 wallet,
352 wallet,
347 addError,
353 addError,
354+ sendMail,
348 ]);
355 ]);
349
356
350 const onClickGenerate = useCallback(async () => {
357 const onClickGenerate = useCallback(async () => {
@@ -439,6 +446,8 @@ export default function InviteEmail() {
439 as={Input}
446 as={Input}
440 cols={[1, 11]}
447 cols={[1, 11]}
441 {...input}
448 {...input}
449+ onValue={email => syncHasReceivedForEmail(email)}
450+ // NB(shrugs): ^ this feels like a hack?
442 accessory={accessoryFor(input.name)}
451 accessory={accessoryFor(input.name)}
443 />
452 />
444 {!isFirst &&
453 {!isFirst &&
@@ -483,7 +492,7 @@ export default function InviteEmail() {
483
492
484 {generalError && (
493 {generalError && (
485 <Grid.Item full>
494 <Grid.Item full>
486− <Highlighted>{generalError.message.toString()}</Highlighted>
495+ <ErrorText>{generalError.message.toString()}</ErrorText>
487 </Grid.Item>
496 </Grid.Item>
488 )}
497 )}
489 </>
498 </>