Skip to content

Commit 7cd356e

Browse files
Merge pull request #80 from marmelab/multiple-contact-emails
Support multiple emails and phone numbers per contact
2 parents ba6cd76 + a49d838 commit 7cd356e

18 files changed

+917
-416
lines changed

.github/pull_request_template.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,6 @@ _Describe the steps required to test the changes_
1313
## Additional Checks
1414

1515
- [ ] The **documentation** is up to date
16-
- [ ] Tested with **fakerest** provider (see [related documentation](../doc/data-providers.md))
16+
- [ ] Tested with **fakerest** provider (see [related documentation](https://github.com/marmelab/atomic-crm/blob/main/doc/developer/data-providers.md))
1717

1818
Also, please make sure to read the [contributing guidelines](https://github.com/marmelab/atomic-crm#contributing).

doc/user/import-contacts.md

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,9 @@ Atomic CRM displays an import contact buttons in the initial user onboarding pag
1111
An example of the expected CSV file is available in the contact import modal:
1212

1313
```csv
14-
first_name,last_name,gender,title,company,email,phone_1_number,phone_1_type,phone_2_number,phone_2_type,background,first_seen,last_seen,has_newsletter,status,tags,linkedin_url
15-
John,Doe,male,Sales Executive,Acme,[email protected],659-980-2015,work,740.645.3807,home,,2024-07-01,2024-07-01T11:54:49.950Z,FALSE,in-contract,"influencer, developer",https://www.linkedin.com/in/johndoe
16-
Jane,Doe,female,Designer,Acme,[email protected],659-980-2020,work,740.647.3802,home,,2024-07-01,2024-07-01T11:54:49.950Z,FALSE,in-contract,"UI, design",https://www.linkedin.com/in/janedoe
17-
Camille,Brown,nonbinary,Accountant,Atomic Corp,[email protected],659-910-3010,work,740.698.3752,home,,2024-07-01,2024-07-01T11:54:49.950Z,FALSE,in-contract,"payroll, accountant",,
14+
first_name,last_name,gender,title,background,first_seen,last_seen,has_newsletter,status,tags,linkedin_url,company,email_work,email_home,email_other,phone_work,phone_home,phone_other
15+
John,Doe,male,Sales Executive,,2024-07-01T00:00:00+00:00,2024-07-01T11:54:49.95+00:00,false,in-contract,"influencer, developer",https://www.linkedin.com/in/johndoe,Acme,[email protected],[email protected],[email protected],659-980-2015,740.645.3807,(446) 758-2122
16+
Jane,Doe,female,Designer,,2024-07-01T00:00:00+00:00,2024-07-01T11:54:49.95+00:00,false,in-contract,"UI, design",https://www.linkedin.com/in/janedoe,Acme,,,[email protected],659-980-2020,740.647.3802,
1817
```
1918

2019
When importing contacts, companies and tags will be automatically matched if they exist on the system, or imported ortherwise.

makefile

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@ start-supabase: ## start supabase locally
1212
start-supabase-functions: ## start the supabase Functions watcher
1313
npx supabase functions serve --env-file supabase/functions/.env.development
1414

15+
supabase-migrate-database: ## apply the migrations to the database
16+
npx supabase migration up
17+
18+
supabase-reset-database: ## reset (and clear!) the database
19+
npx supabase db reset
20+
1521
start-app: ## start the app locally
1622
npm run dev
1723

src/contacts/ContactAside.tsx

Lines changed: 69 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import LinkedInIcon from '@mui/icons-material/LinkedIn';
33
import PhoneIcon from '@mui/icons-material/Phone';
44
import { Box, Divider, Stack, SvgIcon, Typography } from '@mui/material';
55
import {
6+
ArrayField,
67
DateField,
78
DeleteButton,
89
EditButton,
@@ -12,9 +13,11 @@ import {
1213
ReferenceManyField,
1314
SelectField,
1415
ShowButton,
16+
SingleFieldList,
1517
TextField,
1618
UrlField,
1719
useRecordContext,
20+
WithRecord,
1821
} from 'react-admin';
1922
import { AddTask } from '../tasks/AddTask';
2023
import { TasksIterator } from '../tasks/TasksIterator';
@@ -23,6 +26,7 @@ import { TagsListEdit } from './TagsListEdit';
2326
import { useLocation } from 'react-router';
2427
import { useConfigurationContext } from '../root/ConfigurationContext';
2528
import { Contact, Sale } from '../types';
29+
import { ReactNode } from 'react';
2630

2731
export const ContactAside = ({ link = 'edit' }: { link?: 'edit' | 'show' }) => {
2832
const location = useLocation();
@@ -40,89 +44,57 @@ export const ContactAside = ({ link = 'edit' }: { link?: 'edit' | 'show' }) => {
4044
</Box>
4145
<Typography variant="subtitle2">Personal info</Typography>
4246
<Divider sx={{ mb: 2 }} />
43-
{record.email && (
44-
<Stack
45-
direction="row"
46-
alignItems="center"
47-
gap={1}
48-
minHeight={24}
49-
>
50-
<EmailIcon color="disabled" fontSize="small" />
51-
<EmailField source="email" />
52-
</Stack>
53-
)}
47+
<ArrayField source="email_jsonb">
48+
<SingleFieldList linkType={false} gap={0} direction="column">
49+
<PersonalInfoRow
50+
icon={<EmailIcon color="disabled" fontSize="small" />}
51+
primary={<EmailField source="email" />}
52+
showType
53+
/>
54+
</SingleFieldList>
55+
</ArrayField>
5456
{record.has_newsletter && (
5557
<Typography variant="body2" color="textSecondary" pl={3.5}>
5658
Subscribed to newsletter
5759
</Typography>
5860
)}
5961

6062
{record.linkedin_url && (
61-
<Stack
62-
direction="row"
63-
alignItems="center"
64-
gap={1}
65-
minHeight={24}
66-
>
67-
<LinkedInIcon color="disabled" fontSize="small" />
68-
<UrlField
69-
source="linkedin_url"
70-
content="LinkedIn profile"
71-
target="_blank"
72-
rel="noopener"
73-
/>
74-
</Stack>
75-
)}
76-
{record.phone_1_number && (
77-
<Stack direction="row" alignItems="center" gap={1}>
78-
<PhoneIcon color="disabled" fontSize="small" />
79-
<Box>
80-
<TextField source="phone_1_number" />{' '}
81-
{record.phone_1_type !== 'Other' && (
82-
<TextField
83-
source="phone_1_type"
84-
color="textSecondary"
85-
/>
86-
)}
87-
</Box>
88-
</Stack>
89-
)}
90-
{record.phone_2_number && (
91-
<Stack
92-
direction="row"
93-
alignItems="center"
94-
gap={1}
95-
minHeight={24}
96-
>
97-
<PhoneIcon color="disabled" fontSize="small" />
98-
<Box>
99-
<TextField source="phone_2_number" />{' '}
100-
{record.phone_2_type !== 'Other' && (
101-
<TextField
102-
source="phone_2_type"
103-
color="textSecondary"
104-
/>
105-
)}
106-
</Box>
107-
</Stack>
63+
<PersonalInfoRow
64+
icon={<LinkedInIcon color="disabled" fontSize="small" />}
65+
primary={
66+
<UrlField
67+
source="linkedin_url"
68+
content="LinkedIn profile"
69+
target="_blank"
70+
rel="noopener"
71+
/>
72+
}
73+
/>
10874
)}
75+
<ArrayField source="phone_jsonb">
76+
<SingleFieldList linkType={false} gap={0} direction="column">
77+
<PersonalInfoRow
78+
icon={<PhoneIcon color="disabled" fontSize="small" />}
79+
primary={<TextField source="number" />}
80+
showType
81+
/>
82+
</SingleFieldList>
83+
</ArrayField>
10984
<SelectField
11085
source="gender"
11186
choices={contactGender}
11287
optionText={choice => (
113-
<Stack
114-
direction="row"
115-
alignItems="center"
116-
gap={1}
117-
minHeight={24}
118-
>
119-
<SvgIcon
120-
component={choice.icon}
121-
color="disabled"
122-
fontSize="small"
123-
></SvgIcon>
124-
<span>{choice.label}</span>
125-
</Stack>
88+
<PersonalInfoRow
89+
icon={
90+
<SvgIcon
91+
component={choice.icon}
92+
color="disabled"
93+
fontSize="small"
94+
/>
95+
}
96+
primary={<span>{choice.label}</span>}
97+
/>
12698
)}
12799
optionValue="value"
128100
/>
@@ -197,3 +169,29 @@ export const ContactAside = ({ link = 'edit' }: { link?: 'edit' | 'show' }) => {
197169
</Box>
198170
);
199171
};
172+
173+
const PersonalInfoRow = ({
174+
icon,
175+
primary,
176+
showType,
177+
}: {
178+
icon: ReactNode;
179+
primary: ReactNode;
180+
showType?: boolean;
181+
}) => (
182+
<Stack direction="row" alignItems="center" gap={1} minHeight={24}>
183+
{icon}
184+
<Box display="flex" flexWrap="wrap" columnGap={0.5} rowGap={0}>
185+
{primary}
186+
{showType ? (
187+
<WithRecord
188+
render={row =>
189+
row.type !== 'Other' && (
190+
<TextField source="type" color="textSecondary" />
191+
)
192+
}
193+
/>
194+
) : null}
195+
</Box>
196+
</Stack>
197+
);

src/contacts/ContactInputs.tsx

Lines changed: 48 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@ import {
77
useTheme,
88
} from '@mui/material';
99
import {
10+
ArrayInput,
1011
AutocompleteInput,
1112
BooleanInput,
1213
RadioButtonGroupInput,
1314
ReferenceInput,
1415
SelectInput,
16+
SimpleFormIterator,
1517
TextInput,
1618
email,
1719
required,
@@ -33,16 +35,16 @@ export const ContactInputs = () => {
3335
return (
3436
<Stack gap={2} p={1}>
3537
<Avatar />
36-
<Stack gap={4} direction={isMobile ? 'column' : 'row'}>
37-
<Stack gap={4} flex={1}>
38+
<Stack gap={3} direction={isMobile ? 'column' : 'row'}>
39+
<Stack gap={4} flex={4}>
3840
<ContactIdentityInputs />
3941
<ContactPositionInputs />
4042
</Stack>
4143
<Divider
4244
orientation={isMobile ? 'horizontal' : 'vertical'}
4345
flexItem
4446
/>
45-
<Stack gap={4} flex={1}>
47+
<Stack gap={4} flex={5}>
4648
<ContactPersonalInformationInputs />
4749
<ContactMiscInputs />
4850
</Stack>
@@ -148,43 +150,48 @@ const ContactPersonalInformationInputs = () => {
148150
return (
149151
<Stack>
150152
<Typography variant="h6">Personal info</Typography>
151-
<TextInput
152-
source="email"
153+
<ArrayInput
154+
source="email_jsonb"
155+
label="Email addresses"
153156
helperText={false}
154-
validate={email()}
155-
onPaste={handleEmailPaste}
156-
onBlur={handleEmailBlur}
157-
/>
158-
<Stack gap={1} flexDirection="row">
159-
<TextInput
160-
source="phone_1_number"
161-
label="Phone number 1"
162-
helperText={false}
163-
/>
164-
<SelectInput
165-
source="phone_1_type"
166-
label="Type"
167-
helperText={false}
168-
optionText={choice => choice.id}
169-
choices={[{ id: 'Work' }, { id: 'Home' }, { id: 'Other' }]}
170-
defaultValue={'Work'}
171-
/>
172-
</Stack>
173-
<Stack gap={1} flexDirection="row">
174-
<TextInput
175-
source="phone_2_number"
176-
label="Phone number 2"
177-
helperText={false}
178-
/>
179-
<SelectInput
180-
source="phone_2_type"
181-
label="Type"
182-
helperText={false}
183-
optionText={choice => choice.id}
184-
choices={[{ id: 'Work' }, { id: 'Home' }, { id: 'Other' }]}
185-
defaultValue={'Work'}
186-
/>
187-
</Stack>
157+
>
158+
<SimpleFormIterator inline disableReordering>
159+
<TextInput
160+
source="email"
161+
helperText={false}
162+
validate={email()}
163+
onPaste={handleEmailPaste}
164+
onBlur={handleEmailBlur}
165+
/>
166+
<SelectInput
167+
source="type"
168+
helperText={false}
169+
optionText="id"
170+
choices={personalInfoTypes}
171+
defaultValue="Work"
172+
fullWidth={false}
173+
sx={{ width: 100, minWidth: 100 }}
174+
/>
175+
</SimpleFormIterator>
176+
</ArrayInput>
177+
<ArrayInput
178+
source="phone_jsonb"
179+
label="Phone numbers"
180+
helperText={false}
181+
>
182+
<SimpleFormIterator inline disableReordering>
183+
<TextInput source="number" helperText={false} />
184+
<SelectInput
185+
source="type"
186+
helperText={false}
187+
optionText="id"
188+
choices={personalInfoTypes}
189+
defaultValue="Work"
190+
fullWidth={false}
191+
sx={{ width: 100, minWidth: 100 }}
192+
/>
193+
</SimpleFormIterator>
194+
</ArrayInput>
188195
<TextInput
189196
source="linkedin_url"
190197
label="Linkedin URL"
@@ -195,6 +202,8 @@ const ContactPersonalInformationInputs = () => {
195202
);
196203
};
197204

205+
const personalInfoTypes = [{ id: 'Work' }, { id: 'Home' }, { id: 'Other' }];
206+
198207
const ContactMiscInputs = () => {
199208
return (
200209
<Stack>

0 commit comments

Comments
 (0)