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';
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';
41 const INDETERMINATE = 2;
44 * Build initial dropdown model
46 const getModel = (contactGroups: ContactGroup[] = [], contactEmails: ContactEmail[]) => {
47 if (!contactGroups.length) {
48 return Object.create(null);
51 return contactGroups.reduce((acc, { ID }) => {
52 const inGroup = contactEmails.filter(({ LabelIDs = [] }) => {
53 return LabelIDs.includes(ID);
56 acc[ID] = contactEmails.length === inGroup.length ? CHECKED : INDETERMINATE;
61 }, Object.create(null));
64 interface Props extends ButtonProps {
68 contactEmails: ContactEmail[];
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 = ({
87 tooltip = c('Action').t`Add to group`,
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 = () => {
118 if (hasReachedContactGroupMembersLimit(contactEmails.length, mailSettings, false)) {
121 onLimitReached?.({});
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
139 onDelayedSave({ [groupID]: true });
143 const handleAdd = () => {
144 // Should be handled differently with the delayed save, because we need to add the current email to the new group
146 selectedContactEmails: contactEmails,
147 onDelayedSave: onDelayedSave ? handleCreateContactGroup : undefined,
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;
160 // Use delayed save when editing a contact, in this case contact might not be created yet so we save later
162 const updatedChanges = getContactGroupsDelayedSaveChanges({
171 onDelayedSave(updatedChanges);
173 await applyGroups(contactEmails, changes);
182 const initialModel = getModel(contactGroups, contactEmails);
183 setInitialModel(initialModel);
184 setModel(initialModel);
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)) {
196 const normalizedKeyword = normalize(keyword, true);
197 if (!normalizedKeyword.length) {
198 return contactGroups;
200 return contactGroups.filter(({ Name }) => normalize(Name, true).includes(normalizedKeyword));
201 }, [keyword, contactGroups]);
203 const handleSubmit = async (e: FormEvent) => {
215 <Tooltip title={tooltip}>
219 onClick={handleClick}
220 hasCaret={!forToolbar}
222 className={clsx([forToolbar ? 'button-for-icon' : 'flex items-center', className])}
229 id="contact-group-dropdown"
230 className="contactGroupDropdown"
232 anchorRef={anchorRef}
235 autoCloseOutside={!lock}
236 size={{ maxWidth: DropdownSizeUnit.Viewport, maxHeight: DropdownSizeUnit.Viewport }}
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>
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.`
254 className="flex items-center"
255 data-prevent-arrow-navigation
256 disabled={!canCreateNewGroup}
258 <Icon name="users" alt={c('Action').t`Create a new contact group`} /> +
263 <div className="m-4 mb-0">
266 onChange={setKeyword}
268 placeholder={c('Placeholder').t`Filter groups`}
269 data-prevent-arrow-navigation
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}`;
280 className="dropdown-item w-full flex flex-nowrap items-center py-2 px-4"
285 checked={model[ID] === CHECKED}
286 indeterminate={model[ID] === INDETERMINATE}
287 onChange={handleCheck(ID)}
291 className="ml-1 mr-2 shrink-0"
295 <span className="flex-1 text-ellipsis" title={Name}>
296 <Mark value={keyword}>{Name}</Mark>
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`}
311 <div className="m-4">
316 disabled={isPristine || !filteredContactGroups.length}
317 data-prevent-arrow-navigation
319 data-testid="contact-group-dropdown:apply-chosen-groups"
321 {c('Action').t`Apply`}
326 {contactGroupLimitReachedModal}
331 export default ContactGroupDropdown;