Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ae759a089 | ||
|
|
9bf6d9afee | ||
|
|
9ee07b51d2 | ||
|
|
1d5e021b56 | ||
|
|
52bc2d17fc | ||
|
|
3f650839dc | ||
|
|
9accdb7f18 | ||
|
|
b69ac4c1b1 | ||
|
|
2be478b677 | ||
|
|
0bb9e4ac53 | ||
|
|
8abd65e90c | ||
|
|
ffae202399 | ||
|
|
24573f70cf | ||
|
|
44266f4c3c | ||
|
|
7f9cb52994 | ||
|
|
521a83e5bf | ||
|
|
d0101ba849 | ||
|
|
6c506c21c1 | ||
|
|
35c108a968 | ||
|
|
16316ff172 | ||
|
|
fa59e96762 | ||
|
|
c15ae95c5f | ||
|
|
fdc26a1597 | ||
|
|
4af84ed826 | ||
|
|
ea19adb20f | ||
|
|
fa8c2183d2 |
@@ -3441,6 +3441,7 @@ type Mutation {
|
||||
updateNavigationMenuItem(input: UpdateOneNavigationMenuItemInput!): NavigationMenuItem!
|
||||
deleteManyNavigationMenuItems(ids: [UUID!]!): [NavigationMenuItem!]!
|
||||
deleteNavigationMenuItem(id: UUID!): NavigationMenuItem!
|
||||
uploadEmailAttachmentFile(file: Upload!): FileWithSignedUrl!
|
||||
uploadAIChatFile(file: Upload!): FileWithSignedUrl!
|
||||
uploadWorkflowFile(file: Upload!): FileWithSignedUrl!
|
||||
uploadWorkspaceLogo(file: Upload!): FileWithSignedUrl!
|
||||
@@ -4592,6 +4593,12 @@ input SendEmailInput {
|
||||
subject: String!
|
||||
body: String!
|
||||
inReplyTo: String
|
||||
files: [SendEmailAttachmentInput!]
|
||||
}
|
||||
|
||||
input SendEmailAttachmentInput {
|
||||
id: String!
|
||||
name: String!
|
||||
}
|
||||
|
||||
input EmailAccountConnectionParameters {
|
||||
@@ -4658,6 +4665,7 @@ enum FileFolder {
|
||||
FilesField
|
||||
Dependencies
|
||||
Workflow
|
||||
EmailAttachment
|
||||
AppTarball
|
||||
GeneratedSdkClient
|
||||
}
|
||||
|
||||
@@ -2894,6 +2894,7 @@ export interface Mutation {
|
||||
updateNavigationMenuItem: NavigationMenuItem
|
||||
deleteManyNavigationMenuItems: NavigationMenuItem[]
|
||||
deleteNavigationMenuItem: NavigationMenuItem
|
||||
uploadEmailAttachmentFile: FileWithSignedUrl
|
||||
uploadAIChatFile: FileWithSignedUrl
|
||||
uploadWorkflowFile: FileWithSignedUrl
|
||||
uploadWorkspaceLogo: FileWithSignedUrl
|
||||
@@ -3112,7 +3113,7 @@ export type AiModelRole = 'FAST' | 'SMART'
|
||||
|
||||
export type WorkspaceMigrationActionType = 'delete' | 'create' | 'update'
|
||||
|
||||
export type FileFolder = 'ProfilePicture' | 'WorkspaceLogo' | 'Attachment' | 'PersonPicture' | 'CorePicture' | 'File' | 'AgentChat' | 'BuiltLogicFunction' | 'BuiltFrontComponent' | 'PublicAsset' | 'Source' | 'FilesField' | 'Dependencies' | 'Workflow' | 'AppTarball' | 'GeneratedSdkClient'
|
||||
export type FileFolder = 'ProfilePicture' | 'WorkspaceLogo' | 'Attachment' | 'PersonPicture' | 'CorePicture' | 'File' | 'AgentChat' | 'BuiltLogicFunction' | 'BuiltFrontComponent' | 'PublicAsset' | 'Source' | 'FilesField' | 'Dependencies' | 'Workflow' | 'EmailAttachment' | 'AppTarball' | 'GeneratedSdkClient'
|
||||
|
||||
export interface Subscription {
|
||||
onEventSubscription?: EventSubscription
|
||||
@@ -6208,6 +6209,7 @@ export interface MutationGenqlSelection{
|
||||
updateNavigationMenuItem?: (NavigationMenuItemGenqlSelection & { __args: {input: UpdateOneNavigationMenuItemInput} })
|
||||
deleteManyNavigationMenuItems?: (NavigationMenuItemGenqlSelection & { __args: {ids: Scalars['UUID'][]} })
|
||||
deleteNavigationMenuItem?: (NavigationMenuItemGenqlSelection & { __args: {id: Scalars['UUID']} })
|
||||
uploadEmailAttachmentFile?: (FileWithSignedUrlGenqlSelection & { __args: {file: Scalars['Upload']} })
|
||||
uploadAIChatFile?: (FileWithSignedUrlGenqlSelection & { __args: {file: Scalars['Upload']} })
|
||||
uploadWorkflowFile?: (FileWithSignedUrlGenqlSelection & { __args: {file: Scalars['Upload']} })
|
||||
uploadWorkspaceLogo?: (FileWithSignedUrlGenqlSelection & { __args: {file: Scalars['Upload']} })
|
||||
@@ -6727,7 +6729,9 @@ export interface DeleteSsoInput {identityProviderId: Scalars['UUID']}
|
||||
|
||||
export interface EditSsoInput {id: Scalars['UUID'],status: SSOIdentityProviderStatus}
|
||||
|
||||
export interface SendEmailInput {connectedAccountId: Scalars['String'],to: Scalars['String'],cc?: (Scalars['String'] | null),bcc?: (Scalars['String'] | null),subject: Scalars['String'],body: Scalars['String'],inReplyTo?: (Scalars['String'] | null)}
|
||||
export interface SendEmailInput {connectedAccountId: Scalars['String'],to: Scalars['String'],cc?: (Scalars['String'] | null),bcc?: (Scalars['String'] | null),subject: Scalars['String'],body: Scalars['String'],inReplyTo?: (Scalars['String'] | null),files?: (SendEmailAttachmentInput[] | null)}
|
||||
|
||||
export interface SendEmailAttachmentInput {id: Scalars['String'],name: Scalars['String']}
|
||||
|
||||
export interface EmailAccountConnectionParameters {IMAP?: (ConnectionParameters | null),SMTP?: (ConnectionParameters | null),CALDAV?: (ConnectionParameters | null)}
|
||||
|
||||
@@ -9665,6 +9669,7 @@ export const enumFileFolder = {
|
||||
FilesField: 'FilesField' as const,
|
||||
Dependencies: 'Dependencies' as const,
|
||||
Workflow: 'Workflow' as const,
|
||||
EmailAttachment: 'EmailAttachment' as const,
|
||||
AppTarball: 'AppTarball' as const,
|
||||
GeneratedSdkClient: 'GeneratedSdkClient' as const
|
||||
}
|
||||
|
||||
@@ -88,9 +88,9 @@ export default {
|
||||
373,
|
||||
380,
|
||||
411,
|
||||
491,
|
||||
496,
|
||||
497
|
||||
492,
|
||||
497,
|
||||
498
|
||||
],
|
||||
"types": {
|
||||
"BillingProductDTO": {
|
||||
@@ -7308,6 +7308,15 @@ export default {
|
||||
]
|
||||
}
|
||||
],
|
||||
"uploadEmailAttachmentFile": [
|
||||
142,
|
||||
{
|
||||
"file": [
|
||||
380,
|
||||
"Upload!"
|
||||
]
|
||||
}
|
||||
],
|
||||
"uploadAIChatFile": [
|
||||
142,
|
||||
{
|
||||
@@ -8980,7 +8989,7 @@ export default {
|
||||
"String!"
|
||||
],
|
||||
"connectionParameters": [
|
||||
488,
|
||||
489,
|
||||
"EmailAccountConnectionParameters!"
|
||||
],
|
||||
"id": [
|
||||
@@ -8992,7 +9001,7 @@ export default {
|
||||
200,
|
||||
{
|
||||
"input": [
|
||||
490,
|
||||
491,
|
||||
"UpdateLabPublicFeatureFlagInput!"
|
||||
]
|
||||
}
|
||||
@@ -9079,7 +9088,7 @@ export default {
|
||||
6,
|
||||
{
|
||||
"role": [
|
||||
491,
|
||||
492,
|
||||
"AiModelRole!"
|
||||
],
|
||||
"modelId": [
|
||||
@@ -9284,7 +9293,7 @@ export default {
|
||||
68,
|
||||
{
|
||||
"input": [
|
||||
492,
|
||||
493,
|
||||
"CreateOneAppTokenInput!"
|
||||
]
|
||||
}
|
||||
@@ -9320,7 +9329,7 @@ export default {
|
||||
6,
|
||||
{
|
||||
"workspaceMigration": [
|
||||
494,
|
||||
495,
|
||||
"WorkspaceMigrationInput!"
|
||||
]
|
||||
}
|
||||
@@ -9394,7 +9403,7 @@ export default {
|
||||
"String!"
|
||||
],
|
||||
"fileFolder": [
|
||||
497,
|
||||
498,
|
||||
"FileFolder!"
|
||||
],
|
||||
"filePath": [
|
||||
@@ -11590,19 +11599,33 @@ export default {
|
||||
"inReplyTo": [
|
||||
1
|
||||
],
|
||||
"files": [
|
||||
488
|
||||
],
|
||||
"__typename": [
|
||||
1
|
||||
]
|
||||
},
|
||||
"SendEmailAttachmentInput": {
|
||||
"id": [
|
||||
1
|
||||
],
|
||||
"name": [
|
||||
1
|
||||
],
|
||||
"__typename": [
|
||||
1
|
||||
]
|
||||
},
|
||||
"EmailAccountConnectionParameters": {
|
||||
"IMAP": [
|
||||
489
|
||||
490
|
||||
],
|
||||
"SMTP": [
|
||||
489
|
||||
490
|
||||
],
|
||||
"CALDAV": [
|
||||
489
|
||||
490
|
||||
],
|
||||
"__typename": [
|
||||
1
|
||||
@@ -11642,7 +11665,7 @@ export default {
|
||||
"AiModelRole": {},
|
||||
"CreateOneAppTokenInput": {
|
||||
"appToken": [
|
||||
493
|
||||
494
|
||||
],
|
||||
"__typename": [
|
||||
1
|
||||
@@ -11658,7 +11681,7 @@ export default {
|
||||
},
|
||||
"WorkspaceMigrationInput": {
|
||||
"actions": [
|
||||
495
|
||||
496
|
||||
],
|
||||
"__typename": [
|
||||
1
|
||||
@@ -11666,7 +11689,7 @@ export default {
|
||||
},
|
||||
"WorkspaceMigrationDeleteActionInput": {
|
||||
"type": [
|
||||
496
|
||||
497
|
||||
],
|
||||
"metadataName": [
|
||||
348
|
||||
@@ -11694,7 +11717,7 @@ export default {
|
||||
253,
|
||||
{
|
||||
"input": [
|
||||
499,
|
||||
500,
|
||||
"LogicFunctionLogsInput!"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import { type Preview } from '@storybook/react-vite';
|
||||
import { initialize, mswLoader } from 'msw-storybook-addon';
|
||||
import { SOURCE_LOCALE } from 'twenty-shared/translations';
|
||||
|
||||
// oxlint-disable-next-line no-restricted-imports
|
||||
import { FileUploadProvider } from '../src/modules/file-upload/components/FileUploadProvider';
|
||||
// oxlint-disable-next-line no-restricted-imports
|
||||
import { RootDecorator } from '../src/testing/decorators/RootDecorator';
|
||||
// oxlint-disable-next-line no-restricted-imports
|
||||
@@ -63,11 +65,13 @@ const preview: Preview = {
|
||||
return (
|
||||
<I18nProvider i18n={i18n}>
|
||||
<ThemeProvider colorScheme="light">
|
||||
<ClickOutsideListenerContext.Provider
|
||||
value={{ excludedClickOutsideId: undefined }}
|
||||
>
|
||||
<Story />
|
||||
</ClickOutsideListenerContext.Provider>
|
||||
<FileUploadProvider>
|
||||
<ClickOutsideListenerContext.Provider
|
||||
value={{ excludedClickOutsideId: undefined }}
|
||||
>
|
||||
<Story />
|
||||
</ClickOutsideListenerContext.Provider>
|
||||
</FileUploadProvider>
|
||||
</ThemeProvider>
|
||||
</I18nProvider>
|
||||
);
|
||||
|
||||
File diff suppressed because one or more lines are too long
+4
-78
@@ -1,91 +1,17 @@
|
||||
import { useFirstConnectedAccount } from '@/activities/emails/hooks/useFirstConnectedAccount';
|
||||
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
|
||||
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
|
||||
import { useOpenComposeEmailInSidePanel } from '@/side-panel/hooks/useOpenComposeEmailInSidePanel';
|
||||
import { useTargetRecord } from '@/ui/layout/contexts/useTargetRecord';
|
||||
import { CoreObjectNameSingular, SettingsPath } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { useComposeEmailForTargetRecord } from '@/activities/emails/hooks/useComposeEmailForTargetRecord';
|
||||
import { IconPlus } from 'twenty-ui/display';
|
||||
import { LightIconButton } from 'twenty-ui/input';
|
||||
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
|
||||
|
||||
export const ComposeEmailButton = () => {
|
||||
const targetRecord = useTargetRecord();
|
||||
const { openComposeEmailInSidePanel } = useOpenComposeEmailInSidePanel();
|
||||
const navigateSettings = useNavigateSettings();
|
||||
const { connectedAccountId, loading: accountLoading } =
|
||||
useFirstConnectedAccount();
|
||||
|
||||
const isPerson =
|
||||
targetRecord.targetObjectNameSingular === CoreObjectNameSingular.Person;
|
||||
const isCompany =
|
||||
targetRecord.targetObjectNameSingular === CoreObjectNameSingular.Company;
|
||||
const isOpportunity =
|
||||
targetRecord.targetObjectNameSingular ===
|
||||
CoreObjectNameSingular.Opportunity;
|
||||
|
||||
const { record: personRecord } = useFindOneRecord({
|
||||
objectNameSingular: CoreObjectNameSingular.Person,
|
||||
objectRecordId: targetRecord.id,
|
||||
recordGqlFields: { id: true, emails: { primaryEmail: true } },
|
||||
skip: !isPerson,
|
||||
});
|
||||
|
||||
const { records: companyPeople } = useFindManyRecords({
|
||||
objectNameSingular: CoreObjectNameSingular.Person,
|
||||
filter: { companyId: { eq: targetRecord.id } },
|
||||
recordGqlFields: { id: true, emails: { primaryEmail: true } },
|
||||
limit: 1,
|
||||
skip: !isCompany,
|
||||
});
|
||||
|
||||
const { record: opportunityRecord } = useFindOneRecord({
|
||||
objectNameSingular: CoreObjectNameSingular.Opportunity,
|
||||
objectRecordId: targetRecord.id,
|
||||
recordGqlFields: {
|
||||
id: true,
|
||||
pointOfContact: { id: true, emails: { primaryEmail: true } },
|
||||
company: { id: true },
|
||||
},
|
||||
skip: !isOpportunity,
|
||||
});
|
||||
|
||||
const resolveDefaultTo = (): string => {
|
||||
if (isPerson) {
|
||||
return personRecord?.emails?.primaryEmail ?? '';
|
||||
}
|
||||
if (isCompany) {
|
||||
return companyPeople[0]?.emails?.primaryEmail ?? '';
|
||||
}
|
||||
if (isOpportunity) {
|
||||
return opportunityRecord?.pointOfContact?.emails?.primaryEmail ?? '';
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
if (!isDefined(connectedAccountId)) {
|
||||
navigateSettings(SettingsPath.NewAccount);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
openComposeEmailInSidePanel({
|
||||
connectedAccountId,
|
||||
defaultTo: resolveDefaultTo(),
|
||||
});
|
||||
};
|
||||
|
||||
if (accountLoading) {
|
||||
return null;
|
||||
}
|
||||
const { openComposer, loading } = useComposeEmailForTargetRecord();
|
||||
|
||||
return (
|
||||
<LightIconButton
|
||||
Icon={IconPlus}
|
||||
accent="tertiary"
|
||||
size="small"
|
||||
onClick={handleClick}
|
||||
onClick={openComposer}
|
||||
disabled={loading}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
import { styled } from '@linaria/react';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useContext } from 'react';
|
||||
import { type EmailAttachment } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { IconUpload } from 'twenty-ui/display';
|
||||
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
import { useUploadEmailAttachment } from '@/activities/emails/hooks/useUploadEmailAttachment';
|
||||
import { AttachmentChip } from '@/file/components/AttachmentChip';
|
||||
import { useFileUpload } from '@/file-upload/hooks/useFileUpload';
|
||||
import { InputLabel } from '@/ui/input/components/InputLabel';
|
||||
|
||||
type EmailAttachmentsFieldProps = {
|
||||
files: EmailAttachment[];
|
||||
onChange: (files: EmailAttachment[]) => void;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
const StyledUploadArea = styled.div<{ hasFiles: boolean }>`
|
||||
background-color: ${themeCssVariables.background.transparent.lighter};
|
||||
border: 1px solid ${themeCssVariables.border.color.medium};
|
||||
border-radius: ${themeCssVariables.border.radius.sm};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
min-height: ${({ hasFiles }) => (hasFiles ? 'auto' : '24px')};
|
||||
padding-bottom: ${themeCssVariables.spacing[1]};
|
||||
padding-left: ${themeCssVariables.spacing[2]};
|
||||
padding-right: ${themeCssVariables.spacing[2]};
|
||||
padding-top: ${themeCssVariables.spacing[1]};
|
||||
|
||||
&:hover {
|
||||
background-color: ${themeCssVariables.background.transparent.light};
|
||||
border-color: ${themeCssVariables.border.color.strong};
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledChipsContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: ${themeCssVariables.spacing[1]};
|
||||
`;
|
||||
|
||||
const StyledUploadAreaLabel = styled.div`
|
||||
color: ${themeCssVariables.font.color.tertiary};
|
||||
display: flex;
|
||||
font-size: ${themeCssVariables.font.size.sm};
|
||||
font-weight: ${themeCssVariables.font.weight.medium};
|
||||
gap: ${themeCssVariables.spacing[1]};
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
export const EmailAttachmentsField = ({
|
||||
files,
|
||||
label,
|
||||
onChange,
|
||||
}: EmailAttachmentsFieldProps) => {
|
||||
const { theme } = useContext(ThemeContext);
|
||||
const { uploadEmailAttachment } = useUploadEmailAttachment();
|
||||
const { openFileUpload } = useFileUpload();
|
||||
const { t } = useLingui();
|
||||
|
||||
const handleUploadFiles = async (filesToUpload: File[]) => {
|
||||
const uploadedFiles = await Promise.all(
|
||||
filesToUpload.map((file) => uploadEmailAttachment(file)),
|
||||
);
|
||||
|
||||
const successfulUploads = uploadedFiles.filter(isDefined);
|
||||
|
||||
if (successfulUploads.length > 0) {
|
||||
onChange([...files, ...successfulUploads]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddFileClick = () => {
|
||||
openFileUpload({
|
||||
multiple: true,
|
||||
onUpload: handleUploadFiles,
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveFile = (fileId: string) => {
|
||||
onChange(files.filter((file) => file.id !== fileId));
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
{label ? <InputLabel>{label}</InputLabel> : null}
|
||||
|
||||
<StyledUploadArea
|
||||
hasFiles={files.length > 0}
|
||||
onClick={handleAddFileClick}
|
||||
>
|
||||
{files.length > 0 ? (
|
||||
<StyledChipsContainer>
|
||||
{files.map((file) => (
|
||||
<AttachmentChip
|
||||
key={file.id}
|
||||
file={file}
|
||||
onRemove={() => handleRemoveFile(file.id)}
|
||||
/>
|
||||
))}
|
||||
</StyledChipsContainer>
|
||||
) : (
|
||||
<StyledUploadAreaLabel>
|
||||
<IconUpload size={theme.icon.size.sm} />
|
||||
<span>{t`Upload file`}</span>
|
||||
</StyledUploadAreaLabel>
|
||||
)}
|
||||
</StyledUploadArea>
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
@@ -7,6 +7,12 @@ import { IconArrowBackUp } from 'twenty-ui/display';
|
||||
import { Button } from 'twenty-ui/input';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
const StyledFooterWarning = styled.span`
|
||||
color: ${themeCssVariables.color.red};
|
||||
flex: 1;
|
||||
font-size: ${themeCssVariables.font.size.xs};
|
||||
`;
|
||||
|
||||
const StyledComposerContainer = styled.div`
|
||||
background: ${themeCssVariables.background.primary};
|
||||
display: flex;
|
||||
@@ -56,6 +62,11 @@ export const EmailComposer = ({
|
||||
<StyledComposerContainer>
|
||||
<EmailComposerFields composerState={composerState} />
|
||||
<StyledFooter>
|
||||
{composerState.exceedsRecipientLimit && (
|
||||
<StyledFooterWarning>
|
||||
{t`Too many recipients (${composerState.recipientCount}/${composerState.maxRecipients}).`}
|
||||
</StyledFooterWarning>
|
||||
)}
|
||||
<StyledFooterActions>
|
||||
{onClose && (
|
||||
<Button
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useQuery } from '@apollo/client/react';
|
||||
import { styled } from '@linaria/react';
|
||||
|
||||
import { EmailAttachmentsField } from '@/activities/emails/components/EmailAttachmentsField';
|
||||
import { type EmailComposerState } from '@/activities/emails/types/EmailComposerState';
|
||||
import { FormAdvancedTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormAdvancedTextFieldInput';
|
||||
import { FormMultiTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormMultiTextFieldInput';
|
||||
@@ -112,6 +113,11 @@ export const EmailComposerFields = ({
|
||||
maxWidth={600}
|
||||
contentType="json"
|
||||
/>
|
||||
<EmailAttachmentsField
|
||||
label={t`Attachments`}
|
||||
files={composerState.files}
|
||||
onChange={composerState.setFiles}
|
||||
/>
|
||||
</StyledFieldsContainer>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import { CustomResolverFetchMoreLoader } from '@/activities/components/CustomRes
|
||||
import { SkeletonLoader } from '@/activities/components/SkeletonLoader';
|
||||
import { ComposeEmailButton } from '@/activities/emails/components/ComposeEmailButton';
|
||||
import { EmailThreadPreview } from '@/activities/emails/components/EmailThreadPreview';
|
||||
import { EmptyInboxPlaceholder } from '@/activities/emails/components/EmptyInboxPlaceholder';
|
||||
import { TIMELINE_THREADS_DEFAULT_PAGE_SIZE } from '@/activities/emails/constants/Messaging';
|
||||
import { getTimelineThreadsFromCompanyId } from '@/activities/emails/graphql/queries/getTimelineThreadsFromCompanyId';
|
||||
import { getTimelineThreadsFromOpportunityId } from '@/activities/emails/graphql/queries/getTimelineThreadsFromOpportunityId';
|
||||
@@ -14,15 +15,7 @@ import { CoreObjectNameSingular } from 'twenty-shared/types';
|
||||
import { useTargetRecord } from '@/ui/layout/contexts/useTargetRecord';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { H1Title, H1TitleFontColor } from 'twenty-ui/display';
|
||||
import {
|
||||
AnimatedPlaceholder,
|
||||
AnimatedPlaceholderEmptyContainer,
|
||||
AnimatedPlaceholderEmptySubTitle,
|
||||
AnimatedPlaceholderEmptyTextContainer,
|
||||
AnimatedPlaceholderEmptyTitle,
|
||||
EMPTY_PLACEHOLDER_TRANSITION_PROPS,
|
||||
Section,
|
||||
} from 'twenty-ui/layout';
|
||||
import { Section } from 'twenty-ui/layout';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import {
|
||||
type TimelineThread,
|
||||
@@ -43,24 +36,19 @@ const StyledHeaderRow = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: ${themeCssVariables.spacing[4]};
|
||||
`;
|
||||
|
||||
const StyledH1TitleWrapper = styled.div`
|
||||
> h2 {
|
||||
display: flex;
|
||||
gap: ${themeCssVariables.spacing[2]};
|
||||
}
|
||||
const StyledH1Title = styled(H1Title)`
|
||||
display: flex;
|
||||
gap: ${themeCssVariables.spacing[2]};
|
||||
margin-bottom: 0;
|
||||
`;
|
||||
|
||||
const StyledEmailCount = styled.span`
|
||||
color: ${themeCssVariables.font.color.light};
|
||||
`;
|
||||
|
||||
const StyledComposeButtonRow = styled.div`
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
`;
|
||||
|
||||
export const EmailsCard = () => {
|
||||
const targetRecord = useTargetRecord();
|
||||
|
||||
@@ -102,25 +90,7 @@ export const EmailsCard = () => {
|
||||
if (!firstQueryLoading && !timelineThreads?.length) {
|
||||
return (
|
||||
<StyledContainer>
|
||||
<StyledComposeButtonRow>
|
||||
<ComposeEmailButton />
|
||||
</StyledComposeButtonRow>
|
||||
<AnimatedPlaceholderEmptyContainer
|
||||
// oxlint-disable-next-line react/jsx-props-no-spreading
|
||||
{...EMPTY_PLACEHOLDER_TRANSITION_PROPS}
|
||||
>
|
||||
<AnimatedPlaceholder type="emptyInbox" />
|
||||
<AnimatedPlaceholderEmptyTextContainer>
|
||||
<AnimatedPlaceholderEmptyTitle>
|
||||
<Trans>Empty Inbox</Trans>
|
||||
</AnimatedPlaceholderEmptyTitle>
|
||||
<AnimatedPlaceholderEmptySubTitle>
|
||||
<Trans>
|
||||
No email exchange has occurred with this record yet.
|
||||
</Trans>
|
||||
</AnimatedPlaceholderEmptySubTitle>
|
||||
</AnimatedPlaceholderEmptyTextContainer>
|
||||
</AnimatedPlaceholderEmptyContainer>
|
||||
<EmptyInboxPlaceholder />
|
||||
</StyledContainer>
|
||||
);
|
||||
}
|
||||
@@ -129,17 +99,15 @@ export const EmailsCard = () => {
|
||||
<StyledContainer>
|
||||
<Section>
|
||||
<StyledHeaderRow>
|
||||
<StyledH1TitleWrapper>
|
||||
<H1Title
|
||||
title={
|
||||
<>
|
||||
<Trans>Inbox</Trans>{' '}
|
||||
<StyledEmailCount>{totalNumberOfThreads}</StyledEmailCount>
|
||||
</>
|
||||
}
|
||||
fontColor={H1TitleFontColor.Primary}
|
||||
/>
|
||||
</StyledH1TitleWrapper>
|
||||
<StyledH1Title
|
||||
title={
|
||||
<>
|
||||
<Trans>Inbox</Trans>{' '}
|
||||
<StyledEmailCount>{totalNumberOfThreads}</StyledEmailCount>
|
||||
</>
|
||||
}
|
||||
fontColor={H1TitleFontColor.Primary}
|
||||
/>
|
||||
<ComposeEmailButton />
|
||||
</StyledHeaderRow>
|
||||
{!firstQueryLoading && (
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
import { useComposeEmailForTargetRecord } from '@/activities/emails/hooks/useComposeEmailForTargetRecord';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { IconMail } from 'twenty-ui/display';
|
||||
import { Button } from 'twenty-ui/input';
|
||||
import {
|
||||
AnimatedPlaceholder,
|
||||
AnimatedPlaceholderEmptyContainer,
|
||||
AnimatedPlaceholderEmptySubTitle,
|
||||
AnimatedPlaceholderEmptyTextContainer,
|
||||
AnimatedPlaceholderEmptyTitle,
|
||||
EMPTY_PLACEHOLDER_TRANSITION_PROPS,
|
||||
} from 'twenty-ui/layout';
|
||||
|
||||
export const EmptyInboxPlaceholder = () => {
|
||||
const { t } = useLingui();
|
||||
const { openComposer, loading } = useComposeEmailForTargetRecord();
|
||||
|
||||
return (
|
||||
<AnimatedPlaceholderEmptyContainer
|
||||
// oxlint-disable-next-line react/jsx-props-no-spreading
|
||||
{...EMPTY_PLACEHOLDER_TRANSITION_PROPS}
|
||||
>
|
||||
<AnimatedPlaceholder type="emptyInbox" />
|
||||
<AnimatedPlaceholderEmptyTextContainer>
|
||||
<AnimatedPlaceholderEmptyTitle>
|
||||
<Trans>Empty Inbox</Trans>
|
||||
</AnimatedPlaceholderEmptyTitle>
|
||||
<AnimatedPlaceholderEmptySubTitle>
|
||||
<Trans>No email exchange has occurred with this record yet.</Trans>
|
||||
</AnimatedPlaceholderEmptySubTitle>
|
||||
</AnimatedPlaceholderEmptyTextContainer>
|
||||
<Button
|
||||
Icon={IconMail}
|
||||
title={t`Send Email`}
|
||||
variant="secondary"
|
||||
onClick={openComposer}
|
||||
disabled={loading}
|
||||
/>
|
||||
</AnimatedPlaceholderEmptyContainer>
|
||||
);
|
||||
};
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
|
||||
import { useResolveDefaultEmailRecipient } from '@/activities/emails/hooks/useResolveDefaultEmailRecipient';
|
||||
import { CoreObjectNameSingular } from 'twenty-shared/types';
|
||||
|
||||
const mockUseFindOneRecord = jest.fn();
|
||||
const mockUseFindManyRecords = jest.fn();
|
||||
|
||||
jest.mock('@/object-record/hooks/useFindOneRecord', () => ({
|
||||
useFindOneRecord: (args: unknown) => mockUseFindOneRecord(args),
|
||||
}));
|
||||
|
||||
jest.mock('@/object-record/hooks/useFindManyRecords', () => ({
|
||||
useFindManyRecords: (args: unknown) => mockUseFindManyRecords(args),
|
||||
}));
|
||||
|
||||
describe('useResolveDefaultEmailRecipient', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
mockUseFindOneRecord.mockReturnValue({
|
||||
record: null,
|
||||
loading: false,
|
||||
});
|
||||
mockUseFindManyRecords.mockReturnValue({
|
||||
records: [],
|
||||
loading: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return the person primary email for a Person record', () => {
|
||||
mockUseFindOneRecord.mockImplementation(
|
||||
(args: { objectNameSingular: string }) => {
|
||||
if (args.objectNameSingular === CoreObjectNameSingular.Person) {
|
||||
return {
|
||||
record: { emails: { primaryEmail: 'person@example.com' } },
|
||||
loading: false,
|
||||
};
|
||||
}
|
||||
|
||||
return { record: null, loading: false };
|
||||
},
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useResolveDefaultEmailRecipient({
|
||||
objectNameSingular: CoreObjectNameSingular.Person,
|
||||
recordId: 'person-id',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.defaultTo).toBe('person@example.com');
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
it('should return the first company employee email for a Company record', () => {
|
||||
mockUseFindManyRecords.mockReturnValue({
|
||||
records: [{ emails: { primaryEmail: 'employee@company.com' } }],
|
||||
loading: false,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useResolveDefaultEmailRecipient({
|
||||
objectNameSingular: CoreObjectNameSingular.Company,
|
||||
recordId: 'company-id',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.defaultTo).toBe('employee@company.com');
|
||||
});
|
||||
|
||||
it('should return the opportunity point of contact email', () => {
|
||||
mockUseFindOneRecord.mockImplementation(
|
||||
(args: { objectNameSingular: string }) => {
|
||||
if (args.objectNameSingular === CoreObjectNameSingular.Opportunity) {
|
||||
return {
|
||||
record: {
|
||||
pointOfContact: {
|
||||
emails: { primaryEmail: 'contact@opp.com' },
|
||||
},
|
||||
},
|
||||
loading: false,
|
||||
};
|
||||
}
|
||||
|
||||
return { record: null, loading: false };
|
||||
},
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useResolveDefaultEmailRecipient({
|
||||
objectNameSingular: CoreObjectNameSingular.Opportunity,
|
||||
recordId: 'opp-id',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.defaultTo).toBe('contact@opp.com');
|
||||
});
|
||||
|
||||
it('should return empty string for unknown object types', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useResolveDefaultEmailRecipient({
|
||||
objectNameSingular: 'customObject',
|
||||
recordId: 'some-id',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.defaultTo).toBe('');
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
it('should return empty string when recordId is null', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useResolveDefaultEmailRecipient({
|
||||
objectNameSingular: CoreObjectNameSingular.Person,
|
||||
recordId: null,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.defaultTo).toBe('');
|
||||
});
|
||||
|
||||
it('should report loading when the relevant query is in flight', () => {
|
||||
mockUseFindOneRecord.mockImplementation(
|
||||
(args: { objectNameSingular: string }) => {
|
||||
if (args.objectNameSingular === CoreObjectNameSingular.Person) {
|
||||
return { record: null, loading: true };
|
||||
}
|
||||
|
||||
return { record: null, loading: false };
|
||||
},
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useResolveDefaultEmailRecipient({
|
||||
objectNameSingular: CoreObjectNameSingular.Person,
|
||||
recordId: 'person-id',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.loading).toBe(true);
|
||||
});
|
||||
|
||||
it('should pass skip=true to queries for non-matching object types', () => {
|
||||
renderHook(() =>
|
||||
useResolveDefaultEmailRecipient({
|
||||
objectNameSingular: CoreObjectNameSingular.Person,
|
||||
recordId: 'person-id',
|
||||
}),
|
||||
);
|
||||
|
||||
// Person query should NOT be skipped
|
||||
const personCall = mockUseFindOneRecord.mock.calls.find(
|
||||
(call: { objectNameSingular: string }[]) =>
|
||||
call[0].objectNameSingular === CoreObjectNameSingular.Person,
|
||||
);
|
||||
|
||||
expect(personCall?.[0].skip).toBe(false);
|
||||
|
||||
// Opportunity query SHOULD be skipped
|
||||
const oppCall = mockUseFindOneRecord.mock.calls.find(
|
||||
(call: { objectNameSingular: string }[]) =>
|
||||
call[0].objectNameSingular === CoreObjectNameSingular.Opportunity,
|
||||
);
|
||||
|
||||
expect(oppCall?.[0].skip).toBe(true);
|
||||
|
||||
// Company people query SHOULD be skipped
|
||||
expect(mockUseFindManyRecords.mock.calls[0][0].skip).toBe(true);
|
||||
});
|
||||
});
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import { useFirstConnectedAccount } from '@/activities/emails/hooks/useFirstConnectedAccount';
|
||||
import { useResolveDefaultEmailRecipient } from '@/activities/emails/hooks/useResolveDefaultEmailRecipient';
|
||||
import { useOpenComposeEmailInSidePanel } from '@/side-panel/hooks/useOpenComposeEmailInSidePanel';
|
||||
import { useTargetRecord } from '@/ui/layout/contexts/useTargetRecord';
|
||||
import { SettingsPath } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
|
||||
|
||||
export const useComposeEmailForTargetRecord = () => {
|
||||
const targetRecord = useTargetRecord();
|
||||
const { openComposeEmailInSidePanel } = useOpenComposeEmailInSidePanel();
|
||||
const navigateSettings = useNavigateSettings();
|
||||
const { connectedAccountId, loading: accountLoading } =
|
||||
useFirstConnectedAccount();
|
||||
|
||||
const { defaultTo, loading: recipientLoading } =
|
||||
useResolveDefaultEmailRecipient({
|
||||
objectNameSingular: targetRecord.targetObjectNameSingular,
|
||||
recordId: targetRecord.id,
|
||||
});
|
||||
|
||||
const openComposer = () => {
|
||||
if (!isDefined(connectedAccountId)) {
|
||||
navigateSettings(SettingsPath.NewAccount);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
openComposeEmailInSidePanel({
|
||||
connectedAccountId,
|
||||
defaultTo,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
openComposer,
|
||||
loading: accountLoading || recipientLoading,
|
||||
};
|
||||
};
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { MAX_EMAIL_RECIPIENTS } from 'twenty-shared/constants';
|
||||
import { type EmailAttachment } from 'twenty-shared/types';
|
||||
|
||||
import { useSendEmail } from '@/activities/emails/hooks/useSendEmail';
|
||||
|
||||
@@ -10,6 +12,12 @@ type UseEmailComposerStateArgs = {
|
||||
onSent?: () => void;
|
||||
};
|
||||
|
||||
const countRecipients = (csv: string): number =>
|
||||
csv
|
||||
.split(',')
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0).length;
|
||||
|
||||
export const useEmailComposerState = ({
|
||||
connectedAccountId: initialConnectedAccountId,
|
||||
defaultTo = '',
|
||||
@@ -26,14 +34,25 @@ export const useEmailComposerState = ({
|
||||
const [subject, setSubject] = useState(defaultSubject);
|
||||
const [body, setBody] = useState('');
|
||||
const [showCcBcc, setShowCcBcc] = useState(false);
|
||||
const [files, setFiles] = useState<EmailAttachment[]>([]);
|
||||
|
||||
const { sendEmail, loading } = useSendEmail();
|
||||
|
||||
const recipientCount = useMemo(
|
||||
() => countRecipients(to) + countRecipients(cc) + countRecipients(bcc),
|
||||
[to, cc, bcc],
|
||||
);
|
||||
|
||||
const exceedsRecipientLimit = recipientCount > MAX_EMAIL_RECIPIENTS;
|
||||
|
||||
const canSend =
|
||||
to.trim().length > 0 && connectedAccountId.length > 0 && !loading;
|
||||
to.trim().length > 0 &&
|
||||
connectedAccountId.length > 0 &&
|
||||
!loading &&
|
||||
!exceedsRecipientLimit;
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
if (!to.trim() || !connectedAccountId) {
|
||||
if (!to.trim() || !connectedAccountId || exceedsRecipientLimit) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -49,6 +68,7 @@ export const useEmailComposerState = ({
|
||||
subject,
|
||||
body,
|
||||
inReplyTo: defaultInReplyTo,
|
||||
files: files.length > 0 ? files : undefined,
|
||||
});
|
||||
|
||||
if (success) {
|
||||
@@ -62,8 +82,10 @@ export const useEmailComposerState = ({
|
||||
subject,
|
||||
body,
|
||||
defaultInReplyTo,
|
||||
files,
|
||||
sendEmail,
|
||||
onSent,
|
||||
exceedsRecipientLimit,
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -81,10 +103,15 @@ export const useEmailComposerState = ({
|
||||
setBody,
|
||||
showCcBcc,
|
||||
setShowCcBcc,
|
||||
files,
|
||||
setFiles,
|
||||
handleSend,
|
||||
loading,
|
||||
canSend,
|
||||
defaultTo,
|
||||
defaultSubject,
|
||||
recipientCount,
|
||||
exceedsRecipientLimit,
|
||||
maxRecipients: MAX_EMAIL_RECIPIENTS,
|
||||
};
|
||||
};
|
||||
|
||||
+10
-2
@@ -2,10 +2,18 @@ import { useQuery } from '@apollo/client/react';
|
||||
|
||||
import { GET_MY_CONNECTED_ACCOUNTS } from '@/settings/accounts/graphql/queries/getMyConnectedAccounts';
|
||||
|
||||
export const useFirstConnectedAccount = () => {
|
||||
type UseFirstConnectedAccountOptions = {
|
||||
skip?: boolean;
|
||||
};
|
||||
|
||||
export const useFirstConnectedAccount = (
|
||||
options?: UseFirstConnectedAccountOptions,
|
||||
) => {
|
||||
const { data, loading } = useQuery<{
|
||||
myConnectedAccounts: { id: string; handle: string }[];
|
||||
}>(GET_MY_CONNECTED_ACCOUNTS);
|
||||
}>(GET_MY_CONNECTED_ACCOUNTS, {
|
||||
skip: options?.skip,
|
||||
});
|
||||
|
||||
const firstAccount = data?.myConnectedAccounts?.[0] ?? null;
|
||||
|
||||
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { useFirstConnectedAccount } from '@/activities/emails/hooks/useFirstConnectedAccount';
|
||||
import { useOpenComposeEmailInSidePanel } from '@/side-panel/hooks/useOpenComposeEmailInSidePanel';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
type UseOpenEmailInAppOrFallbackOptions = {
|
||||
skip?: boolean;
|
||||
};
|
||||
|
||||
// Falls back to mailto: when no connected account exists, rather than
|
||||
// redirecting to settings.
|
||||
export const useOpenEmailInAppOrFallback = (
|
||||
options?: UseOpenEmailInAppOrFallbackOptions,
|
||||
) => {
|
||||
const { connectedAccountId, loading } = useFirstConnectedAccount({
|
||||
skip: options?.skip,
|
||||
});
|
||||
const { openComposeEmailInSidePanel } = useOpenComposeEmailInSidePanel();
|
||||
|
||||
const openEmail = useCallback(
|
||||
(email: string) => {
|
||||
if (isDefined(connectedAccountId)) {
|
||||
openComposeEmailInSidePanel({
|
||||
connectedAccountId,
|
||||
defaultTo: email,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
window.open(`mailto:${email}`, '_blank');
|
||||
},
|
||||
[connectedAccountId, openComposeEmailInSidePanel],
|
||||
);
|
||||
|
||||
return { openEmail, loading };
|
||||
};
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
|
||||
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
|
||||
import { CoreObjectNameSingular } from 'twenty-shared/types';
|
||||
|
||||
type UseResolveDefaultEmailRecipientParams = {
|
||||
objectNameSingular: string | null | undefined;
|
||||
recordId: string | null | undefined;
|
||||
};
|
||||
|
||||
export const useResolveDefaultEmailRecipient = ({
|
||||
objectNameSingular,
|
||||
recordId,
|
||||
}: UseResolveDefaultEmailRecipientParams) => {
|
||||
const isPerson = objectNameSingular === CoreObjectNameSingular.Person;
|
||||
const isCompany = objectNameSingular === CoreObjectNameSingular.Company;
|
||||
const isOpportunity =
|
||||
objectNameSingular === CoreObjectNameSingular.Opportunity;
|
||||
|
||||
const skipPerson = !isPerson || !recordId;
|
||||
const skipCompanyPeople = !isCompany || !recordId;
|
||||
const skipOpportunity = !isOpportunity || !recordId;
|
||||
|
||||
const { record: personRecord, loading: personLoading } = useFindOneRecord({
|
||||
objectNameSingular: CoreObjectNameSingular.Person,
|
||||
objectRecordId: recordId ?? '',
|
||||
recordGqlFields: { id: true, emails: { primaryEmail: true } },
|
||||
skip: skipPerson,
|
||||
});
|
||||
|
||||
const { records: companyPeople, loading: companyPeopleLoading } =
|
||||
useFindManyRecords({
|
||||
objectNameSingular: CoreObjectNameSingular.Person,
|
||||
filter: { companyId: { eq: recordId ?? '' } },
|
||||
recordGqlFields: { id: true, emails: { primaryEmail: true } },
|
||||
limit: 1,
|
||||
skip: skipCompanyPeople,
|
||||
});
|
||||
|
||||
const { record: opportunityRecord, loading: opportunityLoading } =
|
||||
useFindOneRecord({
|
||||
objectNameSingular: CoreObjectNameSingular.Opportunity,
|
||||
objectRecordId: recordId ?? '',
|
||||
recordGqlFields: {
|
||||
id: true,
|
||||
pointOfContact: { id: true, emails: { primaryEmail: true } },
|
||||
},
|
||||
skip: skipOpportunity,
|
||||
});
|
||||
|
||||
const defaultTo = isPerson
|
||||
? (personRecord?.emails?.primaryEmail ?? '')
|
||||
: isCompany
|
||||
? (companyPeople[0]?.emails?.primaryEmail ?? '')
|
||||
: isOpportunity
|
||||
? (opportunityRecord?.pointOfContact?.emails?.primaryEmail ?? '')
|
||||
: '';
|
||||
|
||||
const loading =
|
||||
(isPerson && personLoading) ||
|
||||
(isCompany && companyPeopleLoading) ||
|
||||
(isOpportunity && opportunityLoading);
|
||||
|
||||
return { defaultTo, loading };
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMutation } from '@apollo/client/react';
|
||||
import { useCallback } from 'react';
|
||||
import { type EmailAttachment } from 'twenty-shared/types';
|
||||
|
||||
import { SEND_EMAIL } from '@/activities/emails/graphql/mutations/sendEmail';
|
||||
import { getTimelineThreadsFromCompanyId } from '@/activities/emails/graphql/queries/getTimelineThreadsFromCompanyId';
|
||||
@@ -21,6 +22,7 @@ type SendEmailParams = {
|
||||
subject: string;
|
||||
body: string;
|
||||
inReplyTo?: string;
|
||||
files?: EmailAttachment[];
|
||||
};
|
||||
|
||||
export const useSendEmail = () => {
|
||||
@@ -46,6 +48,7 @@ export const useSendEmail = () => {
|
||||
subject: params.subject,
|
||||
body: params.body,
|
||||
inReplyTo: params.inReplyTo,
|
||||
files: params.files,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useMutation } from '@apollo/client/react';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { type EmailAttachment } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { MAX_ATTACHMENT_SIZE } from '@/advanced-text-editor/utils/maxAttachmentSize';
|
||||
import { UPLOAD_EMAIL_ATTACHMENT_FILE } from '@/file/graphql/mutations/uploadEmailAttachmentFile';
|
||||
import { formatFileSize } from '@/file/utils/formatFileSize';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import {
|
||||
type UploadEmailAttachmentFileMutation,
|
||||
type UploadEmailAttachmentFileMutationVariables,
|
||||
} from '~/generated-metadata/graphql';
|
||||
import { logError } from '~/utils/logError';
|
||||
|
||||
export const useUploadEmailAttachment = () => {
|
||||
const [uploadEmailAttachmentFileMutation] = useMutation<
|
||||
UploadEmailAttachmentFileMutation,
|
||||
UploadEmailAttachmentFileMutationVariables
|
||||
>(UPLOAD_EMAIL_ATTACHMENT_FILE);
|
||||
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
|
||||
const { t } = useLingui();
|
||||
|
||||
const uploadEmailAttachment = async (
|
||||
file: File,
|
||||
): Promise<EmailAttachment | null> => {
|
||||
try {
|
||||
if (file.size > MAX_ATTACHMENT_SIZE) {
|
||||
const fileName = file.name;
|
||||
const maxUploadSize = formatFileSize(MAX_ATTACHMENT_SIZE);
|
||||
|
||||
enqueueErrorSnackBar({
|
||||
message: t`File "${fileName}" exceeds ${maxUploadSize}`,
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = await uploadEmailAttachmentFileMutation({
|
||||
variables: { file },
|
||||
});
|
||||
|
||||
const uploadedFile = result?.data?.uploadEmailAttachmentFile;
|
||||
|
||||
if (!isDefined(uploadedFile)) {
|
||||
throw new Error('File upload failed');
|
||||
}
|
||||
|
||||
const attachment: EmailAttachment = {
|
||||
id: uploadedFile.id,
|
||||
name: file.name,
|
||||
};
|
||||
|
||||
const fileName = file.name;
|
||||
|
||||
enqueueSuccessSnackBar({
|
||||
message: t`File "${fileName}" uploaded successfully`,
|
||||
});
|
||||
|
||||
return attachment;
|
||||
} catch (error) {
|
||||
logError(`Failed to upload email attachment "${file.name}": ${error}`);
|
||||
|
||||
const fileNameForError = file.name;
|
||||
|
||||
enqueueErrorSnackBar({
|
||||
message: t`Failed to upload "${fileNameForError}"`,
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return { uploadEmailAttachment };
|
||||
};
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import { getPrimaryEmailFromRecord } from '@/activities/emails/utils/getPrimaryEmailFromRecord';
|
||||
|
||||
const makeRecord = (emails: unknown): Record<string, unknown> => ({ emails });
|
||||
|
||||
describe('getPrimaryEmailFromRecord', () => {
|
||||
it('should return the primary email when present', () => {
|
||||
const record = makeRecord({ primaryEmail: 'alice@example.com' });
|
||||
|
||||
expect(getPrimaryEmailFromRecord(record)).toBe('alice@example.com');
|
||||
});
|
||||
|
||||
it('should return null when emails is null', () => {
|
||||
const record = makeRecord(null);
|
||||
|
||||
expect(getPrimaryEmailFromRecord(record)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when emails is undefined', () => {
|
||||
const record = makeRecord(undefined);
|
||||
|
||||
expect(getPrimaryEmailFromRecord(record)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when emails is not an object', () => {
|
||||
const record = makeRecord('not-an-object');
|
||||
|
||||
expect(getPrimaryEmailFromRecord(record)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when primaryEmail is missing', () => {
|
||||
const record = makeRecord({ additionalEmails: ['b@example.com'] });
|
||||
|
||||
expect(getPrimaryEmailFromRecord(record)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when primaryEmail is an empty string', () => {
|
||||
const record = makeRecord({ primaryEmail: '' });
|
||||
|
||||
expect(getPrimaryEmailFromRecord(record)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when primaryEmail is not a string', () => {
|
||||
const record = makeRecord({ primaryEmail: 42 });
|
||||
|
||||
expect(getPrimaryEmailFromRecord(record)).toBeNull();
|
||||
});
|
||||
});
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
|
||||
export const getPrimaryEmailFromRecord = (
|
||||
record: Record<string, unknown>,
|
||||
): string | null => {
|
||||
const emails = record.emails;
|
||||
|
||||
if (emails === null || typeof emails !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const primaryEmail = (emails as { primaryEmail?: unknown }).primaryEmail;
|
||||
|
||||
return isNonEmptyString(primaryEmail) ? primaryEmail : null;
|
||||
};
|
||||
+11
-40
@@ -1,10 +1,11 @@
|
||||
import { WorkflowAttachmentChip } from '@/advanced-text-editor/components/WorkflowAttachmentChip';
|
||||
import { useUploadWorkflowFile } from '@/advanced-text-editor/hooks/useUploadWorkflowFile';
|
||||
import { AttachmentChip } from '@/file/components/AttachmentChip';
|
||||
import { useFileUpload } from '@/file-upload/hooks/useFileUpload';
|
||||
import { InputLabel } from '@/ui/input/components/InputLabel';
|
||||
|
||||
import { styled } from '@linaria/react';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { type ChangeEvent, useContext, useRef } from 'react';
|
||||
import { useContext } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { type WorkflowAttachment } from 'twenty-shared/workflow';
|
||||
import { IconUpload } from 'twenty-ui/display';
|
||||
@@ -21,10 +22,6 @@ const StyledContainer = styled.div`
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
const StyledFileInput = styled.input`
|
||||
display: none;
|
||||
`;
|
||||
|
||||
const StyledUploadArea = styled.div<{ hasFiles: boolean }>`
|
||||
background-color: ${themeCssVariables.background.transparent.lighter};
|
||||
border: 1px solid ${themeCssVariables.border.color.medium};
|
||||
@@ -52,7 +49,6 @@ const StyledChipsContainer = styled.div`
|
||||
`;
|
||||
|
||||
const StyledUploadAreaLabel = styled.div`
|
||||
color: ${themeCssVariables.font.color.secondary};
|
||||
color: ${themeCssVariables.font.color.tertiary};
|
||||
display: flex;
|
||||
font-size: ${themeCssVariables.font.size.sm};
|
||||
@@ -67,26 +63,11 @@ export const WorkflowSendEmailAttachments = ({
|
||||
onChange,
|
||||
}: WorkflowSendEmailAttachmentsProps) => {
|
||||
const { theme } = useContext(ThemeContext);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { uploadWorkflowFile } = useUploadWorkflowFile();
|
||||
const { openFileUpload } = useFileUpload();
|
||||
const { t } = useLingui();
|
||||
const handleAddFileClick = (e: React.MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
|
||||
const isInsideChip = target.closest('[data-chip]') !== null;
|
||||
const isInsideButton = target.closest('button') !== null;
|
||||
const isSvgOrPath = target.tagName === 'svg' || target.tagName === 'path';
|
||||
|
||||
if (isInsideChip || isInsideButton || isSvgOrPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (fileInputRef.current !== null) {
|
||||
fileInputRef.current.click();
|
||||
}
|
||||
};
|
||||
|
||||
const onUploadFiles = async (filesToUpload: File[]) => {
|
||||
const handleUploadFiles = async (filesToUpload: File[]) => {
|
||||
const uploadedFiles = await Promise.all(
|
||||
filesToUpload.map((file) => uploadWorkflowFile(file)),
|
||||
);
|
||||
@@ -98,14 +79,11 @@ export const WorkflowSendEmailAttachments = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFiles = event.target.files;
|
||||
if (isDefined(selectedFiles)) {
|
||||
onUploadFiles(Array.from(selectedFiles));
|
||||
}
|
||||
if (fileInputRef.current !== null) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
const handleAddFileClick = () => {
|
||||
openFileUpload({
|
||||
multiple: true,
|
||||
onUpload: handleUploadFiles,
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveFile = (fileId: string) => {
|
||||
@@ -116,13 +94,6 @@ export const WorkflowSendEmailAttachments = ({
|
||||
<StyledContainer>
|
||||
{label ? <InputLabel>{label}</InputLabel> : null}
|
||||
|
||||
<StyledFileInput
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
|
||||
<StyledUploadArea
|
||||
hasFiles={files.length > 0}
|
||||
onClick={handleAddFileClick}
|
||||
@@ -130,7 +101,7 @@ export const WorkflowSendEmailAttachments = ({
|
||||
{files.length > 0 ? (
|
||||
<StyledChipsContainer>
|
||||
{files.map((file: WorkflowAttachment) => (
|
||||
<WorkflowAttachmentChip
|
||||
<AttachmentChip
|
||||
key={file.id}
|
||||
file={file}
|
||||
onRemove={() => handleRemoveFile(file.id)}
|
||||
|
||||
+53
-6
@@ -1,15 +1,61 @@
|
||||
import { useFirstConnectedAccount } from '@/activities/emails/hooks/useFirstConnectedAccount';
|
||||
import { useResolveDefaultEmailRecipient } from '@/activities/emails/hooks/useResolveDefaultEmailRecipient';
|
||||
import { getPrimaryEmailFromRecord } from '@/activities/emails/utils/getPrimaryEmailFromRecord';
|
||||
import { MAX_EMAIL_RECIPIENTS } from 'twenty-shared/constants';
|
||||
import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect';
|
||||
import { useHeadlessCommandContextApi } from '@/command-menu-item/engine-command/hooks/useHeadlessCommandContextApi';
|
||||
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
|
||||
import { useOpenComposeEmailInSidePanel } from '@/side-panel/hooks/useOpenComposeEmailInSidePanel';
|
||||
import { SettingsPath } from 'twenty-shared/types';
|
||||
import { CoreObjectNameSingular, SettingsPath } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
|
||||
|
||||
export const ComposeEmailCommand = () => {
|
||||
const { connectedAccountId, loading } = useFirstConnectedAccount();
|
||||
const { connectedAccountId, loading: accountLoading } =
|
||||
useFirstConnectedAccount();
|
||||
const { openComposeEmailInSidePanel } = useOpenComposeEmailInSidePanel();
|
||||
const navigateSettings = useNavigateSettings();
|
||||
|
||||
const {
|
||||
objectMetadataItem,
|
||||
selectedRecords,
|
||||
graphqlFilter,
|
||||
targetedRecordsRule,
|
||||
} = useHeadlessCommandContextApi();
|
||||
|
||||
const objectNameSingular = objectMetadataItem?.nameSingular ?? null;
|
||||
const isPerson = objectNameSingular === CoreObjectNameSingular.Person;
|
||||
|
||||
const isBulkPerson =
|
||||
isPerson &&
|
||||
(selectedRecords.length > 1 || targetedRecordsRule.mode === 'exclusion');
|
||||
|
||||
const { records: bulkPersonRecords, loading: bulkLoading } =
|
||||
useFindManyRecords({
|
||||
objectNameSingular: CoreObjectNameSingular.Person,
|
||||
filter: graphqlFilter ?? undefined,
|
||||
recordGqlFields: { id: true, emails: { primaryEmail: true } },
|
||||
limit: MAX_EMAIL_RECIPIENTS,
|
||||
skip: !isBulkPerson,
|
||||
});
|
||||
|
||||
const singleSelectedRecordId = !isBulkPerson
|
||||
? (selectedRecords[0]?.id ?? null)
|
||||
: null;
|
||||
|
||||
const { defaultTo: singleDefaultTo, loading: recipientLoading } =
|
||||
useResolveDefaultEmailRecipient({
|
||||
objectNameSingular,
|
||||
recordId: singleSelectedRecordId,
|
||||
});
|
||||
|
||||
const defaultTo = isBulkPerson
|
||||
? bulkPersonRecords
|
||||
.map(getPrimaryEmailFromRecord)
|
||||
.filter(isDefined)
|
||||
.join(', ')
|
||||
: singleDefaultTo;
|
||||
|
||||
const handleExecute = () => {
|
||||
if (!isDefined(connectedAccountId)) {
|
||||
navigateSettings(SettingsPath.NewAccount);
|
||||
@@ -19,13 +65,14 @@ export const ComposeEmailCommand = () => {
|
||||
|
||||
openComposeEmailInSidePanel({
|
||||
connectedAccountId,
|
||||
defaultTo,
|
||||
});
|
||||
};
|
||||
|
||||
const ready =
|
||||
!accountLoading && (isBulkPerson ? !bulkLoading : !recipientLoading);
|
||||
|
||||
return (
|
||||
<HeadlessEngineCommandWrapperEffect
|
||||
execute={handleExecute}
|
||||
ready={!loading}
|
||||
/>
|
||||
<HeadlessEngineCommandWrapperEffect execute={handleExecute} ready={ready} />
|
||||
);
|
||||
};
|
||||
|
||||
+19
-10
@@ -1,15 +1,15 @@
|
||||
import { getFileType } from '@/activities/files/utils/getFileType';
|
||||
import { useFileCategoryColors } from '@/file/hooks/useFileCategoryColors';
|
||||
import { IconMapping } from '@/file/utils/fileIconMappings';
|
||||
import { styled } from '@linaria/react';
|
||||
import { useContext } from 'react';
|
||||
import { type WorkflowAttachment } from 'twenty-shared/workflow';
|
||||
import { AvatarOrIcon } from 'twenty-ui/components';
|
||||
import { IconX } from 'twenty-ui/display';
|
||||
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
type WorkflowAttachmentChipProps = {
|
||||
file: WorkflowAttachment;
|
||||
import { getFileType } from '@/activities/files/utils/getFileType';
|
||||
import { useFileCategoryColors } from '@/file/hooks/useFileCategoryColors';
|
||||
import { IconMapping } from '@/file/utils/fileIconMappings';
|
||||
|
||||
type AttachmentChipProps = {
|
||||
file: { id: string; name: string };
|
||||
onRemove: () => void;
|
||||
readonly?: boolean;
|
||||
};
|
||||
@@ -59,16 +59,25 @@ const StyledDelete = styled.button`
|
||||
}
|
||||
`;
|
||||
|
||||
export const WorkflowAttachmentChip = ({
|
||||
export const AttachmentChip = ({
|
||||
file,
|
||||
onRemove,
|
||||
readonly = false,
|
||||
}: WorkflowAttachmentChipProps) => {
|
||||
}: AttachmentChipProps) => {
|
||||
const { theme } = useContext(ThemeContext);
|
||||
const iconColors = useFileCategoryColors();
|
||||
|
||||
const handleChipClick = (event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
const handleRemoveClick = (event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
onRemove();
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledChip data-chip deletable={!readonly}>
|
||||
<StyledChip deletable={!readonly} onClick={handleChipClick}>
|
||||
<AvatarOrIcon
|
||||
Icon={IconMapping[getFileType(file.name)]}
|
||||
IconBackgroundColor={iconColors[getFileType(file.name)]}
|
||||
@@ -76,7 +85,7 @@ export const WorkflowAttachmentChip = ({
|
||||
<StyledLabel title={file.name}>{file.name}</StyledLabel>
|
||||
|
||||
{!readonly && (
|
||||
<StyledDelete onClick={onRemove}>
|
||||
<StyledDelete onClick={handleRemoveClick}>
|
||||
<IconX size={theme.icon.size.sm} stroke={theme.icon.stroke.sm} />
|
||||
</StyledDelete>
|
||||
)}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const UPLOAD_EMAIL_ATTACHMENT_FILE = gql`
|
||||
mutation UploadEmailAttachmentFile($file: Upload!) {
|
||||
uploadEmailAttachmentFile(file: $file) {
|
||||
id
|
||||
path
|
||||
size
|
||||
createdAt
|
||||
url
|
||||
}
|
||||
}
|
||||
`;
|
||||
+3
-1
@@ -44,7 +44,9 @@ export const Disabled: Story = {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
const input = await waitFor(() => {
|
||||
const inputElement = canvasElement.querySelector('input');
|
||||
const inputElement = canvasElement.querySelector<HTMLInputElement>(
|
||||
'input:not([type="file"])',
|
||||
);
|
||||
expect(inputElement).not.toBeNull();
|
||||
return inputElement!;
|
||||
});
|
||||
|
||||
+18
-1
@@ -1,3 +1,4 @@
|
||||
import { useOpenEmailInAppOrFallback } from '@/activities/emails/hooks/useOpenEmailInAppOrFallback';
|
||||
import { useEmailsFieldDisplay } from '@/object-record/record-field/ui/meta-types/hooks/useEmailsFieldDisplay';
|
||||
import { EmailsDisplay } from '@/ui/field/display/components/EmailsDisplay';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
@@ -10,7 +11,14 @@ export const EmailsFieldDisplay = () => {
|
||||
const { copyToClipboard } = useCopyToClipboard();
|
||||
const { t } = useLingui();
|
||||
|
||||
const onClickAction = fieldDefinition.metadata.settings?.clickAction;
|
||||
const onClickAction =
|
||||
fieldDefinition.metadata.settings?.clickAction ??
|
||||
FieldMetadataSettingsOnClickAction.OPEN_IN_APP;
|
||||
|
||||
const isOpenInApp =
|
||||
onClickAction === FieldMetadataSettingsOnClickAction.OPEN_IN_APP;
|
||||
|
||||
const { openEmail } = useOpenEmailInAppOrFallback({ skip: !isOpenInApp });
|
||||
|
||||
const handleEmailClick = (
|
||||
email: string,
|
||||
@@ -19,6 +27,15 @@ export const EmailsFieldDisplay = () => {
|
||||
if (onClickAction === FieldMetadataSettingsOnClickAction.COPY) {
|
||||
event.preventDefault();
|
||||
copyToClipboard(email, t`Email copied to clipboard`);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (isOpenInApp) {
|
||||
event.preventDefault();
|
||||
openEmail(email);
|
||||
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+40
-15
@@ -1,3 +1,4 @@
|
||||
import { useOpenEmailInAppOrFallback } from '@/activities/emails/hooks/useOpenEmailInAppOrFallback';
|
||||
import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext';
|
||||
import {
|
||||
type FieldEmailsValue,
|
||||
@@ -12,13 +13,17 @@ import { t } from '@lingui/core/macro';
|
||||
import { useContext } from 'react';
|
||||
import { FieldMetadataSettingsOnClickAction } from 'twenty-shared/types';
|
||||
import { ensureAbsoluteUrl, isDefined } from 'twenty-shared/utils';
|
||||
import { IconArrowUpRight, IconCopy } from 'twenty-ui/display';
|
||||
import { IconArrowUpRight, IconCopy, IconMail } from 'twenty-ui/display';
|
||||
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
|
||||
|
||||
export const useGetSecondaryRecordTableCellButton = () => {
|
||||
const { fieldDefinition, recordId } = useContext(FieldContext);
|
||||
const { copyToClipboard } = useCopyToClipboard();
|
||||
|
||||
const isEmailField = isFieldEmails(fieldDefinition);
|
||||
|
||||
const { openEmail } = useOpenEmailInAppOrFallback({ skip: !isEmailField });
|
||||
|
||||
const fieldValue = useRecordFieldValue<
|
||||
FieldPhonesValue | FieldEmailsValue | FieldLinksValue | undefined
|
||||
>(recordId, fieldDefinition.metadata.fieldName, fieldDefinition);
|
||||
@@ -26,23 +31,31 @@ export const useGetSecondaryRecordTableCellButton = () => {
|
||||
if (
|
||||
(!isFieldPhones(fieldDefinition) &&
|
||||
!isFieldLinks(fieldDefinition) &&
|
||||
!isFieldEmails(fieldDefinition)) ||
|
||||
!isEmailField) ||
|
||||
!isDefined(fieldValue)
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const defaultClickAction = isEmailField
|
||||
? FieldMetadataSettingsOnClickAction.OPEN_IN_APP
|
||||
: FieldMetadataSettingsOnClickAction.OPEN_LINK;
|
||||
|
||||
const mainActionOnClick =
|
||||
fieldDefinition.metadata.settings?.clickAction ??
|
||||
FieldMetadataSettingsOnClickAction.OPEN_LINK;
|
||||
fieldDefinition.metadata.settings?.clickAction ?? defaultClickAction;
|
||||
|
||||
const openActionForFieldType = isEmailField
|
||||
? FieldMetadataSettingsOnClickAction.OPEN_IN_APP
|
||||
: FieldMetadataSettingsOnClickAction.OPEN_LINK;
|
||||
|
||||
const secondaryActionOnClick =
|
||||
mainActionOnClick === FieldMetadataSettingsOnClickAction.OPEN_LINK
|
||||
? FieldMetadataSettingsOnClickAction.COPY
|
||||
: FieldMetadataSettingsOnClickAction.OPEN_LINK;
|
||||
mainActionOnClick === FieldMetadataSettingsOnClickAction.COPY
|
||||
? openActionForFieldType
|
||||
: FieldMetadataSettingsOnClickAction.COPY;
|
||||
|
||||
let openLinkOnClick: () => void = () => {};
|
||||
let copyOnClick: () => void = () => {};
|
||||
let openInAppOnClick: () => void = () => {};
|
||||
|
||||
if (isFieldPhones(fieldDefinition)) {
|
||||
const { primaryPhoneCallingCode = '', primaryPhoneNumber = '' } =
|
||||
@@ -64,6 +77,9 @@ export const useGetSecondaryRecordTableCellButton = () => {
|
||||
copyOnClick = () => {
|
||||
copyToClipboard(email, t`Email copied to clipboard`);
|
||||
};
|
||||
openInAppOnClick = () => {
|
||||
openEmail(email);
|
||||
};
|
||||
}
|
||||
|
||||
if (isFieldLinks(fieldDefinition)) {
|
||||
@@ -76,16 +92,25 @@ export const useGetSecondaryRecordTableCellButton = () => {
|
||||
};
|
||||
}
|
||||
|
||||
const onClickByAction: Record<
|
||||
FieldMetadataSettingsOnClickAction,
|
||||
() => void
|
||||
> = {
|
||||
[FieldMetadataSettingsOnClickAction.OPEN_LINK]: openLinkOnClick,
|
||||
[FieldMetadataSettingsOnClickAction.COPY]: copyOnClick,
|
||||
[FieldMetadataSettingsOnClickAction.OPEN_IN_APP]: openInAppOnClick,
|
||||
};
|
||||
|
||||
const iconByAction = {
|
||||
[FieldMetadataSettingsOnClickAction.OPEN_LINK]: IconArrowUpRight,
|
||||
[FieldMetadataSettingsOnClickAction.COPY]: IconCopy,
|
||||
[FieldMetadataSettingsOnClickAction.OPEN_IN_APP]: IconMail,
|
||||
};
|
||||
|
||||
return [
|
||||
{
|
||||
onClick:
|
||||
secondaryActionOnClick === FieldMetadataSettingsOnClickAction.OPEN_LINK
|
||||
? openLinkOnClick
|
||||
: copyOnClick,
|
||||
Icon:
|
||||
secondaryActionOnClick === FieldMetadataSettingsOnClickAction.OPEN_LINK
|
||||
? IconArrowUpRight
|
||||
: IconCopy,
|
||||
onClick: onClickByAction[secondaryActionOnClick],
|
||||
Icon: iconByAction[secondaryActionOnClick],
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
+17
-7
@@ -9,7 +9,7 @@ import { useLingui } from '@lingui/react/macro';
|
||||
import {
|
||||
type FieldMetadataMultiItemSettings,
|
||||
FieldMetadataSettingsOnClickAction,
|
||||
type FieldMetadataType,
|
||||
FieldMetadataType,
|
||||
} from 'twenty-shared/types';
|
||||
import { IconClick } from 'twenty-ui/display';
|
||||
|
||||
@@ -35,7 +35,21 @@ export const SettingsDataModelFieldOnClickActionForm = ({
|
||||
existingFieldMetadataId,
|
||||
);
|
||||
|
||||
const isEmailField = fieldType === FieldMetadataType.EMAILS;
|
||||
|
||||
const defaultClickAction = isEmailField
|
||||
? FieldMetadataSettingsOnClickAction.OPEN_IN_APP
|
||||
: FieldMetadataSettingsOnClickAction.OPEN_LINK;
|
||||
|
||||
const options = [
|
||||
...(isEmailField
|
||||
? [
|
||||
{
|
||||
label: t`Open in app`,
|
||||
value: FieldMetadataSettingsOnClickAction.OPEN_IN_APP,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: t`Open as link`,
|
||||
value: FieldMetadataSettingsOnClickAction.OPEN_LINK,
|
||||
@@ -58,17 +72,13 @@ export const SettingsDataModelFieldOnClickActionForm = ({
|
||||
control={control}
|
||||
defaultValue={{
|
||||
...existingSettings,
|
||||
clickAction:
|
||||
existingSettings.clickAction ??
|
||||
FieldMetadataSettingsOnClickAction.OPEN_LINK,
|
||||
clickAction: existingSettings.clickAction ?? defaultClickAction,
|
||||
}}
|
||||
render={({ field: { value, onChange } }) => {
|
||||
const currentSettings =
|
||||
(value as FieldMetadataMultiItemSettings | undefined) ?? {};
|
||||
|
||||
const clickAction =
|
||||
currentSettings.clickAction ??
|
||||
FieldMetadataSettingsOnClickAction.OPEN_LINK;
|
||||
const clickAction = currentSettings.clickAction ?? defaultClickAction;
|
||||
|
||||
return (
|
||||
<SettingsOptionCardContentSelect
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { z } from 'zod';
|
||||
|
||||
export const settingsDataModelFieldOnClickActionSchema = z.object({
|
||||
settings: z.object({
|
||||
clickAction: z.enum(['COPY', 'OPEN_LINK']).optional(),
|
||||
clickAction: z.enum(['COPY', 'OPEN_LINK', 'OPEN_IN_APP']).optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
+2
@@ -3,6 +3,7 @@ import { Module } from '@nestjs/common';
|
||||
import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module';
|
||||
import { BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand } from 'src/database/commands/upgrade-version-command/1-22/1-22-workspace-command-1780000001000-backfill-page-layouts-and-fields-widget-view-fields.command';
|
||||
import { BackfillStandardSkillsCommand } from 'src/database/commands/upgrade-version-command/1-22/1-22-workspace-command-1780000002000-backfill-standard-skills.command';
|
||||
import { AddSendEmailRecordSelectionCommandMenuItemsCommand } from 'src/database/commands/upgrade-version-command/1-22/1-22-workspace-command-1775500016000-add-send-email-record-selection-command-menu-items.command';
|
||||
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
|
||||
@@ -17,6 +18,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
|
||||
WorkspaceMigrationModule,
|
||||
],
|
||||
providers: [
|
||||
AddSendEmailRecordSelectionCommandMenuItemsCommand,
|
||||
BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand,
|
||||
BackfillStandardSkillsCommand,
|
||||
],
|
||||
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
import { Command } from 'nest-commander';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner';
|
||||
import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
|
||||
import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner';
|
||||
import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
|
||||
import { STANDARD_COMMAND_MENU_ITEMS } from 'src/engine/workspace-manager/twenty-standard-application/constants/standard-command-menu-item.constant';
|
||||
import { computeTwentyStandardApplicationAllFlatEntityMaps } from 'src/engine/workspace-manager/twenty-standard-application/utils/twenty-standard-application-all-flat-entity-maps.constant';
|
||||
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
|
||||
|
||||
const SEND_EMAIL_RECORD_SELECTION_UNIVERSAL_IDENTIFIERS = [
|
||||
STANDARD_COMMAND_MENU_ITEMS.composeEmailToPerson.universalIdentifier,
|
||||
STANDARD_COMMAND_MENU_ITEMS.composeEmailToCompany.universalIdentifier,
|
||||
STANDARD_COMMAND_MENU_ITEMS.composeEmailToOpportunity.universalIdentifier,
|
||||
];
|
||||
|
||||
@RegisteredWorkspaceCommand('1.22.0', 1775500016000)
|
||||
@Command({
|
||||
name: 'upgrade:1-22:add-send-email-record-selection-command-menu-items',
|
||||
description:
|
||||
'Add the per-object Send Email command menu items (Person, Company, Opportunity) to existing workspaces',
|
||||
})
|
||||
export class AddSendEmailRecordSelectionCommandMenuItemsCommand extends ActiveOrSuspendedWorkspaceCommandRunner {
|
||||
constructor(
|
||||
protected readonly workspaceIteratorService: WorkspaceIteratorService,
|
||||
private readonly applicationService: ApplicationService,
|
||||
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
|
||||
private readonly workspaceCacheService: WorkspaceCacheService,
|
||||
) {
|
||||
super(workspaceIteratorService);
|
||||
}
|
||||
|
||||
override async runOnWorkspace({
|
||||
workspaceId,
|
||||
options,
|
||||
}: RunOnWorkspaceArgs): Promise<void> {
|
||||
const isDryRun = options.dryRun ?? false;
|
||||
|
||||
this.logger.log(
|
||||
`${isDryRun ? '[DRY RUN] ' : ''}Checking Send Email record-selection commands for workspace ${workspaceId}`,
|
||||
);
|
||||
|
||||
const { twentyStandardFlatApplication } =
|
||||
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
|
||||
{ workspaceId },
|
||||
);
|
||||
|
||||
const { flatCommandMenuItemMaps: existingFlatCommandMenuItemMaps } =
|
||||
await this.workspaceCacheService.getOrRecompute(workspaceId, [
|
||||
'flatCommandMenuItemMaps',
|
||||
]);
|
||||
|
||||
const missingUniversalIdentifiers =
|
||||
SEND_EMAIL_RECORD_SELECTION_UNIVERSAL_IDENTIFIERS.filter(
|
||||
(universalIdentifier) =>
|
||||
!isDefined(
|
||||
existingFlatCommandMenuItemMaps.byUniversalIdentifier[
|
||||
universalIdentifier
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (missingUniversalIdentifiers.length === 0) {
|
||||
this.logger.log(
|
||||
`Send Email record-selection commands already exist for workspace ${workspaceId}, skipping`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const { allFlatEntityMaps: standardAllFlatEntityMaps } =
|
||||
computeTwentyStandardApplicationAllFlatEntityMaps({
|
||||
shouldIncludeRecordPageLayouts: true,
|
||||
now: new Date().toISOString(),
|
||||
workspaceId,
|
||||
twentyStandardApplicationId: twentyStandardFlatApplication.id,
|
||||
});
|
||||
|
||||
const itemsToCreate = missingUniversalIdentifiers
|
||||
.map(
|
||||
(universalIdentifier) =>
|
||||
standardAllFlatEntityMaps.flatCommandMenuItemMaps
|
||||
.byUniversalIdentifier[universalIdentifier],
|
||||
)
|
||||
.filter(isDefined);
|
||||
|
||||
if (itemsToCreate.length === 0) {
|
||||
this.logger.warn(
|
||||
`Send Email record-selection commands not found in standard application for workspace ${workspaceId}`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDryRun) {
|
||||
this.logger.log(
|
||||
`[DRY RUN] Would create ${itemsToCreate.length} Send Email record-selection commands for workspace ${workspaceId}`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const validateAndBuildResult =
|
||||
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
|
||||
{
|
||||
allFlatEntityOperationByMetadataName: {
|
||||
commandMenuItem: {
|
||||
flatEntityToCreate: itemsToCreate,
|
||||
flatEntityToDelete: [],
|
||||
flatEntityToUpdate: [],
|
||||
},
|
||||
},
|
||||
workspaceId,
|
||||
applicationUniversalIdentifier:
|
||||
twentyStandardFlatApplication.universalIdentifier,
|
||||
},
|
||||
);
|
||||
|
||||
if (validateAndBuildResult.status === 'fail') {
|
||||
this.logger.error(
|
||||
`Failed to add Send Email record-selection commands:\n${JSON.stringify(validateAndBuildResult, null, 2)}`,
|
||||
);
|
||||
|
||||
throw new Error(
|
||||
`Failed to add Send Email record-selection commands for workspace ${workspaceId}`,
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Successfully added ${itemsToCreate.length} Send Email record-selection commands for workspace ${workspaceId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
|
||||
import { FileEmailAttachmentResolver } from 'src/engine/core-modules/file/file-email-attachment/resolvers/file-email-attachment.resolver';
|
||||
import { FileEmailAttachmentService } from 'src/engine/core-modules/file/file-email-attachment/services/file-email-attachment.service';
|
||||
import { FileUrlModule } from 'src/engine/core-modules/file/file-url/file-url.module';
|
||||
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
|
||||
|
||||
@Module({
|
||||
imports: [FileUrlModule, ApplicationModule, PermissionsModule],
|
||||
providers: [FileEmailAttachmentService, FileEmailAttachmentResolver],
|
||||
exports: [FileEmailAttachmentService],
|
||||
})
|
||||
export class FileEmailAttachmentModule {}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
|
||||
import { Args, Mutation } from '@nestjs/graphql';
|
||||
|
||||
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
|
||||
import { PermissionFlagType } from 'twenty-shared/constants';
|
||||
|
||||
import type { FileUpload } from 'graphql-upload/processRequest.mjs';
|
||||
|
||||
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
|
||||
import { FileWithSignedUrlDTO } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto';
|
||||
import { FileEmailAttachmentService } from 'src/engine/core-modules/file/file-email-attachment/services/file-email-attachment.service';
|
||||
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
|
||||
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import { streamToBuffer } from 'src/utils/stream-to-buffer';
|
||||
|
||||
@UseGuards(WorkspaceAuthGuard)
|
||||
@UsePipes(ResolverValidationPipe)
|
||||
@UseFilters(PreventNestToAutoLogGraphqlErrorsFilter)
|
||||
@MetadataResolver()
|
||||
export class FileEmailAttachmentResolver {
|
||||
constructor(
|
||||
private readonly fileEmailAttachmentService: FileEmailAttachmentService,
|
||||
) {}
|
||||
|
||||
@Mutation(() => FileWithSignedUrlDTO)
|
||||
@UseGuards(SettingsPermissionGuard(PermissionFlagType.UPLOAD_FILE))
|
||||
async uploadEmailAttachmentFile(
|
||||
@AuthWorkspace()
|
||||
{ id: workspaceId }: WorkspaceEntity,
|
||||
@Args({ name: 'file', type: () => GraphQLUpload })
|
||||
{ createReadStream, filename }: FileUpload,
|
||||
): Promise<FileWithSignedUrlDTO> {
|
||||
const stream = createReadStream();
|
||||
const buffer = await streamToBuffer(stream);
|
||||
|
||||
return await this.fileEmailAttachmentService.uploadFile({
|
||||
file: buffer,
|
||||
filename,
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { FileFolder } from 'twenty-shared/types';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
|
||||
import { FileWithSignedUrlDTO } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto';
|
||||
import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
|
||||
import { extractFileInfo } from 'src/engine/core-modules/file/utils/extract-file-info.utils';
|
||||
import { sanitizeFile } from 'src/engine/core-modules/file/utils/sanitize-file.utils';
|
||||
|
||||
@Injectable()
|
||||
export class FileEmailAttachmentService {
|
||||
private readonly logger = new Logger(FileEmailAttachmentService.name);
|
||||
|
||||
constructor(
|
||||
private readonly fileStorageService: FileStorageService,
|
||||
private readonly applicationService: ApplicationService,
|
||||
private readonly fileUrlService: FileUrlService,
|
||||
) {}
|
||||
|
||||
async uploadFile({
|
||||
file,
|
||||
filename,
|
||||
workspaceId,
|
||||
}: {
|
||||
file: Buffer;
|
||||
filename: string;
|
||||
workspaceId: string;
|
||||
}): Promise<FileWithSignedUrlDTO> {
|
||||
const { mimeType, ext } = await extractFileInfo({
|
||||
file,
|
||||
filename,
|
||||
});
|
||||
|
||||
const sanitizedFile = sanitizeFile({ file, ext, mimeType });
|
||||
|
||||
const fileId = v4();
|
||||
const name = `${fileId}${isNonEmptyString(ext) ? `.${ext}` : ''}`;
|
||||
|
||||
const { workspaceCustomFlatApplication } =
|
||||
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
|
||||
{
|
||||
workspaceId,
|
||||
},
|
||||
);
|
||||
|
||||
const savedFile = await this.fileStorageService.writeFile({
|
||||
sourceFile: sanitizedFile,
|
||||
resourcePath: name,
|
||||
mimeType,
|
||||
fileFolder: FileFolder.EmailAttachment,
|
||||
applicationUniversalIdentifier:
|
||||
workspaceCustomFlatApplication.universalIdentifier,
|
||||
workspaceId,
|
||||
fileId,
|
||||
settings: {
|
||||
isTemporaryFile: true,
|
||||
toDelete: false,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...savedFile,
|
||||
url: this.fileUrlService.signFileByIdUrl({
|
||||
fileId,
|
||||
workspaceId,
|
||||
fileFolder: FileFolder.EmailAttachment,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async deleteFiles({
|
||||
fileIds,
|
||||
workspaceId,
|
||||
}: {
|
||||
fileIds: string[];
|
||||
workspaceId: string;
|
||||
}): Promise<void> {
|
||||
for (const fileId of fileIds) {
|
||||
try {
|
||||
await this.fileStorageService.deleteByFileId({
|
||||
fileId,
|
||||
workspaceId,
|
||||
fileFolder: FileFolder.EmailAttachment,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Failed to delete email attachment file ${fileId}: ${error}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
|
||||
import { FileController } from './controllers/file.controller';
|
||||
import { FileEntity } from './entities/file.entity';
|
||||
import { FileCorePictureModule } from './file-core-picture/file-core-picture.module';
|
||||
import { FileEmailAttachmentModule } from './file-email-attachment/file-email-attachment.module';
|
||||
import { FileUrlModule } from './file-url/file-url.module';
|
||||
import { FileWorkflowModule } from './file-workflow/file-workflow.module';
|
||||
import { FilesFieldModule } from './files-field/files-field.module';
|
||||
@@ -34,6 +35,7 @@ import { FileService } from './services/file.service';
|
||||
FileCorePictureModule,
|
||||
FileWorkflowModule,
|
||||
FileAIChatModule,
|
||||
FileEmailAttachmentModule,
|
||||
SecureHttpClientModule,
|
||||
],
|
||||
providers: [
|
||||
@@ -52,6 +54,7 @@ import { FileService } from './services/file.service';
|
||||
FileCorePictureModule,
|
||||
FileWorkflowModule,
|
||||
FileAIChatModule,
|
||||
FileEmailAttachmentModule,
|
||||
],
|
||||
controllers: [FileController],
|
||||
})
|
||||
|
||||
@@ -12,6 +12,7 @@ export const SUPPORTED_FILE_FOLDERS = [
|
||||
FileFolder.FilesField,
|
||||
FileFolder.Workflow,
|
||||
FileFolder.AgentChat,
|
||||
FileFolder.EmailAttachment,
|
||||
FileFolder.AppTarball,
|
||||
] as const;
|
||||
|
||||
|
||||
+3
@@ -54,6 +54,9 @@ export const fileFolderConfigs: Record<FileFolder, FileFolderConfig> = {
|
||||
[FileFolder.Workflow]: {
|
||||
ignoreExpirationToken: false,
|
||||
},
|
||||
[FileFolder.EmailAttachment]: {
|
||||
ignoreExpirationToken: false,
|
||||
},
|
||||
[FileFolder.AppTarball]: {
|
||||
ignoreExpirationToken: false,
|
||||
},
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { MAX_EMAIL_RECIPIENTS } from 'twenty-shared/constants';
|
||||
+3
@@ -1,5 +1,7 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { FileFolder } from 'twenty-shared/types';
|
||||
|
||||
import { EmailComposerService } from 'src/engine/core-modules/tool/tools/email-tool/email-composer.service';
|
||||
import { EmailToolInputZodSchema } from 'src/engine/core-modules/tool/tools/email-tool/email-tool.schema';
|
||||
import { EmailToolException } from 'src/engine/core-modules/tool/tools/email-tool/exceptions/email-tool.exception';
|
||||
@@ -32,6 +34,7 @@ export class DraftEmailTool implements Tool {
|
||||
const result = await this.emailComposerService.composeEmail(
|
||||
parameters,
|
||||
context,
|
||||
{ attachmentsFileFolder: FileFolder.Workflow },
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
|
||||
+35
-10
@@ -4,9 +4,9 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { render, toPlainText } from '@react-email/render';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { reactMarkupFromJSON } from 'twenty-emails';
|
||||
import { FileFolder } from 'twenty-shared/types';
|
||||
import { MAX_EMAIL_RECIPIENTS } from 'twenty-shared/constants';
|
||||
import { type EmailAttachment, FileFolder } from 'twenty-shared/types';
|
||||
import { isDefined, isValidUuid } from 'twenty-shared/utils';
|
||||
import { WorkflowAttachment } from 'twenty-shared/workflow';
|
||||
import { In, type Repository } from 'typeorm';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
EmailToolExceptionCode,
|
||||
} from 'src/engine/core-modules/tool/tools/email-tool/exceptions/email-tool.exception';
|
||||
import { EmailComposerResult } from 'src/engine/core-modules/tool/tools/email-tool/types/email-composer-result.type';
|
||||
import { EmailToolInput } from 'src/engine/core-modules/tool/tools/email-tool/types/email-tool-input.type';
|
||||
import { type ComposeEmailParams } from 'src/engine/core-modules/tool/tools/email-tool/types/compose-email-params.type';
|
||||
import { parseCommaSeparatedEmails } from 'src/engine/core-modules/tool/tools/email-tool/utils/parse-comma-separated-emails.util';
|
||||
import { type ToolExecutionContext } from 'src/engine/core-modules/tool/types/tool-execution-context.type';
|
||||
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
|
||||
@@ -104,7 +104,7 @@ export class EmailComposerService {
|
||||
);
|
||||
}
|
||||
|
||||
private normalizeRecipients(parameters: EmailToolInput): {
|
||||
private normalizeRecipients(parameters: ComposeEmailParams): {
|
||||
to: string[];
|
||||
cc: string[];
|
||||
bcc: string[];
|
||||
@@ -157,9 +157,26 @@ export class EmailComposerService {
|
||||
return invalidEmails;
|
||||
}
|
||||
|
||||
private assertRecipientCountWithinLimit(recipients: {
|
||||
to: string[];
|
||||
cc: string[];
|
||||
bcc: string[];
|
||||
}): void {
|
||||
const total =
|
||||
recipients.to.length + recipients.cc.length + recipients.bcc.length;
|
||||
|
||||
if (total > MAX_EMAIL_RECIPIENTS) {
|
||||
throw new EmailToolException(
|
||||
`Too many recipients: ${total}. Maximum allowed is ${MAX_EMAIL_RECIPIENTS}.`,
|
||||
EmailToolExceptionCode.TOO_MANY_RECIPIENTS,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async getAttachments(
|
||||
files: Array<WorkflowAttachment>,
|
||||
files: Array<EmailAttachment>,
|
||||
workspaceId: string,
|
||||
fileFolder: FileFolder,
|
||||
): Promise<MessageAttachment[]> {
|
||||
if (files.length === 0) {
|
||||
return [];
|
||||
@@ -168,7 +185,7 @@ export class EmailComposerService {
|
||||
const fileIds = files.map((file) => file.id);
|
||||
|
||||
const fileEntities = await this.fileRepository.find({
|
||||
where: { id: In(fileIds) },
|
||||
where: { id: In(fileIds), workspaceId },
|
||||
});
|
||||
|
||||
const fileEntityMap = new Map(
|
||||
@@ -193,10 +210,12 @@ export class EmailComposerService {
|
||||
const attachments: MessageAttachment[] = [];
|
||||
|
||||
for (const fileMetadata of files) {
|
||||
const fileEntity = fileEntityMap.get(fileMetadata.id);
|
||||
|
||||
const { stream } = await this.fileService.getFileStreamById({
|
||||
fileId: fileMetadata.id,
|
||||
workspaceId,
|
||||
fileFolder: FileFolder.Workflow,
|
||||
fileFolder,
|
||||
});
|
||||
|
||||
const buffer = await streamToBuffer(stream);
|
||||
@@ -204,7 +223,7 @@ export class EmailComposerService {
|
||||
attachments.push({
|
||||
filename: fileMetadata.name,
|
||||
content: buffer,
|
||||
contentType: fileMetadata.type,
|
||||
contentType: fileEntity?.mimeType ?? 'application/octet-stream',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -256,8 +275,9 @@ export class EmailComposerService {
|
||||
}
|
||||
|
||||
async composeEmail(
|
||||
parameters: EmailToolInput,
|
||||
parameters: ComposeEmailParams,
|
||||
context: ToolExecutionContext,
|
||||
options: { attachmentsFileFolder: FileFolder },
|
||||
): Promise<EmailComposerResult> {
|
||||
const { workspaceId } = context;
|
||||
const { subject, body, files, inReplyTo } = parameters;
|
||||
@@ -267,6 +287,7 @@ export class EmailComposerService {
|
||||
|
||||
try {
|
||||
recipients = this.normalizeRecipients(parameters);
|
||||
this.assertRecipientCountWithinLimit(recipients);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Invalid recipients';
|
||||
@@ -334,7 +355,11 @@ export class EmailComposerService {
|
||||
refreshToken,
|
||||
};
|
||||
|
||||
const attachments = await this.getAttachments(files || [], workspaceId);
|
||||
const attachments = await this.getAttachments(
|
||||
files || [],
|
||||
workspaceId,
|
||||
options.attachmentsFileFolder,
|
||||
);
|
||||
|
||||
const parsedBody = parseEmailBody(body);
|
||||
const reactMarkup = reactMarkupFromJSON(parsedBody);
|
||||
|
||||
+3
@@ -11,6 +11,7 @@ export enum EmailToolExceptionCode {
|
||||
WORKSPACE_ID_NOT_FOUND = 'WORKSPACE_ID_NOT_FOUND',
|
||||
FILE_NOT_FOUND = 'FILE_NOT_FOUND',
|
||||
INVALID_FILE_ID = 'INVALID_FILE_ID',
|
||||
TOO_MANY_RECIPIENTS = 'TOO_MANY_RECIPIENTS',
|
||||
}
|
||||
|
||||
const getEmailToolExceptionUserFriendlyMessage = (
|
||||
@@ -29,6 +30,8 @@ const getEmailToolExceptionUserFriendlyMessage = (
|
||||
return msg`File not found.`;
|
||||
case EmailToolExceptionCode.INVALID_FILE_ID:
|
||||
return msg`Invalid file ID.`;
|
||||
case EmailToolExceptionCode.TOO_MANY_RECIPIENTS:
|
||||
return msg`Too many recipients.`;
|
||||
default:
|
||||
assertUnreachable(code);
|
||||
}
|
||||
|
||||
+3
@@ -1,5 +1,7 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { FileFolder } from 'twenty-shared/types';
|
||||
|
||||
import { EmailComposerService } from 'src/engine/core-modules/tool/tools/email-tool/email-composer.service';
|
||||
import { EmailToolInputZodSchema } from 'src/engine/core-modules/tool/tools/email-tool/email-tool.schema';
|
||||
import { EmailToolException } from 'src/engine/core-modules/tool/tools/email-tool/exceptions/email-tool.exception';
|
||||
@@ -31,6 +33,7 @@ export class SendEmailTool implements Tool {
|
||||
const result = await this.emailComposerService.composeEmail(
|
||||
parameters,
|
||||
context,
|
||||
{ attachmentsFileFolder: FileFolder.Workflow },
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import { type EmailAttachment } from 'twenty-shared/types';
|
||||
|
||||
export type ComposeEmailParams = {
|
||||
recipients: {
|
||||
to: string;
|
||||
cc?: string;
|
||||
bcc?: string;
|
||||
};
|
||||
subject: string;
|
||||
body: string;
|
||||
connectedAccountId?: string;
|
||||
files?: Array<EmailAttachment>;
|
||||
inReplyTo?: string;
|
||||
};
|
||||
+45
@@ -943,4 +943,49 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
|
||||
hotKeys: null,
|
||||
payload: { path: '/settings/updates' },
|
||||
},
|
||||
composeEmailToPerson: {
|
||||
universalIdentifier: 'f01d4b8b-2b4e-4ae0-9c6f-0b9a9a3e5b21',
|
||||
label: 'Send Email',
|
||||
icon: 'IconMail',
|
||||
isPinned: true,
|
||||
position: 63,
|
||||
shortLabel: 'Send Email',
|
||||
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
|
||||
conditionalAvailabilityExpression: 'numberOfSelectedRecords >= 1',
|
||||
availabilityObjectMetadataUniversalIdentifier:
|
||||
STANDARD_OBJECTS.person.universalIdentifier,
|
||||
frontComponentUniversalIdentifier: null,
|
||||
engineComponentKey: EngineComponentKey.COMPOSE_EMAIL,
|
||||
hotKeys: null,
|
||||
},
|
||||
composeEmailToCompany: {
|
||||
universalIdentifier: 'a76d3ab8-4c3a-4e5d-8a4a-1f5d6e7f8a90',
|
||||
label: 'Send Email',
|
||||
icon: 'IconMail',
|
||||
isPinned: true,
|
||||
position: 64,
|
||||
shortLabel: 'Send Email',
|
||||
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
|
||||
conditionalAvailabilityExpression: 'numberOfSelectedRecords == 1',
|
||||
availabilityObjectMetadataUniversalIdentifier:
|
||||
STANDARD_OBJECTS.company.universalIdentifier,
|
||||
frontComponentUniversalIdentifier: null,
|
||||
engineComponentKey: EngineComponentKey.COMPOSE_EMAIL,
|
||||
hotKeys: null,
|
||||
},
|
||||
composeEmailToOpportunity: {
|
||||
universalIdentifier: 'b3e7c9f2-5d6e-4f7a-8b9c-0d1e2f3a4b5c',
|
||||
label: 'Send Email',
|
||||
icon: 'IconMail',
|
||||
isPinned: true,
|
||||
position: 65,
|
||||
shortLabel: 'Send Email',
|
||||
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
|
||||
conditionalAvailabilityExpression: 'numberOfSelectedRecords == 1',
|
||||
availabilityObjectMetadataUniversalIdentifier:
|
||||
STANDARD_OBJECTS.opportunity.universalIdentifier,
|
||||
frontComponentUniversalIdentifier: null,
|
||||
engineComponentKey: EngineComponentKey.COMPOSE_EMAIL,
|
||||
hotKeys: null,
|
||||
},
|
||||
} as const;
|
||||
|
||||
+12
@@ -1,5 +1,14 @@
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
|
||||
@InputType()
|
||||
export class SendEmailAttachmentInput {
|
||||
@Field(() => String)
|
||||
id: string;
|
||||
|
||||
@Field(() => String)
|
||||
name: string;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class SendEmailInput {
|
||||
@Field(() => String)
|
||||
@@ -22,4 +31,7 @@ export class SendEmailInput {
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
inReplyTo?: string;
|
||||
|
||||
@Field(() => [SendEmailAttachmentInput], { nullable: true })
|
||||
files?: SendEmailAttachmentInput[];
|
||||
}
|
||||
|
||||
+15
-1
@@ -7,8 +7,11 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { Args, Mutation } from '@nestjs/graphql';
|
||||
|
||||
import { FileFolder } from 'twenty-shared/types';
|
||||
|
||||
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
|
||||
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
|
||||
import { FileEmailAttachmentService } from 'src/engine/core-modules/file/file-email-attachment/services/file-email-attachment.service';
|
||||
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { EmailComposerService } from 'src/engine/core-modules/tool/tools/email-tool/email-composer.service';
|
||||
@@ -31,6 +34,7 @@ export class SendEmailResolver {
|
||||
constructor(
|
||||
private readonly connectedAccountMetadataService: ConnectedAccountMetadataService,
|
||||
private readonly emailComposerService: EmailComposerService,
|
||||
private readonly fileEmailAttachmentService: FileEmailAttachmentService,
|
||||
private readonly sendEmailService: SendEmailService,
|
||||
) {}
|
||||
|
||||
@@ -57,10 +61,11 @@ export class SendEmailResolver {
|
||||
subject: input.subject,
|
||||
body: input.body,
|
||||
connectedAccountId: input.connectedAccountId,
|
||||
files: [],
|
||||
files: input.files ?? [],
|
||||
inReplyTo: input.inReplyTo,
|
||||
},
|
||||
{ workspaceId: workspace.id },
|
||||
{ attachmentsFileFolder: FileFolder.EmailAttachment },
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
@@ -80,6 +85,15 @@ export class SendEmailResolver {
|
||||
workspace.id,
|
||||
);
|
||||
|
||||
const attachmentFileIds = (input.files ?? []).map((file) => file.id);
|
||||
|
||||
if (attachmentFileIds.length > 0) {
|
||||
await this.fileEmailAttachmentService.deleteFiles({
|
||||
fileIds: attachmentFileIds,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (error instanceof ForbiddenException) {
|
||||
|
||||
+2
@@ -1,5 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { FileEmailAttachmentModule } from 'src/engine/core-modules/file/file-email-attachment/file-email-attachment.module';
|
||||
import { ToolModule } from 'src/engine/core-modules/tool/tool.module';
|
||||
import { ConnectedAccountMetadataModule } from 'src/engine/metadata-modules/connected-account/connected-account-metadata.module';
|
||||
import { SendEmailResolver } from 'src/modules/messaging/message-outbound-manager/resolvers/send-email.resolver';
|
||||
@@ -7,6 +8,7 @@ import { MessagingSendManagerModule } from 'src/modules/messaging/message-outbou
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
FileEmailAttachmentModule,
|
||||
ToolModule,
|
||||
MessagingSendManagerModule,
|
||||
ConnectedAccountMetadataModule,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const MAX_EMAIL_RECIPIENTS = 100;
|
||||
@@ -31,6 +31,7 @@ export { FILES_FIELD_MAX_NUMBER_OF_VALUES } from './FilesFieldMaxNumberOfValues'
|
||||
export { GROUP_BY_DATE_GRANULARITY_THAT_REQUIRE_TIME_ZONE } from './GroupByDateGranularityThatRequireTimeZone';
|
||||
export { IANA_TIME_ZONES } from './IanaTimeZones';
|
||||
export { LABEL_IDENTIFIER_FIELD_METADATA_TYPES } from './LabelIdentifierFieldMetadataTypes';
|
||||
export { MAX_EMAIL_RECIPIENTS } from './MaxEmailRecipients';
|
||||
export { MULTI_ITEM_FIELD_DEFAULT_MAX_VALUES } from './MultiItemFieldDefaultMaxValues';
|
||||
export { MULTI_ITEM_FIELD_MIN_MAX_VALUES } from './MultiItemFieldMinMaxValues';
|
||||
export { MUTATION_MAX_MERGE_RECORDS } from './MutationMaxMergeRecords';
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export type EmailAttachment = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
export enum FieldMetadataSettingsOnClickAction {
|
||||
COPY = 'COPY',
|
||||
OPEN_LINK = 'OPEN_LINK',
|
||||
OPEN_IN_APP = 'OPEN_IN_APP',
|
||||
}
|
||||
|
||||
export type FieldMetadataMultiItemSettings = {
|
||||
|
||||
@@ -13,6 +13,7 @@ export enum FileFolder {
|
||||
FilesField = 'files-field',
|
||||
Dependencies = 'dependencies',
|
||||
Workflow = 'workflow',
|
||||
EmailAttachment = 'email-attachment',
|
||||
AppTarball = 'app-tarball',
|
||||
GeneratedSdkClient = 'generated-sdk-client',
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ export type { ConfirmationModalCaller } from './ConfirmationModalCaller';
|
||||
export { ConnectedAccountProvider } from './ConnectedAccountProvider';
|
||||
export { CoreObjectNameSingular } from './CoreObjectNameSingular';
|
||||
export { CrudOperationType } from './CrudOperationType';
|
||||
export type { EmailAttachment } from './EmailAttachment';
|
||||
export type {
|
||||
SnackBarVariant,
|
||||
EnqueueSnackbarParams,
|
||||
|
||||
Reference in New Issue
Block a user