Cleanup - unused files / unused exports / duplicate exports
[ProtonMail-WebClient.git] / packages / components / containers / contacts / ContactGroupDropdown.tsx
blob885077581526adff910238dc5635fc020b986a00
1 import type { ChangeEvent, FormEvent, ReactNode } from 'react';
2 import { useEffect, useMemo, useState } from 'react';
4 import { c } from 'ttag';
6 import { useUser } from '@proton/account/user/hooks';
7 import type { ButtonProps } from '@proton/atoms';
8 import { Button } from '@proton/atoms';
9 import Dropdown from '@proton/components/components/dropdown/Dropdown';
10 import DropdownButton from '@proton/components/components/dropdown/DropdownButton';
11 import { DropdownSizeUnit } from '@proton/components/components/dropdown/utils';
12 import Icon from '@proton/components/components/icon/Icon';
13 import Checkbox from '@proton/components/components/input/Checkbox';
14 import SearchInput from '@proton/components/components/input/SearchInput';
15 import usePopperAnchor from '@proton/components/components/popper/usePopperAnchor';
16 import Tooltip from '@proton/components/components/tooltip/Tooltip';
17 import { useContactGroups } from '@proton/mail';
18 import { useContactEmails } from '@proton/mail/contactEmails/hooks';
19 import { useMailSettings } from '@proton/mail/mailSettings/hooks';
20 import {
21     getContactGroupsDelayedSaveChanges,
22     hasReachedContactGroupMembersLimit,
23 } from '@proton/shared/lib/contacts/helpers/contactGroup';
24 import { validateEmailAddress } from '@proton/shared/lib/helpers/email';
25 import isDeepEqual from '@proton/shared/lib/helpers/isDeepEqual';
26 import { normalize } from '@proton/shared/lib/helpers/string';
27 import type { ContactEmail, ContactGroup } from '@proton/shared/lib/interfaces/contacts/Contact';
28 import clsx from '@proton/utils/clsx';
29 import generateUID from '@proton/utils/generateUID';
31 import Mark from '../../components/text/Mark';
32 import type { ContactGroupEditProps } from './group/ContactGroupEditModal';
33 import useApplyGroups from './hooks/useApplyGroups';
34 import type { ContactGroupLimitReachedProps } from './modals/ContactGroupLimitReachedModal';
35 import type { SelectEmailsProps } from './modals/SelectEmailsModal';
37 import './ContactGroupDropdown.scss';
39 const UNCHECKED = 0;
40 const CHECKED = 1;
41 const INDETERMINATE = 2;
43 /**
44  * Build initial dropdown model
45  */
46 const getModel = (contactGroups: ContactGroup[] = [], contactEmails: ContactEmail[]) => {
47     if (!contactGroups.length) {
48         return Object.create(null);
49     }
51     return contactGroups.reduce((acc, { ID }) => {
52         const inGroup = contactEmails.filter(({ LabelIDs = [] }) => {
53             return LabelIDs.includes(ID);
54         });
55         if (inGroup.length) {
56             acc[ID] = contactEmails.length === inGroup.length ? CHECKED : INDETERMINATE;
57         } else {
58             acc[ID] = UNCHECKED;
59         }
60         return acc;
61     }, Object.create(null));
64 interface Props extends ButtonProps {
65     children?: ReactNode;
66     className?: string;
67     disabled?: boolean;
68     contactEmails: ContactEmail[];
69     tooltip?: string;
70     forToolbar?: boolean;
71     onDelayedSave?: (changes: { [groupID: string]: boolean }) => void;
72     onLock?: (lock: boolean) => void;
73     onSuccess?: () => void;
74     onGroupEdit: (props: ContactGroupEditProps) => void;
75     onLimitReached?: (props: ContactGroupLimitReachedProps) => void;
76     onUpgrade: () => void;
77     // Required when called with more than 1 contactEmail at a time
78     onSelectEmails?: (props: SelectEmailsProps) => Promise<ContactEmail[]>;
81 const ContactGroupDropdown = ({
82     children,
83     className,
84     contactEmails,
85     disabled = false,
86     forToolbar = false,
87     tooltip = c('Action').t`Add to group`,
88     onDelayedSave,
89     onLock: onLockWidget,
90     onSuccess,
91     onGroupEdit,
92     onLimitReached,
93     onUpgrade,
94     onSelectEmails,
95     ...rest
96 }: Props) => {
97     const [mailSettings] = useMailSettings();
98     const [{ hasPaidMail }] = useUser();
99     const [keyword, setKeyword] = useState('');
100     const [loading, setLoading] = useState(false);
101     const { anchorRef, isOpen, toggle, close } = usePopperAnchor<HTMLButtonElement>();
102     const [contactGroups = []] = useContactGroups();
103     const [userContactEmails = []] = useContactEmails();
104     const [initialModel, setInitialModel] = useState<{ [groupID: string]: number }>(Object.create(null));
105     const [model, setModel] = useState<{ [groupID: string]: number }>(Object.create(null));
106     const [uid] = useState(generateUID('contactGroupDropdown'));
107     const [lock, setLock] = useState(false);
108     const { applyGroups, contactGroupLimitReachedModal } = useApplyGroups(setLock, setLoading, onSelectEmails);
110     // If the name and email are not empty, we can create a new group, otherwise we disable the button
111     const { Email, Name } = contactEmails[0] ?? {};
112     const canCreateNewGroup = Email !== '' && Name?.trim() !== '' && validateEmailAddress(Email ?? '');
114     useEffect(() => onLockWidget?.(isOpen), [isOpen]);
116     const handleClick = () => {
117         if (hasPaidMail) {
118             if (hasReachedContactGroupMembersLimit(contactEmails.length, mailSettings, false)) {
119                 toggle();
120             } else {
121                 onLimitReached?.({});
122             }
123         } else {
124             onUpgrade();
125         }
126     };
128     const handleCheck =
129         (contactGroupID: string) =>
130         ({ target }: ChangeEvent<HTMLInputElement>) =>
131             setModel({ ...model, [contactGroupID]: +target.checked });
133     const handleCreateContactGroup = async (groupID: string) => {
134         // If creating a group with a delayed save, check the associated checkbox
135         handleCheck(groupID);
137         // Do the delayed save with the group ID
138         if (onDelayedSave) {
139             onDelayedSave({ [groupID]: true });
140         }
141     };
143     const handleAdd = () => {
144         // Should be handled differently with the delayed save, because we need to add the current email to the new group
145         onGroupEdit({
146             selectedContactEmails: contactEmails,
147             onDelayedSave: onDelayedSave ? handleCreateContactGroup : undefined,
148         });
149         close();
150     };
152     const handleApply = async () => {
153         const changes = Object.entries(model).reduce<{ [groupID: string]: boolean }>((acc, [groupID, isChecked]) => {
154             if (isChecked !== initialModel[groupID]) {
155                 acc[groupID] = isChecked === CHECKED;
156             }
157             return acc;
158         }, {});
160         // Use delayed save when editing a contact, in this case contact might not be created yet so we save later
161         if (onDelayedSave) {
162             const updatedChanges = getContactGroupsDelayedSaveChanges({
163                 userContactEmails,
164                 changes,
165                 onLimitReached,
166                 model,
167                 initialModel,
168                 mailSettings,
169             });
171             onDelayedSave(updatedChanges);
172         } else {
173             await applyGroups(contactEmails, changes);
174         }
176         close();
177         onSuccess?.();
178     };
180     useEffect(() => {
181         if (isOpen) {
182             const initialModel = getModel(contactGroups, contactEmails);
183             setInitialModel(initialModel);
184             setModel(initialModel);
185         }
186     }, [contactGroups, contactEmails, isOpen]);
188     const isPristine = useMemo(() => {
189         return isDeepEqual(initialModel, model);
190     }, [initialModel, model]);
192     const filteredContactGroups = useMemo(() => {
193         if (!Array.isArray(contactGroups)) {
194             return [];
195         }
196         const normalizedKeyword = normalize(keyword, true);
197         if (!normalizedKeyword.length) {
198             return contactGroups;
199         }
200         return contactGroups.filter(({ Name }) => normalize(Name, true).includes(normalizedKeyword));
201     }, [keyword, contactGroups]);
203     const handleSubmit = async (e: FormEvent) => {
204         e.preventDefault();
205         setLoading(true);
206         try {
207             await handleApply();
208         } finally {
209             setLoading(false);
210         }
211     };
213     return (
214         <>
215             <Tooltip title={tooltip}>
216                 <DropdownButton
217                     ref={anchorRef}
218                     isOpen={isOpen}
219                     onClick={handleClick}
220                     hasCaret={!forToolbar}
221                     disabled={disabled}
222                     className={clsx([forToolbar ? 'button-for-icon' : 'flex items-center', className])}
223                     {...rest}
224                 >
225                     {children}
226                 </DropdownButton>
227             </Tooltip>
228             <Dropdown
229                 id="contact-group-dropdown"
230                 className="contactGroupDropdown"
231                 isOpen={isOpen}
232                 anchorRef={anchorRef}
233                 onClose={close}
234                 autoClose={false}
235                 autoCloseOutside={!lock}
236                 size={{ maxWidth: DropdownSizeUnit.Viewport, maxHeight: DropdownSizeUnit.Viewport }}
237             >
238                 <form onSubmit={handleSubmit}>
239                     <div className="flex justify-space-between items-center m-4 mb-0">
240                         <strong>{c('Label').t`Add to group`}</strong>
241                         <Tooltip
242                             title={
243                                 canCreateNewGroup
244                                     ? c('Info').t`Create a new contact group`
245                                     : c('Info').t`Please provide a name and an email address for creating a group.`
246                             }
247                         >
248                             <div>
249                                 <Button
250                                     icon
251                                     color="norm"
252                                     size="small"
253                                     onClick={handleAdd}
254                                     className="flex items-center"
255                                     data-prevent-arrow-navigation
256                                     disabled={!canCreateNewGroup}
257                                 >
258                                     <Icon name="users" alt={c('Action').t`Create a new contact group`} /> +
259                                 </Button>
260                             </div>
261                         </Tooltip>
262                     </div>
263                     <div className="m-4 mb-0">
264                         <SearchInput
265                             value={keyword}
266                             onChange={setKeyword}
267                             autoFocus
268                             placeholder={c('Placeholder').t`Filter groups`}
269                             data-prevent-arrow-navigation
270                         />
271                     </div>
272                     <div className="overflow-auto mt-4 contactGroupDropdown-list-container">
273                         {filteredContactGroups.length ? (
274                             <ul className="unstyled my-0">
275                                 {filteredContactGroups.map(({ ID, Name, Color }) => {
276                                     const checkboxId = `${uid}${ID}`;
277                                     return (
278                                         <li
279                                             key={ID}
280                                             className="dropdown-item w-full flex flex-nowrap items-center py-2 px-4"
281                                         >
282                                             <Checkbox
283                                                 className="shrink-0"
284                                                 id={checkboxId}
285                                                 checked={model[ID] === CHECKED}
286                                                 indeterminate={model[ID] === INDETERMINATE}
287                                                 onChange={handleCheck(ID)}
288                                             >
289                                                 <Icon
290                                                     name="circle-filled"
291                                                     className="ml-1 mr-2 shrink-0"
292                                                     size={4}
293                                                     color={Color}
294                                                 />
295                                                 <span className="flex-1 text-ellipsis" title={Name}>
296                                                     <Mark value={keyword}>{Name}</Mark>
297                                                 </span>
298                                             </Checkbox>
299                                         </li>
300                                     );
301                                 })}
302                             </ul>
303                         ) : null}
304                         {!filteredContactGroups.length && keyword ? (
305                             <div className="w-full flex flex-nowrap items-center py-2 px-4">
306                                 <Icon name="exclamation-circle" className="mr-2" />
307                                 {c('Info').t`No group found`}
308                             </div>
309                         ) : null}
310                     </div>
311                     <div className="m-4">
312                         <Button
313                             color="norm"
314                             fullWidth
315                             loading={loading}
316                             disabled={isPristine || !filteredContactGroups.length}
317                             data-prevent-arrow-navigation
318                             type="submit"
319                             data-testid="contact-group-dropdown:apply-chosen-groups"
320                         >
321                             {c('Action').t`Apply`}
322                         </Button>
323                     </div>
324                 </form>
325             </Dropdown>
326             {contactGroupLimitReachedModal}
327         </>
328     );
331 export default ContactGroupDropdown;