Remove payments API routing initialization
[ProtonMail-WebClient.git] / packages / components / containers / contacts / edit / ContactEditModal.tsx
blobd1683537ff446ad42ec3198bb06a1d0019fe6536
1 import { useEffect, useRef, useState } from 'react';
3 import { c } from 'ttag';
5 import { Button } from '@proton/atoms';
6 import type { ModalProps } from '@proton/components/components/modalTwo/Modal';
7 import ModalTwo from '@proton/components/components/modalTwo/Modal';
8 import ModalTwoContent from '@proton/components/components/modalTwo/ModalContent';
9 import ModalTwoFooter from '@proton/components/components/modalTwo/ModalFooter';
10 import ModalTwoHeader from '@proton/components/components/modalTwo/ModalHeader';
11 import useEventManager from '@proton/components/hooks/useEventManager';
12 import useHandler from '@proton/components/hooks/useHandler';
13 import useNotifications from '@proton/components/hooks/useNotifications';
14 import { useLoading } from '@proton/hooks';
15 import { useContactEmails } from '@proton/mail/contactEmails/hooks';
16 import {
17     addVCardProperty,
18     getSortedProperties,
19     getVCardProperties,
20     removeVCardProperty,
21     updateVCardContact,
22 } from '@proton/shared/lib/contacts/properties';
23 import { isContactNameValid, isFirstLastNameValid } from '@proton/shared/lib/contacts/property';
24 import { prepareForEdition } from '@proton/shared/lib/contacts/surgery';
25 import { isMultiValue } from '@proton/shared/lib/contacts/vcard';
26 import { getOtherInformationFields } from '@proton/shared/lib/helpers/contacts';
27 import { canonicalizeEmail, validateEmailAddress } from '@proton/shared/lib/helpers/email';
28 import type { ContactEmailModel } from '@proton/shared/lib/interfaces/contacts/Contact';
29 import type { VCardContact, VCardProperty, VcardNValue } from '@proton/shared/lib/interfaces/contacts/VCard';
30 import type { SimpleMap } from '@proton/shared/lib/interfaces/utils';
31 import isTruthy from '@proton/utils/isTruthy';
32 import randomIntFromInterval from '@proton/utils/randomIntFromInterval';
34 import type { ContactGroupEditProps } from '../group/ContactGroupEditModal';
35 import useApplyGroups from '../hooks/useApplyGroups';
36 import { useSaveVCardContact } from '../hooks/useSaveVCardContact';
37 import type { ContactGroupLimitReachedProps } from '../modals/ContactGroupLimitReachedModal';
38 import type { ContactImageProps } from '../modals/ContactImageModal';
39 import ContactEditProperties from './ContactEditProperties';
40 import ContactEditProperty from './ContactEditProperty';
42 const otherInformationFields = getOtherInformationFields().map(({ value }) => value);
44 export interface ContactEditProps {
45     contactID?: string;
46     vCardContact?: VCardContact;
47     newField?: string;
50 export interface ContactEditModalProps {
51     onUpgrade: () => void;
52     onChange?: () => void;
53     onSelectImage: (props: ContactImageProps) => void;
54     onGroupEdit: (props: ContactGroupEditProps) => void;
55     onLimitReached: (props: ContactGroupLimitReachedProps) => void;
58 type Props = ContactEditProps & ContactEditModalProps & ModalProps;
60 const ContactEditModal = ({
61     contactID,
62     vCardContact: inputVCardContact = { fn: [] },
63     newField,
64     onUpgrade,
65     onChange,
66     onSelectImage,
67     onGroupEdit,
68     onLimitReached,
69     ...rest
70 }: Props) => {
71     const { createNotification } = useNotifications();
72     const [loading, withLoading] = useLoading();
73     const { call } = useEventManager();
74     const [isSubmitted, setIsSubmitted] = useState(false);
76     const [vCardContact, setVCardContact] = useState<VCardContact>(prepareForEdition(inputVCardContact));
78     const displayNameFieldRef = useRef<HTMLInputElement>(null);
79     const firstNameFieldRef = useRef<HTMLInputElement>(null);
80     const [contactEmails = [], loadingContactEmails] = useContactEmails();
81     const [modelContactEmails, setModelContactEmails] = useState<SimpleMap<ContactEmailModel>>({});
83     const saveVCardContact = useSaveVCardContact();
84     const { applyGroups, contactGroupLimitReachedModal } = useApplyGroups();
85     const title = contactID ? c('Title').t`Edit contact` : c('Title').t`Create contact`;
87     const displayNameProperty = getSortedProperties(vCardContact, 'fn')[0] as VCardProperty<string>;
88     const nameProperty = getSortedProperties(vCardContact, 'n')[0] as VCardProperty<VcardNValue>;
89     const photoProperty = getSortedProperties(vCardContact, 'photo')[0] as VCardProperty<string>;
91     const getContactEmail = (email: string) =>
92         contactEmails.find((contactEmail) => {
93             if (contactID) {
94                 return (
95                     contactEmail.ContactID === contactID &&
96                     canonicalizeEmail(contactEmail.Email) === canonicalizeEmail(email)
97                 );
98             }
99             // If the contact does not exist before adding to contact group, contactID is not defined, and we have no way of getting it.
100             // If we rely on contactID, adding contact groups would become impossible.
101             // To avoid adding to the wrong contact, check the contact name + the email instead of contactID.
102             // This is still not perfect, because creating a new contact with the same name and same address than one existing
103             // might (depending on the first one found in the list) add the group to the older contact.
104             // That's a super rare case, so I will suggest we live with this "bug".
105             // ---
106             // We also need to trim the value, as form values aren't trimmed until we save the new contact.
107             const { familyNames, givenNames } = nameProperty.value;
108             const fullName = `${givenNames.join(' ').trim()} ${familyNames.join(' ').trim()}`.trim();
109             const displayName = displayNameProperty.value.trim();
111             return (
112                 (fullName === contactEmail.Name || displayName === contactEmail.Name) &&
113                 canonicalizeEmail(contactEmail.Email) === canonicalizeEmail(email)
114             );
115         });
117     useEffect(() => {
118         if (loadingContactEmails) {
119             return;
120         }
122         const newModelContactEmails = { ...modelContactEmails };
124         const emails = vCardContact.email || [];
125         const displayName = displayNameProperty.value;
126         const givenName = vCardContact?.n?.value.givenNames.join(' ').trim() || '';
127         const familyName = vCardContact?.n?.value.familyNames.join(' ').trim() || '';
128         const computedName = `${givenName} ${familyName}`;
130         // The name can either be the display name, or the computed name if we're creating a new contact
131         const Name = contactID ? displayName : displayName || computedName;
133         emails.forEach((emailProperty) => {
134             const uid = emailProperty.uid;
135             const email = emailProperty.value || '';
137             const existingModel = Object.values(newModelContactEmails).find(
138                 (contactEmail) => contactEmail?.uid === uid
139             );
141             if (existingModel) {
142                 if (existingModel.Email !== email) {
143                     const oldEmail = existingModel.Email;
144                     newModelContactEmails[email] = {
145                         ...existingModel,
146                         Email: email,
147                         Name,
148                     };
149                     delete newModelContactEmails[oldEmail];
150                 } else {
151                     existingModel.Name = Name;
152                 }
153                 return;
154             }
156             const existingContactEmail = getContactEmail(email);
158             if (existingContactEmail) {
159                 newModelContactEmails[email] = {
160                     ...existingContactEmail,
161                     uid,
162                     changes: {},
163                     Name,
164                 };
165                 return;
166             }
168             newModelContactEmails[email] = {
169                 uid,
170                 changes: {},
171                 Email: email,
172                 ContactID: contactID || '',
173                 LabelIDs: [],
174                 Name,
175             };
176         });
178         setModelContactEmails(newModelContactEmails);
179     }, [loadingContactEmails, vCardContact.email, vCardContact.n]);
181     // The condition defining if the form is valid is different if we are editing an existing contact or creating a new one
182     // In all cases we want to make sure that all emails are correct
183     const isFormValid = () => {
184         const allEmailsAddress = vCardContact.email?.map((emailProperty) => emailProperty.value).filter(isTruthy) ?? [];
186         // Check if all present address are valid email addresses
187         if (!allEmailsAddress.every((email) => validateEmailAddress(email))) {
188             return false;
189         }
191         const displayName = displayNameProperty.value.trim();
193         const givenName = nameProperty.value.givenNames[0].trim();
194         const familyName = nameProperty.value.familyNames[0].trim();
195         const fullName = `${givenName} ${familyName}`;
197         // Check if there is any name present in the contact
198         if (!familyName && !givenName && !displayName) {
199             return false;
200         }
202         // Check if the last name is valid
203         if (familyName && !isFirstLastNameValid(familyName)) {
204             return false;
205         }
207         // Check if the first name is valid
208         if (givenName && !isFirstLastNameValid(givenName)) {
209             return false;
210         }
212         // Check if the display name is valid when editing a contact
213         if ((contactID && displayName && !isContactNameValid(displayName)) || (contactID && !displayName)) {
214             return false;
215         }
217         // Check if the full name is valid when creating a contact
218         if ((!contactID && fullName && !isContactNameValid(fullName)) || (!contactID && !fullName)) {
219             return false;
220         }
222         return true;
223     };
225     const handleRemove = (propertyUID: string) => {
226         setVCardContact((vCardContact) => {
227             return removeVCardProperty(vCardContact, propertyUID);
228         });
229     };
231     const focusOnField = (uid: string) => {
232         const elm = document.querySelector(`[data-contact-property-id="${uid}"]`) as HTMLElement;
234         // Try to focus on the input field, if not present try the textarea
235         const hasInput = elm?.querySelector('input');
236         if (hasInput) {
237             hasInput.focus();
238             return;
239         }
241         elm?.querySelector('textarea')?.focus();
242     };
244     const handleAdd = (inputField?: string) => () => {
245         let field = inputField;
247         if (!field) {
248             // Get random field from other info, but not a limited one
249             const properties = getVCardProperties(vCardContact);
250             const filteredOtherInformationFields = otherInformationFields.filter(
251                 (field) => isMultiValue(field) || !properties.find((property) => property.field === field)
252             );
254             const index = randomIntFromInterval(0, filteredOtherInformationFields.length - 1);
256             field = filteredOtherInformationFields[index];
257         }
259         setVCardContact((vCardContact) => {
260             const { newVCardContact, newVCardProperty } = addVCardProperty(vCardContact, { field } as VCardProperty);
261             setTimeout(() => focusOnField(newVCardProperty.uid));
262             return newVCardContact;
263         });
264     };
266     const saveContactGroups = useHandler(async () => {
267         await Promise.all(
268             Object.values(modelContactEmails).map(async (modelContactEmail) => {
269                 if (modelContactEmail) {
270                     const contactEmail = getContactEmail(modelContactEmail.Email);
271                     if (contactEmail) {
272                         await applyGroups([contactEmail], modelContactEmail.changes, true);
273                     }
274                 }
275             })
276         );
277     });
279     const handleSubmit = async () => {
280         setIsSubmitted(true);
281         if (!isFormValid()) {
282             firstNameFieldRef.current?.focus();
283             return;
284         }
286         try {
287             await saveVCardContact(contactID, vCardContact);
288             await call();
289             await saveContactGroups();
290             onChange?.();
291             createNotification({ text: c('Success').t`Contact saved` });
292         } finally {
293             rest.onClose?.();
294         }
295     };
297     const handleChangeVCard = (property: VCardProperty) => {
298         setVCardContact((vCardContact) => {
299             return updateVCardContact(vCardContact, property);
300         });
301     };
303     const handleContactEmailChange = (contactEmail: ContactEmailModel) =>
304         setModelContactEmails((modelContactEmails) => ({ ...modelContactEmails, [contactEmail.Email]: contactEmail }));
306     useEffect(() => {
307         if (newField) {
308             handleAdd(newField)();
309         }
310     }, [newField]);
312     // Default focus on name field
313     useEffect(() => {
314         firstNameFieldRef.current?.focus();
315     }, []);
317     return (
318         <>
319             <ModalTwo size="large" className="contacts-modal" {...rest}>
320                 <ModalTwoHeader title={title} />
321                 <ModalTwoContent>
322                     <div className="mb-4">
323                         <ContactEditProperty
324                             ref={firstNameFieldRef}
325                             vCardContact={vCardContact}
326                             isSubmitted={isSubmitted}
327                             onRemove={handleRemove}
328                             actionRow={false}
329                             vCardProperty={nameProperty}
330                             onChangeVCard={handleChangeVCard}
331                             onUpgrade={onUpgrade}
332                             onSelectImage={onSelectImage}
333                             onGroupEdit={onGroupEdit}
334                         />
335                         <ContactEditProperty
336                             ref={displayNameFieldRef}
337                             vCardContact={vCardContact}
338                             isSubmitted={isSubmitted}
339                             onRemove={handleRemove}
340                             actionRow={false}
341                             vCardProperty={displayNameProperty}
342                             onChangeVCard={handleChangeVCard}
343                             onUpgrade={onUpgrade}
344                             onSelectImage={onSelectImage}
345                             onGroupEdit={onGroupEdit}
346                         />
348                         <ContactEditProperty
349                             vCardContact={vCardContact}
350                             isSubmitted={isSubmitted}
351                             onRemove={handleRemove}
352                             actionRow
353                             fixedType
354                             vCardProperty={photoProperty}
355                             onChangeVCard={handleChangeVCard}
356                             onUpgrade={onUpgrade}
357                             onSelectImage={onSelectImage}
358                             onGroupEdit={onGroupEdit}
359                         />
360                     </div>
361                     <ContactEditProperties
362                         field="fn"
363                         isSignatureVerified
364                         isSubmitted={isSubmitted}
365                         onRemove={handleRemove}
366                         vCardContact={vCardContact}
367                         onChangeVCard={handleChangeVCard}
368                         onUpgrade={onUpgrade}
369                         onSelectImage={onSelectImage}
370                         onGroupEdit={onGroupEdit}
371                     />
372                     <ContactEditProperties
373                         field="email"
374                         isSignatureVerified
375                         isSubmitted={isSubmitted}
376                         onRemove={handleRemove}
377                         sortable
378                         onAdd={handleAdd('email')}
379                         contactEmails={modelContactEmails}
380                         onContactEmailChange={handleContactEmailChange}
381                         vCardContact={vCardContact}
382                         onChangeVCard={handleChangeVCard}
383                         onUpgrade={onUpgrade}
384                         onSelectImage={onSelectImage}
385                         onGroupEdit={onGroupEdit}
386                         onLimitReached={onLimitReached}
387                     />
388                     {['tel', 'adr', 'bday', 'note'].map((item) => (
389                         <ContactEditProperties
390                             key={item}
391                             field={item}
392                             isSignatureVerified
393                             isSubmitted={isSubmitted}
394                             onRemove={handleRemove}
395                             sortable
396                             onAdd={handleAdd(item)}
397                             vCardContact={vCardContact}
398                             onChangeVCard={handleChangeVCard}
399                             onUpgrade={onUpgrade}
400                             onSelectImage={onSelectImage}
401                             onGroupEdit={onGroupEdit}
402                         />
403                     ))}
404                     <ContactEditProperties
405                         isSubmitted={isSubmitted}
406                         isSignatureVerified
407                         onRemove={handleRemove}
408                         onAdd={handleAdd()}
409                         vCardContact={vCardContact}
410                         onChangeVCard={handleChangeVCard}
411                         onUpgrade={onUpgrade}
412                         onSelectImage={onSelectImage}
413                         onGroupEdit={onGroupEdit}
414                     />
415                 </ModalTwoContent>
416                 <ModalTwoFooter>
417                     <Button onClick={rest.onClose}>{c('Action').t`Cancel`}</Button>
418                     <Button
419                         color="norm"
420                         loading={loading}
421                         data-testid="create-contact:save"
422                         onClick={() => withLoading(handleSubmit())}
423                     >
424                         {c('Action').t`Save`}
425                     </Button>
426                 </ModalTwoFooter>
427             </ModalTwo>
428             {contactGroupLimitReachedModal}
429         </>
430     );
433 export default ContactEditModal;