Add email thread widget and message thread record page layout (#19351)

## Summary
- Move email thread display from side panel to a dedicated record page
with a new `EMAIL_THREAD` widget type
- Add message thread as a standard object with page layout, subject
field, and backfill command
- Add reply-to-email command menu item for message thread records
- Remove old side panel message thread components in favor of the new
widget-based approach

## Type fixes
- Add `EMAIL_THREAD` to `WidgetConfigurationType`, `WidgetType`, and all
configuration/validator maps
- Create `EmailThreadConfigurationDTO` and shared
`EmailThreadConfiguration` type
- Register EMAIL_THREAD in widget type validators, configuration
resolvers, and standard widget mappings

## Test plan
- [ ] Verify message thread record pages render with the email thread
widget
- [ ] Verify email thread preview navigates to the record page instead
of opening side panel
- [ ] Verify reply-to-email command appears for message thread records
- [ ] Verify typecheck passes for both twenty-front and twenty-server
- [ ] Run existing test suites to check for regressions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Félix Malfait
2026-04-06 12:02:04 +02:00
committed by GitHub
parent 4aa1d71b12
commit 8acfacc69c
56 changed files with 2441 additions and 2371 deletions

View File

@@ -921,6 +921,7 @@ enum WidgetType {
WORKFLOW_RUN
FRONT_COMPONENT
RECORD_TABLE
EMAIL_THREAD
}
union PageLayoutWidgetPosition = PageLayoutWidgetGridPosition | PageLayoutWidgetVerticalListPosition | PageLayoutWidgetCanvasPosition
@@ -948,7 +949,7 @@ type PageLayoutWidgetCanvasPosition {
layoutMode: PageLayoutTabLayoutMode!
}
union WidgetConfiguration = AggregateChartConfiguration | StandaloneRichTextConfiguration | PieChartConfiguration | LineChartConfiguration | IframeConfiguration | GaugeChartConfiguration | BarChartConfiguration | CalendarConfiguration | FrontComponentConfiguration | EmailsConfiguration | FieldConfiguration | FieldRichTextConfiguration | FieldsConfiguration | FilesConfiguration | NotesConfiguration | TasksConfiguration | TimelineConfiguration | ViewConfiguration | RecordTableConfiguration | WorkflowConfiguration | WorkflowRunConfiguration | WorkflowVersionConfiguration
union WidgetConfiguration = AggregateChartConfiguration | StandaloneRichTextConfiguration | PieChartConfiguration | LineChartConfiguration | IframeConfiguration | GaugeChartConfiguration | BarChartConfiguration | CalendarConfiguration | FrontComponentConfiguration | EmailsConfiguration | EmailThreadConfiguration | FieldConfiguration | FieldRichTextConfiguration | FieldsConfiguration | FilesConfiguration | NotesConfiguration | TasksConfiguration | TimelineConfiguration | ViewConfiguration | RecordTableConfiguration | WorkflowConfiguration | WorkflowRunConfiguration | WorkflowVersionConfiguration
type AggregateChartConfiguration {
configurationType: WidgetConfigurationType!
@@ -989,6 +990,7 @@ enum WidgetConfigurationType {
WORKFLOW_RUN
FRONT_COMPONENT
RECORD_TABLE
EMAIL_THREAD
}
type StandaloneRichTextConfiguration {
@@ -1154,6 +1156,10 @@ type EmailsConfiguration {
configurationType: WidgetConfigurationType!
}
type EmailThreadConfiguration {
configurationType: WidgetConfigurationType!
}
type FieldConfiguration {
configurationType: WidgetConfigurationType!
fieldMetadataId: String!
@@ -2632,6 +2638,7 @@ enum EngineComponentKey {
VIEW_PREVIOUS_AI_CHATS
TRIGGER_WORKFLOW_VERSION
FRONT_COMPONENT_RENDERER
REPLY_TO_EMAIL_THREAD
DELETE_SINGLE_RECORD
DELETE_MULTIPLE_RECORDS
RESTORE_SINGLE_RECORD

View File

@@ -676,7 +676,7 @@ export interface PageLayoutWidget {
__typename: 'PageLayoutWidget'
}
export type WidgetType = 'VIEW' | 'IFRAME' | 'FIELD' | 'FIELDS' | 'GRAPH' | 'STANDALONE_RICH_TEXT' | 'TIMELINE' | 'TASKS' | 'NOTES' | 'FILES' | 'EMAILS' | 'CALENDAR' | 'FIELD_RICH_TEXT' | 'WORKFLOW' | 'WORKFLOW_VERSION' | 'WORKFLOW_RUN' | 'FRONT_COMPONENT' | 'RECORD_TABLE'
export type WidgetType = 'VIEW' | 'IFRAME' | 'FIELD' | 'FIELDS' | 'GRAPH' | 'STANDALONE_RICH_TEXT' | 'TIMELINE' | 'TASKS' | 'NOTES' | 'FILES' | 'EMAILS' | 'CALENDAR' | 'FIELD_RICH_TEXT' | 'WORKFLOW' | 'WORKFLOW_VERSION' | 'WORKFLOW_RUN' | 'FRONT_COMPONENT' | 'RECORD_TABLE' | 'EMAIL_THREAD'
export type PageLayoutWidgetPosition = (PageLayoutWidgetGridPosition | PageLayoutWidgetVerticalListPosition | PageLayoutWidgetCanvasPosition) & { __isUnion?: true }
@@ -702,7 +702,7 @@ export interface PageLayoutWidgetCanvasPosition {
__typename: 'PageLayoutWidgetCanvasPosition'
}
export type WidgetConfiguration = (AggregateChartConfiguration | StandaloneRichTextConfiguration | PieChartConfiguration | LineChartConfiguration | IframeConfiguration | GaugeChartConfiguration | BarChartConfiguration | CalendarConfiguration | FrontComponentConfiguration | EmailsConfiguration | FieldConfiguration | FieldRichTextConfiguration | FieldsConfiguration | FilesConfiguration | NotesConfiguration | TasksConfiguration | TimelineConfiguration | ViewConfiguration | RecordTableConfiguration | WorkflowConfiguration | WorkflowRunConfiguration | WorkflowVersionConfiguration) & { __isUnion?: true }
export type WidgetConfiguration = (AggregateChartConfiguration | StandaloneRichTextConfiguration | PieChartConfiguration | LineChartConfiguration | IframeConfiguration | GaugeChartConfiguration | BarChartConfiguration | CalendarConfiguration | FrontComponentConfiguration | EmailsConfiguration | EmailThreadConfiguration | FieldConfiguration | FieldRichTextConfiguration | FieldsConfiguration | FilesConfiguration | NotesConfiguration | TasksConfiguration | TimelineConfiguration | ViewConfiguration | RecordTableConfiguration | WorkflowConfiguration | WorkflowRunConfiguration | WorkflowVersionConfiguration) & { __isUnion?: true }
export interface AggregateChartConfiguration {
configurationType: WidgetConfigurationType
@@ -721,7 +721,7 @@ export interface AggregateChartConfiguration {
__typename: 'AggregateChartConfiguration'
}
export type WidgetConfigurationType = 'AGGREGATE_CHART' | 'GAUGE_CHART' | 'PIE_CHART' | 'BAR_CHART' | 'LINE_CHART' | 'IFRAME' | 'STANDALONE_RICH_TEXT' | 'VIEW' | 'FIELD' | 'FIELDS' | 'TIMELINE' | 'TASKS' | 'NOTES' | 'FILES' | 'EMAILS' | 'CALENDAR' | 'FIELD_RICH_TEXT' | 'WORKFLOW' | 'WORKFLOW_VERSION' | 'WORKFLOW_RUN' | 'FRONT_COMPONENT' | 'RECORD_TABLE'
export type WidgetConfigurationType = 'AGGREGATE_CHART' | 'GAUGE_CHART' | 'PIE_CHART' | 'BAR_CHART' | 'LINE_CHART' | 'IFRAME' | 'STANDALONE_RICH_TEXT' | 'VIEW' | 'FIELD' | 'FIELDS' | 'TIMELINE' | 'TASKS' | 'NOTES' | 'FILES' | 'EMAILS' | 'CALENDAR' | 'FIELD_RICH_TEXT' | 'WORKFLOW' | 'WORKFLOW_VERSION' | 'WORKFLOW_RUN' | 'FRONT_COMPONENT' | 'RECORD_TABLE' | 'EMAIL_THREAD'
export interface StandaloneRichTextConfiguration {
configurationType: WidgetConfigurationType
@@ -869,6 +869,11 @@ export interface EmailsConfiguration {
__typename: 'EmailsConfiguration'
}
export interface EmailThreadConfiguration {
configurationType: WidgetConfigurationType
__typename: 'EmailThreadConfiguration'
}
export interface FieldConfiguration {
configurationType: WidgetConfigurationType
fieldMetadataId: Scalars['String']
@@ -2290,7 +2295,7 @@ export interface CommandMenuItem {
__typename: 'CommandMenuItem'
}
export type EngineComponentKey = 'NAVIGATE_TO_NEXT_RECORD' | 'NAVIGATE_TO_PREVIOUS_RECORD' | 'CREATE_NEW_RECORD' | 'DELETE_RECORDS' | 'RESTORE_RECORDS' | 'DESTROY_RECORDS' | 'ADD_TO_FAVORITES' | 'REMOVE_FROM_FAVORITES' | 'EXPORT_NOTE_TO_PDF' | 'EXPORT_RECORDS' | 'UPDATE_MULTIPLE_RECORDS' | 'MERGE_MULTIPLE_RECORDS' | 'IMPORT_RECORDS' | 'EXPORT_VIEW' | 'SEE_DELETED_RECORDS' | 'CREATE_NEW_VIEW' | 'HIDE_DELETED_RECORDS' | 'GO_TO_PEOPLE' | 'GO_TO_COMPANIES' | 'GO_TO_DASHBOARDS' | 'GO_TO_OPPORTUNITIES' | 'GO_TO_SETTINGS' | 'GO_TO_TASKS' | 'GO_TO_NOTES' | 'EDIT_RECORD_PAGE_LAYOUT' | 'EDIT_DASHBOARD_LAYOUT' | 'SAVE_DASHBOARD_LAYOUT' | 'CANCEL_DASHBOARD_LAYOUT' | 'DUPLICATE_DASHBOARD' | 'GO_TO_WORKFLOWS' | 'ACTIVATE_WORKFLOW' | 'DEACTIVATE_WORKFLOW' | 'DISCARD_DRAFT_WORKFLOW' | 'TEST_WORKFLOW' | 'SEE_ACTIVE_VERSION_WORKFLOW' | 'SEE_RUNS_WORKFLOW' | 'SEE_VERSIONS_WORKFLOW' | 'ADD_NODE_WORKFLOW' | 'TIDY_UP_WORKFLOW' | 'DUPLICATE_WORKFLOW' | 'GO_TO_RUNS' | 'SEE_VERSION_WORKFLOW_RUN' | 'SEE_WORKFLOW_WORKFLOW_RUN' | 'STOP_WORKFLOW_RUN' | 'SEE_RUNS_WORKFLOW_VERSION' | 'SEE_WORKFLOW_WORKFLOW_VERSION' | 'USE_AS_DRAFT_WORKFLOW_VERSION' | 'SEE_VERSIONS_WORKFLOW_VERSION' | 'SEARCH_RECORDS' | 'SEARCH_RECORDS_FALLBACK' | 'ASK_AI' | 'VIEW_PREVIOUS_AI_CHATS' | 'TRIGGER_WORKFLOW_VERSION' | 'FRONT_COMPONENT_RENDERER' | 'DELETE_SINGLE_RECORD' | 'DELETE_MULTIPLE_RECORDS' | 'RESTORE_SINGLE_RECORD' | 'RESTORE_MULTIPLE_RECORDS' | 'DESTROY_SINGLE_RECORD' | 'DESTROY_MULTIPLE_RECORDS' | 'EXPORT_FROM_RECORD_INDEX' | 'EXPORT_FROM_RECORD_SHOW' | 'EXPORT_MULTIPLE_RECORDS'
export type EngineComponentKey = 'NAVIGATE_TO_NEXT_RECORD' | 'NAVIGATE_TO_PREVIOUS_RECORD' | 'CREATE_NEW_RECORD' | 'DELETE_RECORDS' | 'RESTORE_RECORDS' | 'DESTROY_RECORDS' | 'ADD_TO_FAVORITES' | 'REMOVE_FROM_FAVORITES' | 'EXPORT_NOTE_TO_PDF' | 'EXPORT_RECORDS' | 'UPDATE_MULTIPLE_RECORDS' | 'MERGE_MULTIPLE_RECORDS' | 'IMPORT_RECORDS' | 'EXPORT_VIEW' | 'SEE_DELETED_RECORDS' | 'CREATE_NEW_VIEW' | 'HIDE_DELETED_RECORDS' | 'GO_TO_PEOPLE' | 'GO_TO_COMPANIES' | 'GO_TO_DASHBOARDS' | 'GO_TO_OPPORTUNITIES' | 'GO_TO_SETTINGS' | 'GO_TO_TASKS' | 'GO_TO_NOTES' | 'EDIT_RECORD_PAGE_LAYOUT' | 'EDIT_DASHBOARD_LAYOUT' | 'SAVE_DASHBOARD_LAYOUT' | 'CANCEL_DASHBOARD_LAYOUT' | 'DUPLICATE_DASHBOARD' | 'GO_TO_WORKFLOWS' | 'ACTIVATE_WORKFLOW' | 'DEACTIVATE_WORKFLOW' | 'DISCARD_DRAFT_WORKFLOW' | 'TEST_WORKFLOW' | 'SEE_ACTIVE_VERSION_WORKFLOW' | 'SEE_RUNS_WORKFLOW' | 'SEE_VERSIONS_WORKFLOW' | 'ADD_NODE_WORKFLOW' | 'TIDY_UP_WORKFLOW' | 'DUPLICATE_WORKFLOW' | 'GO_TO_RUNS' | 'SEE_VERSION_WORKFLOW_RUN' | 'SEE_WORKFLOW_WORKFLOW_RUN' | 'STOP_WORKFLOW_RUN' | 'SEE_RUNS_WORKFLOW_VERSION' | 'SEE_WORKFLOW_WORKFLOW_VERSION' | 'USE_AS_DRAFT_WORKFLOW_VERSION' | 'SEE_VERSIONS_WORKFLOW_VERSION' | 'SEARCH_RECORDS' | 'SEARCH_RECORDS_FALLBACK' | 'ASK_AI' | 'VIEW_PREVIOUS_AI_CHATS' | 'TRIGGER_WORKFLOW_VERSION' | 'FRONT_COMPONENT_RENDERER' | 'REPLY_TO_EMAIL_THREAD' | 'DELETE_SINGLE_RECORD' | 'DELETE_MULTIPLE_RECORDS' | 'RESTORE_SINGLE_RECORD' | 'RESTORE_MULTIPLE_RECORDS' | 'DESTROY_SINGLE_RECORD' | 'DESTROY_MULTIPLE_RECORDS' | 'EXPORT_FROM_RECORD_INDEX' | 'EXPORT_FROM_RECORD_SHOW' | 'EXPORT_MULTIPLE_RECORDS'
export type CommandMenuItemAvailabilityType = 'GLOBAL' | 'RECORD_SELECTION' | 'FALLBACK'
@@ -3790,6 +3795,7 @@ export interface WidgetConfigurationGenqlSelection{
on_CalendarConfiguration?:CalendarConfigurationGenqlSelection,
on_FrontComponentConfiguration?:FrontComponentConfigurationGenqlSelection,
on_EmailsConfiguration?:EmailsConfigurationGenqlSelection,
on_EmailThreadConfiguration?:EmailThreadConfigurationGenqlSelection,
on_FieldConfiguration?:FieldConfigurationGenqlSelection,
on_FieldRichTextConfiguration?:FieldRichTextConfigurationGenqlSelection,
on_FieldsConfiguration?:FieldsConfigurationGenqlSelection,
@@ -3958,6 +3964,12 @@ export interface EmailsConfigurationGenqlSelection{
__scalar?: boolean | number
}
export interface EmailThreadConfigurationGenqlSelection{
configurationType?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface FieldConfigurationGenqlSelection{
configurationType?: boolean | number
fieldMetadataId?: boolean | number
@@ -6956,7 +6968,7 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const WidgetConfiguration_possibleTypes: string[] = ['AggregateChartConfiguration','StandaloneRichTextConfiguration','PieChartConfiguration','LineChartConfiguration','IframeConfiguration','GaugeChartConfiguration','BarChartConfiguration','CalendarConfiguration','FrontComponentConfiguration','EmailsConfiguration','FieldConfiguration','FieldRichTextConfiguration','FieldsConfiguration','FilesConfiguration','NotesConfiguration','TasksConfiguration','TimelineConfiguration','ViewConfiguration','RecordTableConfiguration','WorkflowConfiguration','WorkflowRunConfiguration','WorkflowVersionConfiguration']
const WidgetConfiguration_possibleTypes: string[] = ['AggregateChartConfiguration','StandaloneRichTextConfiguration','PieChartConfiguration','LineChartConfiguration','IframeConfiguration','GaugeChartConfiguration','BarChartConfiguration','CalendarConfiguration','FrontComponentConfiguration','EmailsConfiguration','EmailThreadConfiguration','FieldConfiguration','FieldRichTextConfiguration','FieldsConfiguration','FilesConfiguration','NotesConfiguration','TasksConfiguration','TimelineConfiguration','ViewConfiguration','RecordTableConfiguration','WorkflowConfiguration','WorkflowRunConfiguration','WorkflowVersionConfiguration']
export const isWidgetConfiguration = (obj?: { __typename?: any } | null): obj is WidgetConfiguration => {
if (!obj?.__typename) throw new Error('__typename is missing in "isWidgetConfiguration"')
return WidgetConfiguration_possibleTypes.includes(obj.__typename)
@@ -7044,6 +7056,14 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const EmailThreadConfiguration_possibleTypes: string[] = ['EmailThreadConfiguration']
export const isEmailThreadConfiguration = (obj?: { __typename?: any } | null): obj is EmailThreadConfiguration => {
if (!obj?.__typename) throw new Error('__typename is missing in "isEmailThreadConfiguration"')
return EmailThreadConfiguration_possibleTypes.includes(obj.__typename)
}
const FieldConfiguration_possibleTypes: string[] = ['FieldConfiguration']
export const isFieldConfiguration = (obj?: { __typename?: any } | null): obj is FieldConfiguration => {
if (!obj?.__typename) throw new Error('__typename is missing in "isFieldConfiguration"')
@@ -8912,7 +8932,8 @@ export const enumWidgetType = {
WORKFLOW_VERSION: 'WORKFLOW_VERSION' as const,
WORKFLOW_RUN: 'WORKFLOW_RUN' as const,
FRONT_COMPONENT: 'FRONT_COMPONENT' as const,
RECORD_TABLE: 'RECORD_TABLE' as const
RECORD_TABLE: 'RECORD_TABLE' as const,
EMAIL_THREAD: 'EMAIL_THREAD' as const
}
export const enumPageLayoutTabLayoutMode = {
@@ -8943,7 +8964,8 @@ export const enumWidgetConfigurationType = {
WORKFLOW_VERSION: 'WORKFLOW_VERSION' as const,
WORKFLOW_RUN: 'WORKFLOW_RUN' as const,
FRONT_COMPONENT: 'FRONT_COMPONENT' as const,
RECORD_TABLE: 'RECORD_TABLE' as const
RECORD_TABLE: 'RECORD_TABLE' as const,
EMAIL_THREAD: 'EMAIL_THREAD' as const
}
export const enumObjectRecordGroupByDateGranularity = {
@@ -9251,6 +9273,7 @@ export const enumEngineComponentKey = {
VIEW_PREVIOUS_AI_CHATS: 'VIEW_PREVIOUS_AI_CHATS' as const,
TRIGGER_WORKFLOW_VERSION: 'TRIGGER_WORKFLOW_VERSION' as const,
FRONT_COMPONENT_RENDERER: 'FRONT_COMPONENT_RENDERER' as const,
REPLY_TO_EMAIL_THREAD: 'REPLY_TO_EMAIL_THREAD' as const,
DELETE_SINGLE_RECORD: 'DELETE_SINGLE_RECORD' as const,
DELETE_MULTIPLE_RECORDS: 'DELETE_MULTIPLE_RECORDS' as const,
RESTORE_SINGLE_RECORD: 'RESTORE_SINGLE_RECORD' as const,

View File

File diff suppressed because it is too large Load Diff

View File

File diff suppressed because one or more lines are too long

View File

@@ -2,9 +2,10 @@ import { styled } from '@linaria/react';
import { ActivityRow } from '@/activities/components/ActivityRow';
import { EmailThreadNotShared } from '@/activities/emails/components/EmailThreadNotShared';
import { useOpenEmailThreadInSidePanel } from '@/side-panel/hooks/useOpenEmailThreadInSidePanel';
import { useOpenRecordInSidePanel } from '@/side-panel/hooks/useOpenRecordInSidePanel';
import { useContext } from 'react';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { Avatar } from 'twenty-ui/display';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
@@ -77,7 +78,7 @@ type EmailThreadPreviewProps = {
export const EmailThreadPreview = ({ thread }: EmailThreadPreviewProps) => {
const { theme } = useContext(ThemeContext);
const { openEmailThreadInSidePanel } = useOpenEmailThreadInSidePanel();
const { openRecordInSidePanel } = useOpenRecordInSidePanel();
const visibility = thread.visibility;
@@ -104,7 +105,10 @@ export const EmailThreadPreview = ({ thread }: EmailThreadPreviewProps) => {
thread.visibility === MessageChannelVisibility.SHARE_EVERYTHING;
if (canOpen) {
openEmailThreadInSidePanel(thread.id);
openRecordInSidePanel({
recordId: thread.id,
objectNameSingular: CoreObjectNameSingular.MessageThread,
});
}
};

View File

@@ -1,29 +1,22 @@
import { useCallback, useEffect, useState } from 'react';
import { type MessageChannel } from '@/accounts/types/MessageChannel';
import { fetchAllThreadMessagesOperationSignatureFactory } from '@/activities/emails/graphql/operation-signatures/factories/fetchAllThreadMessagesOperationSignatureFactory';
import { type EmailThread } from '@/activities/emails/types/EmailThread';
import { type EmailThreadMessage } from '@/activities/emails/types/EmailThreadMessage';
import { type MessageChannel } from '@/accounts/types/MessageChannel';
import { type EmailThreadMessageParticipant } from '@/activities/emails/types/EmailThreadMessageParticipant';
import { type EmailThreadMessageWithSender } from '@/activities/emails/types/EmailThreadMessageWithSender';
import { type MessageChannelMessageAssociation } from '@/activities/emails/types/MessageChannelMessageAssociation';
import { viewableRecordIdComponentState } from '@/side-panel/pages/record-page/states/viewableRecordIdComponentState';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
import {
CoreObjectNameSingular,
MessageParticipantRole,
} from 'twenty-shared/types';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { isDefined } from 'twenty-shared/utils';
// to improve - https://github.com/twentyhq/twenty/issues/12190
export const useEmailThreadInSidePanel = () => {
const viewableRecordId = useAtomComponentStateValue(
viewableRecordIdComponentState,
);
export const useEmailThread = (threadId: string | null) => {
const { upsertRecordsInStore } = useUpsertRecordsInStore();
const [lastMessageId, setLastMessageId] = useState<string | null>(null);
const [lastMessageChannelId, setLastMessageChannelId] = useState<
@@ -33,7 +26,7 @@ export const useEmailThreadInSidePanel = () => {
const { record: thread } = useFindOneRecord<EmailThread>({
objectNameSingular: CoreObjectNameSingular.MessageThread,
objectRecordId: viewableRecordId ?? '',
objectRecordId: threadId ?? '',
recordGqlFields: {
id: true,
},
@@ -47,7 +40,7 @@ export const useEmailThreadInSidePanel = () => {
const FETCH_ALL_MESSAGES_OPERATION_SIGNATURE =
fetchAllThreadMessagesOperationSignatureFactory({
messageThreadId: viewableRecordId,
messageThreadId: threadId,
});
const {
@@ -62,7 +55,7 @@ export const useEmailThreadInSidePanel = () => {
FETCH_ALL_MESSAGES_OPERATION_SIGNATURE.objectNameSingular,
orderBy: FETCH_ALL_MESSAGES_OPERATION_SIGNATURE.variables.orderBy,
recordGqlFields: FETCH_ALL_MESSAGES_OPERATION_SIGNATURE.fields,
skip: !viewableRecordId,
skip: !threadId,
});
const fetchMoreMessages = useCallback(() => {
@@ -76,11 +69,11 @@ export const useEmailThreadInSidePanel = () => {
useEffect(() => {
if (messages.length > 0 && isMessagesFetchComplete) {
const lastMessage = messages[messages.length - 1];
setLastMessageId(lastMessage.id);
}
}, [messages, isMessagesFetchComplete]);
// TODO: introduce nested filters so we can retrieve the message sender directly from the message query
const { records: messageSenders } =
useFindManyRecords<EmailThreadMessageParticipant>({
filter: {
@@ -167,9 +160,11 @@ export const useEmailThreadInSidePanel = () => {
const sender = messageSenders.find(
(messageSender) => messageSender.messageId === message.id,
);
if (!sender) {
return null;
}
return {
...message,
sender,

View File

@@ -3,7 +3,7 @@ import { styled } from '@linaria/react';
import { type EmailThreadMessage } from '@/activities/emails/types/EmailThreadMessage';
import { EventCardMessageBodyNotShared } from '@/activities/timeline-activities/rows/message/components/EventCardMessageBodyNotShared';
import { EventCardMessageForbidden } from '@/activities/timeline-activities/rows/message/components/EventCardMessageForbidden';
import { useOpenEmailThreadInSidePanel } from '@/side-panel/hooks/useOpenEmailThreadInSidePanel';
import { useOpenRecordInSidePanel } from '@/side-panel/hooks/useOpenRecordInSidePanel';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { Trans, useLingui } from '@lingui/react/macro';
@@ -62,7 +62,7 @@ export const EventCardMessage = ({
authorFullName: string;
}) => {
const { t } = useLingui();
const { openEmailThreadInSidePanel } = useOpenEmailThreadInSidePanel();
const { openRecordInSidePanel } = useOpenRecordInSidePanel();
const {
record: message,
@@ -133,7 +133,10 @@ export const EventCardMessage = ({
const handleClick = () => {
if (canOpen && isDefined(message.messageThreadId)) {
openEmailThreadInSidePanel(message.messageThreadId);
openRecordInSidePanel({
recordId: message.messageThreadId,
objectNameSingular: CoreObjectNameSingular.MessageThread,
});
}
};

View File

@@ -40,6 +40,7 @@ import { TestWorkflowSingleRecordCommand } from '@/command-menu-item/engine-comm
import { TidyUpWorkflowSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/workflow/components/TidyUpWorkflowSingleRecordCommand';
import { HeadlessFrontComponentRendererEngineCommand } from '@/command-menu-item/engine-command/components/HeadlessFrontComponentRendererEngineCommand';
import { TriggerWorkflowVersionEngineCommand } from '@/command-menu-item/engine-command/record/components/TriggerWorkflowVersionEngineCommand';
import { ReplyToEmailThreadCommand } from '@/command-menu-item/engine-command/record/single-record/message-thread/components/ReplyToEmailThreadCommand';
import { CoreObjectNamePlural } from '@/object-metadata/types/CoreObjectNamePlural';
import { msg } from '@lingui/core/macro';
import { AppPath, SettingsPath, SidePanelPages } from 'twenty-shared/types';
@@ -240,6 +241,7 @@ export const ENGINE_COMPONENT_KEY_COMPONENT_MAP: Record<
[EngineComponentKey.FRONT_COMPONENT_RENDERER]: (
<HeadlessFrontComponentRendererEngineCommand />
),
[EngineComponentKey.REPLY_TO_EMAIL_THREAD]: <ReplyToEmailThreadCommand />,
// Deprecated keys kept for backward compatibility until migration runs
[EngineComponentKey.DELETE_SINGLE_RECORD]: <DeleteRecordsCommand />,

View File

@@ -0,0 +1,78 @@
import { useMemo } from 'react';
import { useEmailThread } from '@/activities/emails/hooks/useEmailThread';
import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect';
import { useHeadlessCommandContextApi } from '@/command-menu-item/engine-command/hooks/useHeadlessCommandContextApi';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
const ALLOWED_REPLY_PROVIDERS = [
ConnectedAccountProvider.GOOGLE,
ConnectedAccountProvider.MICROSOFT,
ConnectedAccountProvider.IMAP_SMTP_CALDAV,
];
export const ReplyToEmailThreadCommand = () => {
const { selectedRecords } = useHeadlessCommandContextApi();
const threadId = selectedRecords[0]?.id ?? null;
const {
messageThreadExternalId,
connectedAccountHandle,
connectedAccountProvider,
lastMessageExternalId,
connectedAccountConnectionParameters,
messageChannelLoading,
} = useEmailThread(threadId);
const canReply = useMemo(() => {
return (
isDefined(connectedAccountHandle) &&
isDefined(connectedAccountProvider) &&
ALLOWED_REPLY_PROVIDERS.includes(connectedAccountProvider) &&
(connectedAccountProvider !== ConnectedAccountProvider.IMAP_SMTP_CALDAV ||
isDefined(connectedAccountConnectionParameters?.SMTP)) &&
isDefined(messageThreadExternalId)
);
}, [
connectedAccountConnectionParameters,
connectedAccountHandle,
connectedAccountProvider,
messageThreadExternalId,
]);
const handleExecute = () => {
if (!canReply) {
return;
}
switch (connectedAccountProvider) {
case ConnectedAccountProvider.MICROSOFT: {
const url = `https://outlook.office.com/mail/deeplink?ItemID=${lastMessageExternalId}`;
window.open(url, '_blank');
break;
}
case ConnectedAccountProvider.GOOGLE: {
const url = `https://mail.google.com/mail/?authuser=${connectedAccountHandle}#all/${messageThreadExternalId}`;
window.open(url, '_blank');
break;
}
case ConnectedAccountProvider.IMAP_SMTP_CALDAV:
case ConnectedAccountProvider.OIDC:
case ConnectedAccountProvider.SAML:
case null:
return;
default:
return;
}
};
const isReady = !messageChannelLoading && canReply;
return (
<HeadlessEngineCommandWrapperEffect
execute={handleExecute}
ready={isReady}
/>
);
};

View File

@@ -0,0 +1,61 @@
import { DEFAULT_MESSAGE_THREAD_RECORD_PAGE_LAYOUT_ID } from '@/page-layout/constants/DefaultMessageThreadRecordPageLayoutId';
import { type PageLayout } from '@/page-layout/types/PageLayout';
import {
PageLayoutTabLayoutMode,
PageLayoutType,
WidgetConfigurationType,
WidgetType,
} from '~/generated-metadata/graphql';
export const DEFAULT_MESSAGE_THREAD_RECORD_PAGE_LAYOUT: PageLayout = {
__typename: 'PageLayout',
id: DEFAULT_MESSAGE_THREAD_RECORD_PAGE_LAYOUT_ID,
name: 'Default Message Thread Layout',
type: PageLayoutType.RECORD_PAGE,
objectMetadataId: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
tabs: [
{
__typename: 'PageLayoutTab',
applicationId: '',
id: 'message-thread-tab-home',
title: 'Home',
icon: 'IconHome',
position: 100,
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
pageLayoutId: DEFAULT_MESSAGE_THREAD_RECORD_PAGE_LAYOUT_ID,
isOverridden: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
widgets: [
{
__typename: 'PageLayoutWidget',
id: 'message-thread-widget-email-thread',
pageLayoutTabId: 'message-thread-tab-home',
title: 'Thread',
type: WidgetType.EMAIL_THREAD,
objectMetadataId: null,
gridPosition: {
__typename: 'GridPosition',
row: 0,
column: 0,
rowSpan: 12,
columnSpan: 12,
},
configuration: {
__typename: 'FieldsConfiguration',
configurationType: WidgetConfigurationType.FIELDS,
viewId: null,
},
isOverridden: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
},
],
},
],
};

View File

@@ -0,0 +1,2 @@
export const DEFAULT_MESSAGE_THREAD_RECORD_PAGE_LAYOUT_ID =
'default-message-thread-page-layout';

View File

@@ -159,6 +159,9 @@ export const PAGE_LAYOUT_WIDGET_FRAGMENT = gql`
... on EmailsConfiguration {
configurationType
}
... on EmailThreadConfiguration {
configurationType
}
... on FieldConfiguration {
configurationType
fieldDisplayMode

View File

@@ -1,5 +1,7 @@
import { DEFAULT_COMPANY_RECORD_PAGE_LAYOUT } from '@/page-layout/constants/DefaultCompanyRecordPageLayout';
import { DEFAULT_COMPANY_RECORD_PAGE_LAYOUT_ID } from '@/page-layout/constants/DefaultCompanyRecordPageLayoutId';
import { DEFAULT_MESSAGE_THREAD_RECORD_PAGE_LAYOUT } from '@/page-layout/constants/DefaultMessageThreadRecordPageLayout';
import { DEFAULT_MESSAGE_THREAD_RECORD_PAGE_LAYOUT_ID } from '@/page-layout/constants/DefaultMessageThreadRecordPageLayoutId';
import { DEFAULT_NOTE_RECORD_PAGE_LAYOUT } from '@/page-layout/constants/DefaultNoteRecordPageLayout';
import { DEFAULT_NOTE_RECORD_PAGE_LAYOUT_ID } from '@/page-layout/constants/DefaultNoteRecordPageLayoutId';
import { DEFAULT_OPPORTUNITY_RECORD_PAGE_LAYOUT } from '@/page-layout/constants/DefaultOpportunityRecordPageLayout';
@@ -42,6 +44,8 @@ const getDefaultLayoutById = (layoutId: string): PageLayout => {
return DEFAULT_WORKFLOW_VERSION_PAGE_LAYOUT;
case DEFAULT_WORKFLOW_RUN_PAGE_LAYOUT_ID:
return DEFAULT_WORKFLOW_RUN_PAGE_LAYOUT;
case DEFAULT_MESSAGE_THREAD_RECORD_PAGE_LAYOUT_ID:
return DEFAULT_MESSAGE_THREAD_RECORD_PAGE_LAYOUT;
case DEFAULT_RECORD_PAGE_LAYOUT_ID:
default:
return DEFAULT_RECORD_PAGE_LAYOUT;
@@ -57,7 +61,8 @@ const isDefaultLayoutId = (layoutId: string): boolean =>
layoutId === DEFAULT_TASK_RECORD_PAGE_LAYOUT_ID ||
layoutId === DEFAULT_WORKFLOW_PAGE_LAYOUT_ID ||
layoutId === DEFAULT_WORKFLOW_VERSION_PAGE_LAYOUT_ID ||
layoutId === DEFAULT_WORKFLOW_RUN_PAGE_LAYOUT_ID;
layoutId === DEFAULT_WORKFLOW_RUN_PAGE_LAYOUT_ID ||
layoutId === DEFAULT_MESSAGE_THREAD_RECORD_PAGE_LAYOUT_ID;
export const useBasePageLayout = (
pageLayoutId: string,

View File

@@ -1,5 +1,6 @@
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { DEFAULT_COMPANY_RECORD_PAGE_LAYOUT_ID } from '@/page-layout/constants/DefaultCompanyRecordPageLayoutId';
import { DEFAULT_MESSAGE_THREAD_RECORD_PAGE_LAYOUT_ID } from '@/page-layout/constants/DefaultMessageThreadRecordPageLayoutId';
import { DEFAULT_NOTE_RECORD_PAGE_LAYOUT_ID } from '@/page-layout/constants/DefaultNoteRecordPageLayoutId';
import { DEFAULT_OPPORTUNITY_RECORD_PAGE_LAYOUT_ID } from '@/page-layout/constants/DefaultOpportunityRecordPageLayoutId';
import { DEFAULT_PERSON_RECORD_PAGE_LAYOUT_ID } from '@/page-layout/constants/DefaultPersonRecordPageLayoutId';
@@ -20,6 +21,8 @@ const OBJECT_NAME_TO_DEFAULT_LAYOUT_ID: Record<string, string> = {
[CoreObjectNameSingular.WorkflowVersion]:
DEFAULT_WORKFLOW_VERSION_PAGE_LAYOUT_ID,
[CoreObjectNameSingular.WorkflowRun]: DEFAULT_WORKFLOW_RUN_PAGE_LAYOUT_ID,
[CoreObjectNameSingular.MessageThread]:
DEFAULT_MESSAGE_THREAD_RECORD_PAGE_LAYOUT_ID,
};
export const getDefaultRecordPageLayoutId = ({

View File

@@ -1,5 +1,6 @@
import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget';
import { CalendarWidget } from '@/page-layout/widgets/calendar/components/CalendarWidget';
import { EmailThreadWidget } from '@/page-layout/widgets/email-thread/components/EmailThreadWidget';
import { EmailWidget } from '@/page-layout/widgets/emails/components/EmailWidget';
import { FieldRichTextWidgetRenderer } from '@/page-layout/widgets/field-rich-text/components/FieldRichTextWidgetRenderer';
import { FieldWidget } from '@/page-layout/widgets/field/components/FieldWidget';
@@ -77,6 +78,9 @@ export const WidgetContentRenderer = ({
case WidgetType.RECORD_TABLE:
return <RecordTableWidgetRenderer widget={widget} />;
case WidgetType.EMAIL_THREAD:
return <EmailThreadWidget widget={widget} />;
default:
return null;
}

View File

@@ -111,11 +111,14 @@ export const WidgetRenderer = ({ widget }: WidgetRendererProps) => {
// TODO: when we have more widgets without headers, we should use a more generic approach to hide the header
// each widget type could have metadata (e.g., hasHeader: boolean or headerMode: 'always' | 'editOnly' | 'never')
const isRichTextWidget = widget.type === WidgetType.STANDALONE_RICH_TEXT;
const hideRichTextHeader = isRichTextWidget && !isPageLayoutInEditMode;
const isHeaderHiddenInViewMode =
widget.type === WidgetType.STANDALONE_RICH_TEXT ||
widget.type === WidgetType.EMAIL_THREAD;
const hideHeaderInViewMode =
isHeaderHiddenInViewMode && !isPageLayoutInEditMode;
const showHeader =
layoutMode !== PageLayoutTabLayoutMode.CANVAS && !hideRichTextHeader;
layoutMode !== PageLayoutTabLayoutMode.CANVAS && !hideHeaderInViewMode;
const handleClick = () => {
handleEditWidget({

View File

@@ -3,23 +3,25 @@ import { useState } from 'react';
import { EmailThreadMessage } from '@/activities/emails/components/EmailThreadMessage';
import { type EmailThreadMessageWithSender } from '@/activities/emails/types/EmailThreadMessageWithSender';
import { Button } from 'twenty-ui/input';
import { t } from '@lingui/core/macro';
import { IconArrowsVertical } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledButtonContainer = styled.div`
border-bottom: 1px solid ${themeCssVariables.border.color.light};
padding: 16px 24px;
padding: ${themeCssVariables.spacing[4]} ${themeCssVariables.spacing[6]};
`;
export const SidePanelMessageThreadIntermediaryMessages = ({
export const EmailThreadIntermediaryMessages = ({
messages,
}: {
messages: EmailThreadMessageWithSender[];
}) => {
const [areMessagesOpen, setAreMessagesOpen] = useState(false);
const messagesLength = messages.length;
if (messages.length === 0) {
if (messagesLength === 0) {
return null;
}
@@ -37,7 +39,7 @@ export const SidePanelMessageThreadIntermediaryMessages = ({
<StyledButtonContainer>
<Button
Icon={IconArrowsVertical}
title={`${messages.length} email${messages.length > 1 ? 's' : ''}`}
title={t`${messagesLength} emails`}
size="small"
onClick={() => setAreMessagesOpen(true)}
/>

View File

@@ -0,0 +1,91 @@
import { styled } from '@linaria/react';
import { CustomResolverFetchMoreLoader } from '@/activities/components/CustomResolverFetchMoreLoader';
import { EmailLoader } from '@/activities/emails/components/EmailLoader';
import { EmailThreadMessage } from '@/activities/emails/components/EmailThreadMessage';
import { useEmailThread } from '@/activities/emails/hooks/useEmailThread';
import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget';
import { EmailThreadIntermediaryMessages } from '@/page-layout/widgets/email-thread/components/EmailThreadIntermediaryMessages';
import { useTargetRecord } from '@/ui/layout/contexts/useTargetRecord';
import { t } from '@lingui/core/macro';
const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
width: 100%;
`;
const StyledContainer = styled.div`
box-sizing: border-box;
display: flex;
flex-direction: column;
overflow-y: auto;
`;
type EmailThreadWidgetProps = {
widget: PageLayoutWidget;
};
export const EmailThreadWidget = ({
widget: _widget,
}: EmailThreadWidgetProps) => {
const targetRecord = useTargetRecord();
const { thread, messages, fetchMoreMessages, threadLoading } = useEmailThread(
targetRecord.id,
);
const messagesCount = messages.length;
const is5OrMoreMessages = messagesCount >= 5;
const firstMessages = messages.slice(
0,
is5OrMoreMessages ? 2 : messagesCount - 1,
);
const intermediaryMessages = is5OrMoreMessages
? messages.slice(2, messagesCount - 1)
: [];
const lastMessage = messages[messagesCount - 1];
if (threadLoading || !thread || !messages.length) {
return (
<StyledWrapper>
<StyledContainer>
<EmailLoader loadingText={t`Loading thread`} />
</StyledContainer>
</StyledWrapper>
);
}
return (
<StyledWrapper>
<StyledContainer>
{
<>
{firstMessages.map((message) => (
<EmailThreadMessage
key={message.id}
sender={message.sender}
participants={message.messageParticipants}
body={message.text}
sentAt={message.receivedAt}
/>
))}
<EmailThreadIntermediaryMessages messages={intermediaryMessages} />
<EmailThreadMessage
key={lastMessage.id}
sender={lastMessage.sender}
participants={lastMessage.messageParticipants}
body={lastMessage.text}
sentAt={lastMessage.receivedAt}
isExpanded
/>
<CustomResolverFetchMoreLoader
loading={threadLoading}
onLastRowVisible={fetchMoreMessages}
/>
</>
}
</StyledContainer>
</StyledWrapper>
);
};

View File

@@ -6,7 +6,6 @@ import { SidePanelAIChatThreadsPage } from '@/side-panel/pages/ai-chat-threads/c
import { SidePanelAskAIPage } from '@/side-panel/pages/ask-ai/components/SidePanelAskAIPage';
import { SidePanelCalendarEventPage } from '@/side-panel/pages/calendar-event/components/SidePanelCalendarEventPage';
import { SidePanelFrontComponentPage } from '@/side-panel/pages/front-component/components/SidePanelFrontComponentPage';
import { SidePanelMessageThreadPage } from '@/side-panel/pages/message-thread/components/SidePanelMessageThreadPage';
import { SidePanelPageLayoutChartSettings } from '@/side-panel/pages/page-layout/components/SidePanelPageLayoutChartSettings';
import { SidePanelPageLayoutFieldSettings } from '@/side-panel/pages/page-layout/components/SidePanelPageLayoutFieldSettings';
import { SidePanelPageLayoutFieldsSettings } from '@/side-panel/pages/page-layout/components/SidePanelPageLayoutFieldsSettings';
@@ -37,7 +36,6 @@ export const SIDE_PANEL_PAGES_CONFIG = new Map<SidePanelPages, React.ReactNode>(
[SidePanelPages.ViewRecord, <SidePanelRecordPage />],
[SidePanelPages.MergeRecords, <SidePanelMergeRecordPage />],
[SidePanelPages.UpdateRecords, <SidePanelUpdateMultipleRecords />],
[SidePanelPages.ViewEmailThread, <SidePanelMessageThreadPage />],
[SidePanelPages.ViewCalendarEvent, <SidePanelCalendarEventPage />],
[SidePanelPages.EditRichText, <SidePanelEditRichTextPage />],
[

View File

@@ -1,89 +0,0 @@
import { renderHook } from '@testing-library/react';
import { act } from 'react';
import { SIDE_PANEL_COMPONENT_INSTANCE_ID } from '@/side-panel/constants/SidePanelComponentInstanceId';
import { useOpenEmailThreadInSidePanel } from '@/side-panel/hooks/useOpenEmailThreadInSidePanel';
import { viewableRecordIdComponentState } from '@/side-panel/pages/record-page/states/viewableRecordIdComponentState';
import { ContextStoreViewType } from '@/context-store/types/ContextStoreViewType';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { SidePanelPages } from 'twenty-shared/types';
import { IconMail } from 'twenty-ui/display';
import { getJestMetadataAndApolloMocksAndCommandMenuWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksAndCommandMenuWrapper';
import { getTestEnrichedObjectMetadataItemsMock } from '~/testing/utils/getTestEnrichedObjectMetadataItemsMock';
jest.mock('uuid', () => ({
v4: jest.fn().mockReturnValue('mocked-uuid'),
}));
const mockNavigateSidePanel = jest.fn();
jest.mock('@/side-panel/hooks/useNavigateSidePanel', () => ({
useNavigateSidePanel: () => ({
navigateSidePanel: mockNavigateSidePanel,
}),
}));
const personMockObjectMetadataItem =
getTestEnrichedObjectMetadataItemsMock().find(
(item) => item.nameSingular === 'person',
)!;
const wrapper = getJestMetadataAndApolloMocksAndCommandMenuWrapper({
apolloMocks: [],
componentInstanceId: SIDE_PANEL_COMPONENT_INSTANCE_ID,
contextStoreCurrentObjectMetadataNameSingular:
personMockObjectMetadataItem.nameSingular,
contextStoreCurrentViewId: 'my-view-id',
contextStoreTargetedRecordsRule: {
mode: 'selection',
selectedRecordIds: [],
},
contextStoreNumberOfSelectedRecords: 0,
contextStoreCurrentViewType: ContextStoreViewType.Table,
});
const renderHooks = () => {
const { result } = renderHook(
() => {
const { openEmailThreadInSidePanel } = useOpenEmailThreadInSidePanel();
const viewableRecordId = useAtomComponentStateValue(
viewableRecordIdComponentState,
'mocked-uuid',
);
return {
openEmailThreadInSidePanel,
viewableRecordId,
};
},
{
wrapper,
},
);
return { result };
};
describe('useOpenEmailThreadInSidePanel', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should set the correct states and navigate to the email thread page', () => {
const { result } = renderHooks();
const emailThreadId = 'email-thread-123';
act(() => {
result.current.openEmailThreadInSidePanel(emailThreadId);
});
expect(result.current.viewableRecordId).toBe(emailThreadId);
expect(mockNavigateSidePanel).toHaveBeenCalledWith({
page: SidePanelPages.ViewEmailThread,
pageTitle: 'Email Thread',
pageIcon: IconMail,
pageId: 'mocked-uuid',
});
});
});

View File

@@ -1,63 +0,0 @@
import { useNavigateSidePanel } from '@/side-panel/hooks/useNavigateSidePanel';
import { viewableRecordIdComponentState } from '@/side-panel/pages/record-page/states/viewableRecordIdComponentState';
import { t } from '@lingui/core/macro';
import { useCallback } from 'react';
import { SidePanelPages } from 'twenty-shared/types';
import { IconMail } from 'twenty-ui/display';
import { v4 } from 'uuid';
import { useStore } from 'jotai';
export const useOpenEmailThreadInSidePanel = () => {
const store = useStore();
const { navigateSidePanel } = useNavigateSidePanel();
const openEmailThreadInSidePanel = useCallback(
(emailThreadId: string) => {
const pageComponentInstanceId = v4();
store.set(
viewableRecordIdComponentState.atomFamily({
instanceId: pageComponentInstanceId,
}),
emailThreadId,
);
// TODO: Uncomment this once we need to show the thread title in the navigation
// const objectMetadataItem = snapshot
// .getLoadable(objectMetadataItemsSelector)
// .getValue()
// .find(
// ({ nameSingular }) =>
// nameSingular === CoreObjectNameSingular.MessageThread,
// );
// set(
// commandMenuNavigationMorphItemsState,
// new Map([
// ...snapshot
// .getLoadable(commandMenuNavigationMorphItemsState)
// .getValue(),
// [
// pageComponentInstanceId,
// {
// objectMetadataId: objectMetadataItem?.id,
// recordId: emailThreadId,
// },
// ],
// ]),
// );
navigateSidePanel({
page: SidePanelPages.ViewEmailThread,
pageTitle: t`Email Thread`,
pageIcon: IconMail,
pageId: pageComponentInstanceId,
});
},
[navigateSidePanel, store],
);
return {
openEmailThreadInSidePanel,
};
};

View File

@@ -1,183 +0,0 @@
import { styled } from '@linaria/react';
import { useEffect, useMemo } from 'react';
import { CustomResolverFetchMoreLoader } from '@/activities/components/CustomResolverFetchMoreLoader';
import { EmailLoader } from '@/activities/emails/components/EmailLoader';
import { EmailThreadHeader } from '@/activities/emails/components/EmailThreadHeader';
import { EmailThreadMessage } from '@/activities/emails/components/EmailThreadMessage';
import { SidePanelMessageThreadIntermediaryMessages } from '@/side-panel/pages/message-thread/components/SidePanelMessageThreadIntermediaryMessages';
import { useEmailThreadInSidePanel } from '@/side-panel/pages/message-thread/hooks/useEmailThreadInSidePanel';
import { messageThreadComponentState } from '@/side-panel/pages/message-thread/states/messageThreadComponentState';
import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState';
import { t } from '@lingui/core/macro';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
import { IconArrowBackUp } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
height: 100%;
`;
const StyledContainer = styled.div`
box-sizing: border-box;
display: flex;
flex: 1;
flex-direction: column;
height: 85%;
overflow-y: auto;
`;
const StyledButtonContainer = styled.div`
background: ${themeCssVariables.background.secondary};
border-top: 1px solid ${themeCssVariables.border.color.light};
box-sizing: border-box;
display: flex;
justify-content: flex-end;
padding: ${themeCssVariables.spacing[2]};
width: 100%;
`;
const ALLOWED_REPLY_PROVIDERS = [
ConnectedAccountProvider.GOOGLE,
ConnectedAccountProvider.MICROSOFT,
ConnectedAccountProvider.IMAP_SMTP_CALDAV,
];
export const SidePanelMessageThreadPage = () => {
const setMessageThread = useSetAtomComponentState(
messageThreadComponentState,
);
const {
thread,
messages,
fetchMoreMessages,
threadLoading,
messageThreadExternalId,
connectedAccountHandle,
messageChannelLoading,
connectedAccountProvider,
lastMessageExternalId,
connectedAccountConnectionParameters,
} = useEmailThreadInSidePanel();
useEffect(() => {
if (!isDefined(messages[0]?.messageThread)) {
return;
}
setMessageThread(messages[0]?.messageThread);
}, [messages, setMessageThread]);
const messagesCount = messages.length;
const is5OrMoreMessages = messagesCount >= 5;
const firstMessages = messages.slice(
0,
is5OrMoreMessages ? 2 : messagesCount - 1,
);
const intermediaryMessages = is5OrMoreMessages
? messages.slice(2, messagesCount - 1)
: [];
const lastMessage = messages[messagesCount - 1];
const subject = messages[0]?.subject;
const canReply = useMemo(() => {
return (
isDefined(connectedAccountHandle) &&
isDefined(connectedAccountProvider) &&
ALLOWED_REPLY_PROVIDERS.includes(connectedAccountProvider) &&
(connectedAccountProvider !== ConnectedAccountProvider.IMAP_SMTP_CALDAV ||
isDefined(connectedAccountConnectionParameters?.SMTP)) &&
isDefined(lastMessage) &&
messageThreadExternalId != null
);
}, [
connectedAccountConnectionParameters,
connectedAccountHandle,
connectedAccountProvider,
lastMessage,
messageThreadExternalId,
]);
const handleReplyClick = () => {
if (!canReply) {
return;
}
let url: string;
switch (connectedAccountProvider) {
case ConnectedAccountProvider.MICROSOFT:
url = `https://outlook.office.com/mail/deeplink?ItemID=${lastMessageExternalId}`;
window.open(url, '_blank');
break;
case ConnectedAccountProvider.GOOGLE:
url = `https://mail.google.com/mail/?authuser=${connectedAccountHandle}#all/${messageThreadExternalId}`;
window.open(url, '_blank');
break;
case ConnectedAccountProvider.IMAP_SMTP_CALDAV:
case ConnectedAccountProvider.OIDC:
case ConnectedAccountProvider.SAML:
case null:
return;
default:
assertUnreachable(connectedAccountProvider);
}
};
if (!thread || !messages.length) {
return null;
}
return (
<StyledWrapper>
<StyledContainer>
{threadLoading ? (
<EmailLoader loadingText={t`Loading thread`} />
) : (
<>
<EmailThreadHeader
subject={subject}
lastMessageSentAt={lastMessage.receivedAt}
/>
{firstMessages.map((message) => (
<EmailThreadMessage
key={message.id}
sender={message.sender}
participants={message.messageParticipants}
body={message.text}
sentAt={message.receivedAt}
/>
))}
<SidePanelMessageThreadIntermediaryMessages
messages={intermediaryMessages}
/>
<EmailThreadMessage
key={lastMessage.id}
sender={lastMessage.sender}
participants={lastMessage.messageParticipants}
body={lastMessage.text}
sentAt={lastMessage.receivedAt}
isExpanded
/>
<CustomResolverFetchMoreLoader
loading={threadLoading}
onLastRowVisible={fetchMoreMessages}
/>
</>
)}
</StyledContainer>
{!messageChannelLoading && (
<StyledButtonContainer>
<Button
size="small"
onClick={handleReplyClick}
title={t`Reply`}
Icon={IconArrowBackUp}
disabled={!canReply}
/>
</StyledButtonContainer>
)}
</StyledWrapper>
);
};

View File

@@ -1,255 +0,0 @@
import { renderHook, waitFor } from '@testing-library/react';
import { fetchAllThreadMessagesOperationSignatureFactory } from '@/activities/emails/graphql/operation-signatures/factories/fetchAllThreadMessagesOperationSignatureFactory';
import { useEmailThreadInSidePanel } from '@/side-panel/pages/message-thread/hooks/useEmailThreadInSidePanel';
import { viewableRecordIdComponentState } from '@/side-panel/pages/record-page/states/viewableRecordIdComponentState';
import { SidePanelPageComponentInstanceContext } from '@/side-panel/states/contexts/SidePanelPageComponentInstanceContext';
import { generateFindManyRecordsQuery } from '@/object-record/utils/generateFindManyRecordsQuery';
import gql from 'graphql-tag';
import {
QUERY_DEFAULT_LIMIT_RECORDS,
QUERY_MAX_RECORDS,
} from 'twenty-shared/constants';
import { MessageParticipantRole } from 'twenty-shared/types';
import { generateMockRecordNode } from '~/testing/utils/generateMockRecordNode';
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
import { getTestEnrichedObjectMetadataItemsMock } from '~/testing/utils/getTestEnrichedObjectMetadataItemsMock';
import { getMockObjectMetadataItemOrThrow } from '~/testing/utils/getMockObjectMetadataItemOrThrow';
const messageMetadataItem = getMockObjectMetadataItemOrThrow('message');
const messageParticipantMetadataItem =
getMockObjectMetadataItemOrThrow('messageParticipant');
const messageOperationSignature =
fetchAllThreadMessagesOperationSignatureFactory({
messageThreadId: '1',
});
const findManyMessagesQuery = generateFindManyRecordsQuery({
objectMetadataItem: messageMetadataItem,
objectMetadataItems: getTestEnrichedObjectMetadataItemsMock(),
recordGqlFields: messageOperationSignature.fields,
objectPermissionsByObjectMetadataId: {},
});
const findManyMessageParticipantsQuery = generateFindManyRecordsQuery({
objectMetadataItem: messageParticipantMetadataItem,
objectMetadataItems: getTestEnrichedObjectMetadataItemsMock(),
recordGqlFields: {
id: true,
role: true,
displayName: true,
messageId: true,
handle: true,
person: true,
workspaceMember: true,
},
objectPermissionsByObjectMetadataId: {},
});
const mocks = [
{
request: {
query: gql`
query FindOneMessageThread($objectRecordId: UUID!) {
messageThread(filter: { id: { eq: $objectRecordId } }) {
__typename
id
}
}
`,
variables: { objectRecordId: '1' },
},
result: jest.fn(() => ({
data: {
messageThread: {
id: '1',
__typename: 'MessageThread',
},
},
})),
},
{
request: {
query: findManyMessagesQuery,
variables: {
filter: { messageThreadId: { eq: '1' } },
orderBy: [{ receivedAt: 'AscNullsLast' }],
lastCursor: undefined,
limit: QUERY_MAX_RECORDS,
},
},
result: jest.fn(() => ({
data: {
messages: {
edges: [
{
node: generateMockRecordNode({
objectNameSingular: 'message',
input: {
id: '1',
text: 'Message 1',
createdAt: '2024-10-03T10:20:10.145Z',
},
}),
cursor: '1',
},
{
node: generateMockRecordNode({
objectNameSingular: 'message',
input: {
id: '2',
text: 'Message 2',
createdAt: '2024-10-03T10:20:10.145Z',
},
}),
cursor: '2',
},
],
totalCount: 2,
pageInfo: {
hasNextPage: false,
hasPreviousPage: false,
startCursor: '1',
endCursor: '2',
},
},
},
})),
},
{
request: {
query: findManyMessageParticipantsQuery,
variables: {
filter: {
messageId: { in: ['1', '2'] },
role: { eq: MessageParticipantRole.FROM },
},
orderBy: undefined,
lastCursor: undefined,
limit: QUERY_DEFAULT_LIMIT_RECORDS,
},
},
result: jest.fn(() => ({
data: {
messageParticipants: {
edges: [
{
node: generateMockRecordNode({
objectNameSingular: 'messageParticipant',
input: {
id: 'messageParticipant-1',
role: MessageParticipantRole.FROM,
messageId: '1',
},
}),
cursor: '1',
},
{
node: generateMockRecordNode({
objectNameSingular: 'messageParticipant',
input: {
id: 'messageParticipant-2',
role: MessageParticipantRole.FROM,
messageId: '2',
},
}),
cursor: '2',
},
],
totalCount: 2,
pageInfo: {
hasNextPage: false,
hasPreviousPage: false,
startCursor: '1',
endCursor: '2',
},
},
},
})),
},
];
const Wrapper = ({ children }: { children: React.ReactNode }) => {
const MetadataWrapper = getJestMetadataAndApolloMocksWrapper({
apolloMocks: mocks,
onInitializeJotaiStore: (store) => {
store.set(
viewableRecordIdComponentState.atomFamily({
instanceId: 'test-instance',
}),
'1',
);
},
});
return (
<MetadataWrapper>
<SidePanelPageComponentInstanceContext.Provider
value={{ instanceId: 'test-instance' }}
>
{children}
</SidePanelPageComponentInstanceContext.Provider>
</MetadataWrapper>
);
};
describe('useEmailThreadInSidePanel', () => {
it('should return correct values', async () => {
const mockMessages = [
{
__typename: 'Message',
createdAt: '2024-10-03T10:20:10.145Z',
headerMessageId: '',
id: '1',
messageParticipants: [],
messageThread: null,
receivedAt: null,
sender: {
__typename: 'MessageParticipant',
displayName: '',
handle: '',
id: 'messageParticipant-1',
messageId: '1',
person: null,
role: MessageParticipantRole.FROM,
workspaceMember: null,
},
subject: '',
text: 'Message 1',
},
{
__typename: 'Message',
createdAt: '2024-10-03T10:20:10.145Z',
headerMessageId: '',
id: '2',
messageParticipants: [],
messageThread: null,
receivedAt: null,
sender: {
__typename: 'MessageParticipant',
displayName: '',
handle: '',
id: 'messageParticipant-2',
messageId: '2',
person: null,
role: MessageParticipantRole.FROM,
workspaceMember: null,
},
subject: '',
text: 'Message 2',
},
];
const { result } = renderHook(() => useEmailThreadInSidePanel(), {
wrapper: Wrapper,
});
await waitFor(() => {
expect(result.current.thread).toBeDefined();
expect(result.current.messages).toEqual(mockMessages);
expect(result.current.threadLoading).toBeFalsy();
expect(result.current.fetchMoreMessages).toBeInstanceOf(Function);
});
});
});

View File

@@ -1,10 +0,0 @@
import { type MessageThread } from '@/activities/emails/types/MessageThread';
import { SidePanelPageComponentInstanceContext } from '@/side-panel/states/contexts/SidePanelPageComponentInstanceContext';
import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState';
export const messageThreadComponentState =
createAtomComponentState<MessageThread | null>({
key: 'messageThreadComponentState',
defaultValue: null,
componentInstanceContext: SidePanelPageComponentInstanceContext,
});

View File

@@ -3,6 +3,7 @@ import {
type AggregateChartConfiguration,
type BarChartConfiguration,
type CalendarConfiguration,
type EmailThreadConfiguration,
type EmailsConfiguration,
type FieldRichTextConfiguration,
type FieldsConfiguration,
@@ -44,6 +45,12 @@ type WidgetConfigurationTypenameMap = {
> & {
configurationType: WidgetConfigurationType.FRONT_COMPONENT;
};
EmailThreadConfiguration: Omit<
EmailThreadConfiguration,
'configurationType'
> & {
configurationType: WidgetConfigurationType.EMAIL_THREAD;
};
EmailsConfiguration: Omit<EmailsConfiguration, 'configurationType'> & {
configurationType: WidgetConfigurationType.EMAILS;
};

View File

@@ -0,0 +1,73 @@
import { Command } from 'nest-commander';
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 { getWorkspaceSchemaName } from 'src/engine/workspace-datasource/utils/get-workspace-schema-name.util';
@Command({
name: 'upgrade:1-21:backfill-message-thread-subject',
description:
'Backfill messageThread.subject from the most recently received message in each thread',
})
export class BackfillMessageThreadSubjectCommand extends ActiveOrSuspendedWorkspaceCommandRunner {
constructor(
protected readonly workspaceIteratorService: WorkspaceIteratorService,
) {
super(workspaceIteratorService);
}
override async runOnWorkspace({
workspaceId,
dataSource,
options,
}: RunOnWorkspaceArgs): Promise<void> {
if (!dataSource) {
this.logger.log(`No data source for workspace ${workspaceId}, skipping`);
return;
}
const schemaName = getWorkspaceSchemaName(workspaceId);
if (options.dryRun) {
this.logger.log(
`[DRY RUN] Would backfill messageThread.subject for workspace ${workspaceId}`,
);
return;
}
const columnExists = await dataSource.query(
`SELECT 1 FROM information_schema.columns
WHERE table_schema = $1
AND table_name = 'messageThread'
AND column_name = 'subject'`,
[schemaName],
);
if (columnExists.length === 0) {
this.logger.log(
`Column "subject" does not exist yet on messageThread for workspace ${workspaceId}, skipping (will be created by sync-metadata)`,
);
return;
}
const result = await dataSource.query(
`UPDATE "${schemaName}"."messageThread" mt
SET "subject" = sub.subject
FROM (
SELECT DISTINCT ON ("messageThreadId") "messageThreadId", "subject"
FROM "${schemaName}"."message"
ORDER BY "messageThreadId", "receivedAt" DESC NULLS LAST
) sub
WHERE mt.id = sub."messageThreadId"
AND mt."subject" IS NULL`,
);
this.logger.log(
`Backfilled subject for ${result?.[1] ?? 0} message threads in workspace ${workspaceId}`,
);
}
}

View File

@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module';
import { BackfillMessageThreadSubjectCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-backfill-message-thread-subject.command';
import { AddGlobalKeyValuePairUniqueIndexCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-add-global-key-value-pair-unique-index.command';
import { BackfillDatasourceToWorkspaceCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-backfill-datasource-to-workspace.command';
import { BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-backfill-page-layouts-and-fields-widget-view-fields.command';
@@ -39,6 +40,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
providers: [
AddGlobalKeyValuePairUniqueIndexCommand,
BackfillDatasourceToWorkspaceCommand,
BackfillMessageThreadSubjectCommand,
BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand,
DeduplicateEngineCommandsCommand,
FixSelectAllCommandMenuItemsCommand,
@@ -50,6 +52,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
exports: [
AddGlobalKeyValuePairUniqueIndexCommand,
BackfillDatasourceToWorkspaceCommand,
BackfillMessageThreadSubjectCommand,
BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand,
DeduplicateEngineCommandsCommand,
FixSelectAllCommandMenuItemsCommand,

View File

@@ -28,6 +28,7 @@ import { SeedCliApplicationRegistrationCommand } from 'src/database/commands/upg
import { UpdateStandardIndexViewNamesCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-update-standard-index-view-names.command';
import { AddGlobalKeyValuePairUniqueIndexCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-add-global-key-value-pair-unique-index.command';
import { BackfillDatasourceToWorkspaceCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-backfill-datasource-to-workspace.command';
import { BackfillMessageThreadSubjectCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-backfill-message-thread-subject.command';
import { BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-backfill-page-layouts-and-fields-widget-view-fields.command';
import { DeduplicateEngineCommandsCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-deduplicate-engine-commands.command';
import { FixSelectAllCommandMenuItemsCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-fix-select-all-command-menu-items.command';
@@ -75,6 +76,7 @@ export class UpgradeCommand extends UpgradeCommandRunner {
// 1.21 Commands
private readonly addGlobalKeyValuePairUniqueIndexCommand: AddGlobalKeyValuePairUniqueIndexCommand,
private readonly backfillDatasourceToWorkspaceCommand: BackfillDatasourceToWorkspaceCommand,
private readonly backfillMessageThreadSubjectCommand: BackfillMessageThreadSubjectCommand,
private readonly backfillPageLayoutsAndFieldsWidgetViewFieldsCommand: BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand,
private readonly deduplicateEngineCommandsCommand: DeduplicateEngineCommandsCommand,
private readonly fixSelectAllCommandMenuItemsCommand: FixSelectAllCommandMenuItemsCommand,
@@ -116,6 +118,7 @@ export class UpgradeCommand extends UpgradeCommandRunner {
const commands_1210: VersionCommands = [
this.addGlobalKeyValuePairUniqueIndexCommand,
this.backfillDatasourceToWorkspaceCommand,
this.backfillMessageThreadSubjectCommand,
this.backfillPageLayoutsAndFieldsWidgetViewFieldsCommand,
this.deduplicateEngineCommandsCommand,
this.fixSelectAllCommandMenuItemsCommand,

View File

@@ -0,0 +1,47 @@
import { type MigrationInterface, type QueryRunner } from 'typeorm';
export class AddEmailThreadWidgetType1775200000000
implements MigrationInterface
{
name = 'AddEmailThreadWidgetType1775200000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TYPE "core"."pageLayoutWidget_type_enum" RENAME TO "pageLayoutWidget_type_enum_old"`,
);
await queryRunner.query(
`CREATE TYPE "core"."pageLayoutWidget_type_enum" AS ENUM('VIEW', 'IFRAME', 'FIELD', 'FIELDS', 'GRAPH', 'STANDALONE_RICH_TEXT', 'TIMELINE', 'TASKS', 'NOTES', 'FILES', 'EMAILS', 'CALENDAR', 'FIELD_RICH_TEXT', 'WORKFLOW', 'WORKFLOW_VERSION', 'WORKFLOW_RUN', 'FRONT_COMPONENT', 'RECORD_TABLE', 'EMAIL_THREAD')`,
);
await queryRunner.query(
`ALTER TABLE "core"."pageLayoutWidget" ALTER COLUMN "type" DROP DEFAULT`,
);
await queryRunner.query(
`ALTER TABLE "core"."pageLayoutWidget" ALTER COLUMN "type" TYPE "core"."pageLayoutWidget_type_enum" USING "type"::"text"::"core"."pageLayoutWidget_type_enum"`,
);
await queryRunner.query(
`ALTER TABLE "core"."pageLayoutWidget" ALTER COLUMN "type" SET DEFAULT 'VIEW'`,
);
await queryRunner.query(
`DROP TYPE "core"."pageLayoutWidget_type_enum_old"`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TYPE "core"."pageLayoutWidget_type_enum_old" AS ENUM('VIEW', 'IFRAME', 'FIELD', 'FIELDS', 'GRAPH', 'STANDALONE_RICH_TEXT', 'TIMELINE', 'TASKS', 'NOTES', 'FILES', 'EMAILS', 'CALENDAR', 'FIELD_RICH_TEXT', 'WORKFLOW', 'WORKFLOW_VERSION', 'WORKFLOW_RUN', 'FRONT_COMPONENT', 'RECORD_TABLE')`,
);
await queryRunner.query(
`ALTER TABLE "core"."pageLayoutWidget" ALTER COLUMN "type" DROP DEFAULT`,
);
await queryRunner.query(
`ALTER TABLE "core"."pageLayoutWidget" ALTER COLUMN "type" TYPE "core"."pageLayoutWidget_type_enum_old" USING "type"::"text"::"core"."pageLayoutWidget_type_enum_old"`,
);
await queryRunner.query(
`ALTER TABLE "core"."pageLayoutWidget" ALTER COLUMN "type" SET DEFAULT 'VIEW'`,
);
await queryRunner.query(`DROP TYPE "core"."pageLayoutWidget_type_enum"`);
await queryRunner.query(
`ALTER TYPE "core"."pageLayoutWidget_type_enum_old" RENAME TO "pageLayoutWidget_type_enum"`,
);
}
}

View File

@@ -55,6 +55,7 @@ export enum EngineComponentKey {
VIEW_PREVIOUS_AI_CHATS = 'VIEW_PREVIOUS_AI_CHATS',
TRIGGER_WORKFLOW_VERSION = 'TRIGGER_WORKFLOW_VERSION',
FRONT_COMPONENT_RENDERER = 'FRONT_COMPONENT_RENDERER',
REPLY_TO_EMAIL_THREAD = 'REPLY_TO_EMAIL_THREAD',
// Deprecated keys kept for backward compatibility until migration runs
DELETE_SINGLE_RECORD = 'DELETE_SINGLE_RECORD',

View File

@@ -92,6 +92,9 @@ export class FlatPageLayoutWidgetTypeValidatorService {
RECORD_TABLE: validateSimpleRecordPageWidgetForCreation(
WidgetConfigurationType.RECORD_TABLE,
),
EMAIL_THREAD: validateSimpleRecordPageWidgetForCreation(
WidgetConfigurationType.EMAIL_THREAD,
),
};
private readonly PAGE_LAYOUT_WIDGET_TYPE_VALIDATOR_FOR_UPDATE_HASHMAP: FlatPageLayoutWidgetTypeValidatorForUpdate =
@@ -143,6 +146,9 @@ export class FlatPageLayoutWidgetTypeValidatorService {
RECORD_TABLE: validateSimpleRecordPageWidgetForUpdate(
WidgetConfigurationType.RECORD_TABLE,
),
EMAIL_THREAD: validateSimpleRecordPageWidgetForUpdate(
WidgetConfigurationType.EMAIL_THREAD,
),
};
public validateFlatPageLayoutWidgetTypeSpecificitiesForCreation(

View File

@@ -378,6 +378,7 @@ export const fromPageLayoutWidgetConfigurationToUniversalConfiguration = ({
case WidgetConfigurationType.WORKFLOW_RUN:
case WidgetConfigurationType.IFRAME:
case WidgetConfigurationType.STANDALONE_RICH_TEXT:
case WidgetConfigurationType.EMAIL_THREAD:
return configuration;
}
};

View File

@@ -1,4 +1,5 @@
import { AggregateChartConfigurationDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/aggregate-chart-configuration.dto';
import { EmailThreadConfigurationDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/email-thread-configuration.dto';
import { BarChartConfigurationDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/bar-chart-configuration.dto';
import { CalendarConfigurationDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/calendar-configuration.dto';
import { EmailsConfigurationDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/emails-configuration.dto';
@@ -35,6 +36,7 @@ export const ALL_WIDGET_CONFIGURATION_TYPE_VALIDATOR_BY_WIDGET_CONFIGURATION_TYP
CALENDAR: CalendarConfigurationDTO,
FRONT_COMPONENT: FrontComponentConfigurationDTO,
EMAILS: EmailsConfigurationDTO,
EMAIL_THREAD: EmailThreadConfigurationDTO,
FIELD: FieldConfigurationDTO,
FIELD_RICH_TEXT: FieldRichTextConfigurationDTO,
FIELDS: FieldsConfigurationDTO,

View File

@@ -0,0 +1,14 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { IsIn, IsNotEmpty } from 'class-validator';
import { type EmailThreadConfiguration } from 'twenty-shared/types';
import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type';
@ObjectType('EmailThreadConfiguration')
export class EmailThreadConfigurationDTO implements EmailThreadConfiguration {
@Field(() => WidgetConfigurationType)
@IsIn([WidgetConfigurationType.EMAIL_THREAD])
@IsNotEmpty()
configurationType: WidgetConfigurationType.EMAIL_THREAD;
}

View File

@@ -27,6 +27,7 @@ export enum WidgetConfigurationType {
WORKFLOW_RUN = 'WORKFLOW_RUN',
FRONT_COMPONENT = 'FRONT_COMPONENT',
RECORD_TABLE = 'RECORD_TABLE',
EMAIL_THREAD = 'EMAIL_THREAD',
}
export type AllGraphWidgetConfigurationType =
| WidgetConfigurationType.AGGREGATE_CHART

View File

@@ -17,4 +17,5 @@ export enum WidgetType {
WORKFLOW_RUN = 'WORKFLOW_RUN',
FRONT_COMPONENT = 'FRONT_COMPONENT',
RECORD_TABLE = 'RECORD_TABLE',
EMAIL_THREAD = 'EMAIL_THREAD',
}

View File

@@ -3,10 +3,26 @@ type MessageThreadDataSeed = {
createdAt: Date;
updatedAt: Date;
deletedAt: Date | null;
subject: string;
};
export const MESSAGE_THREAD_DATA_SEED_COLUMNS: (keyof MessageThreadDataSeed)[] =
['id', 'createdAt', 'updatedAt', 'deletedAt'];
['id', 'createdAt', 'updatedAt', 'deletedAt', 'subject'];
const EMAIL_SUBJECTS = [
'Meeting Request',
'Project Update',
'Invoice for Services',
'Thank You for the Meeting',
'Proposal Submission',
'Follow-up on Discussion',
'Customer Feedback',
'Training Session Reminder',
'Contract Renewal',
'Quarterly Report',
'Partnership Opportunity',
'Event Invitation',
];
const GENERATE_MESSAGE_THREAD_IDS = (): Record<string, string> => {
const THREAD_IDS: Record<string, string> = {};
@@ -41,11 +57,14 @@ const GENERATE_MESSAGE_THREAD_SEEDS = (): MessageThreadDataSeed[] => {
CREATED_DATE.getTime() + UPDATE_OFFSET * 24 * 60 * 60 * 1000,
);
const TEMPLATE_INDEX = ((INDEX - 1) * 2) % EMAIL_SUBJECTS.length;
THREAD_SEEDS.push({
id: MESSAGE_THREAD_DATA_SEED_IDS[`ID_${INDEX}`],
createdAt: CREATED_DATE,
updatedAt: UPDATED_DATE,
deletedAt: null,
subject: EMAIL_SUBJECTS[TEMPLATE_INDEX],
});
}

View File

@@ -787,4 +787,19 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
engineComponentKey: EngineComponentKey.VIEW_PREVIOUS_AI_CHATS,
hotKeys: null,
},
replyToEmailThread: {
universalIdentifier: '8f015cbd-c764-434e-a6c6-bb7581b4be44',
label: 'Reply',
icon: 'IconArrowBackUp',
isPinned: true,
position: 70,
shortLabel: 'Reply',
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
conditionalAvailabilityExpression: 'numberOfSelectedRecords == 1',
availabilityObjectMetadataUniversalIdentifier:
STANDARD_OBJECTS.messageThread.universalIdentifier,
frontComponentUniversalIdentifier: null,
engineComponentKey: EngineComponentKey.REPLY_TO_EMAIL_THREAD,
hotKeys: null,
},
} as const;

View File

@@ -223,4 +223,10 @@ export const WIDGET_PROPS = {
gridPosition: GRID_POSITIONS.FULL_WIDTH,
position: CANVAS_LAYOUT_POSITIONS.DEFAULT,
},
emailThread: {
title: 'Thread',
type: WidgetType.EMAIL_THREAD,
gridPosition: GRID_POSITIONS.FULL_WIDTH,
position: VERTICAL_LIST_LAYOUT_POSITIONS.SECOND,
},
} as const;

View File

@@ -13,6 +13,7 @@ import {
STANDARD_MESSAGE_CHANNEL_PAGE_LAYOUT_CONFIG,
STANDARD_MESSAGE_FOLDER_PAGE_LAYOUT_CONFIG,
STANDARD_MESSAGE_PARTICIPANT_PAGE_LAYOUT_CONFIG,
STANDARD_MESSAGE_THREAD_PAGE_LAYOUT_CONFIG,
STANDARD_NOTE_PAGE_LAYOUT_CONFIG,
STANDARD_OPPORTUNITY_PAGE_LAYOUT_CONFIG,
STANDARD_PERSON_PAGE_LAYOUT_CONFIG,
@@ -43,6 +44,7 @@ export const STANDARD_PAGE_LAYOUTS = {
STANDARD_MESSAGE_CHANNEL_MESSAGE_ASSOCIATION_MESSAGE_FOLDER_PAGE_LAYOUT_CONFIG,
messageFolderRecordPage: STANDARD_MESSAGE_FOLDER_PAGE_LAYOUT_CONFIG,
messageParticipantRecordPage: STANDARD_MESSAGE_PARTICIPANT_PAGE_LAYOUT_CONFIG,
messageThreadRecordPage: STANDARD_MESSAGE_THREAD_PAGE_LAYOUT_CONFIG,
noteRecordPage: STANDARD_NOTE_PAGE_LAYOUT_CONFIG,
opportunityRecordPage: STANDARD_OPPORTUNITY_PAGE_LAYOUT_CONFIG,
personRecordPage: STANDARD_PERSON_PAGE_LAYOUT_CONFIG,

View File

@@ -1,4 +1,4 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`getStandardPageLayoutMetadataRelatedEntityIds should return standard page layout metadata related entity ids 1`] = `
{
@@ -321,6 +321,19 @@ exports[`getStandardPageLayoutMetadataRelatedEntityIds should return standard pa
},
},
},
"messageThreadRecordPage": {
"id": "00000000-0000-0000-0000-000000000088",
"tabs": {
"home": {
"id": "00000000-0000-0000-0000-000000000089",
"widgets": {
"emailThread": {
"id": "00000000-0000-0000-0000-000000000090",
},
},
},
},
},
"myFirstDashboard": {
"id": "00000000-0000-0000-0000-000000000001",
"tabs": {
@@ -356,299 +369,299 @@ exports[`getStandardPageLayoutMetadataRelatedEntityIds should return standard pa
},
},
"noteRecordPage": {
"id": "00000000-0000-0000-0000-000000000088",
"id": "00000000-0000-0000-0000-000000000091",
"tabs": {
"files": {
"id": "00000000-0000-0000-0000-000000000096",
"id": "00000000-0000-0000-0000-000000000099",
"widgets": {
"files": {
"id": "00000000-0000-0000-0000-000000000097",
"id": "00000000-0000-0000-0000-000000000100",
},
},
},
"home": {
"id": "00000000-0000-0000-0000-000000000089",
"id": "00000000-0000-0000-0000-000000000092",
"widgets": {
"fields": {
"id": "00000000-0000-0000-0000-000000000090",
"id": "00000000-0000-0000-0000-000000000093",
},
"noteRichText": {
"id": "00000000-0000-0000-0000-000000000091",
"id": "00000000-0000-0000-0000-000000000094",
},
},
},
"note": {
"id": "00000000-0000-0000-0000-000000000092",
"id": "00000000-0000-0000-0000-000000000095",
"widgets": {
"noteRichText": {
"id": "00000000-0000-0000-0000-000000000093",
"id": "00000000-0000-0000-0000-000000000096",
},
},
},
"timeline": {
"id": "00000000-0000-0000-0000-000000000094",
"id": "00000000-0000-0000-0000-000000000097",
"widgets": {
"timeline": {
"id": "00000000-0000-0000-0000-000000000095",
"id": "00000000-0000-0000-0000-000000000098",
},
},
},
},
},
"opportunityRecordPage": {
"id": "00000000-0000-0000-0000-000000000098",
"id": "00000000-0000-0000-0000-000000000101",
"tabs": {
"calendar": {
"id": "00000000-0000-0000-0000-000000000114",
"id": "00000000-0000-0000-0000-000000000117",
"widgets": {
"calendar": {
"id": "00000000-0000-0000-0000-000000000115",
"id": "00000000-0000-0000-0000-000000000118",
},
},
},
"emails": {
"id": "00000000-0000-0000-0000-000000000112",
"id": "00000000-0000-0000-0000-000000000115",
"widgets": {
"emails": {
"id": "00000000-0000-0000-0000-000000000113",
"id": "00000000-0000-0000-0000-000000000116",
},
},
},
"files": {
"id": "00000000-0000-0000-0000-000000000110",
"id": "00000000-0000-0000-0000-000000000113",
"widgets": {
"files": {
"id": "00000000-0000-0000-0000-000000000111",
"id": "00000000-0000-0000-0000-000000000114",
},
},
},
"home": {
"id": "00000000-0000-0000-0000-000000000099",
"id": "00000000-0000-0000-0000-000000000102",
"widgets": {
"company": {
"id": "00000000-0000-0000-0000-000000000102",
"id": "00000000-0000-0000-0000-000000000105",
},
"fields": {
"id": "00000000-0000-0000-0000-000000000100",
},
"owner": {
"id": "00000000-0000-0000-0000-000000000103",
},
"owner": {
"id": "00000000-0000-0000-0000-000000000106",
},
"pointOfContact": {
"id": "00000000-0000-0000-0000-000000000101",
"id": "00000000-0000-0000-0000-000000000104",
},
},
},
"notes": {
"id": "00000000-0000-0000-0000-000000000108",
"id": "00000000-0000-0000-0000-000000000111",
"widgets": {
"notes": {
"id": "00000000-0000-0000-0000-000000000109",
"id": "00000000-0000-0000-0000-000000000112",
},
},
},
"tasks": {
"id": "00000000-0000-0000-0000-000000000106",
"id": "00000000-0000-0000-0000-000000000109",
"widgets": {
"tasks": {
"id": "00000000-0000-0000-0000-000000000107",
"id": "00000000-0000-0000-0000-000000000110",
},
},
},
"timeline": {
"id": "00000000-0000-0000-0000-000000000104",
"id": "00000000-0000-0000-0000-000000000107",
"widgets": {
"timeline": {
"id": "00000000-0000-0000-0000-000000000105",
"id": "00000000-0000-0000-0000-000000000108",
},
},
},
},
},
"personRecordPage": {
"id": "00000000-0000-0000-0000-000000000116",
"id": "00000000-0000-0000-0000-000000000119",
"tabs": {
"calendar": {
"id": "00000000-0000-0000-0000-000000000131",
"id": "00000000-0000-0000-0000-000000000134",
"widgets": {
"calendar": {
"id": "00000000-0000-0000-0000-000000000132",
"id": "00000000-0000-0000-0000-000000000135",
},
},
},
"emails": {
"id": "00000000-0000-0000-0000-000000000129",
"id": "00000000-0000-0000-0000-000000000132",
"widgets": {
"emails": {
"id": "00000000-0000-0000-0000-000000000130",
"id": "00000000-0000-0000-0000-000000000133",
},
},
},
"files": {
"id": "00000000-0000-0000-0000-000000000127",
"id": "00000000-0000-0000-0000-000000000130",
"widgets": {
"files": {
"id": "00000000-0000-0000-0000-000000000128",
"id": "00000000-0000-0000-0000-000000000131",
},
},
},
"home": {
"id": "00000000-0000-0000-0000-000000000117",
"id": "00000000-0000-0000-0000-000000000120",
"widgets": {
"company": {
"id": "00000000-0000-0000-0000-000000000119",
"id": "00000000-0000-0000-0000-000000000122",
},
"fields": {
"id": "00000000-0000-0000-0000-000000000118",
"id": "00000000-0000-0000-0000-000000000121",
},
"pointOfContactForOpportunities": {
"id": "00000000-0000-0000-0000-000000000120",
"id": "00000000-0000-0000-0000-000000000123",
},
},
},
"notes": {
"id": "00000000-0000-0000-0000-000000000125",
"id": "00000000-0000-0000-0000-000000000128",
"widgets": {
"notes": {
"id": "00000000-0000-0000-0000-000000000126",
"id": "00000000-0000-0000-0000-000000000129",
},
},
},
"tasks": {
"id": "00000000-0000-0000-0000-000000000123",
"id": "00000000-0000-0000-0000-000000000126",
"widgets": {
"tasks": {
"id": "00000000-0000-0000-0000-000000000124",
"id": "00000000-0000-0000-0000-000000000127",
},
},
},
"timeline": {
"id": "00000000-0000-0000-0000-000000000121",
"id": "00000000-0000-0000-0000-000000000124",
"widgets": {
"timeline": {
"id": "00000000-0000-0000-0000-000000000122",
"id": "00000000-0000-0000-0000-000000000125",
},
},
},
},
},
"taskRecordPage": {
"id": "00000000-0000-0000-0000-000000000133",
"id": "00000000-0000-0000-0000-000000000136",
"tabs": {
"files": {
"id": "00000000-0000-0000-0000-000000000141",
"id": "00000000-0000-0000-0000-000000000144",
"widgets": {
"files": {
"id": "00000000-0000-0000-0000-000000000142",
"id": "00000000-0000-0000-0000-000000000145",
},
},
},
"home": {
"id": "00000000-0000-0000-0000-000000000134",
"id": "00000000-0000-0000-0000-000000000137",
"widgets": {
"fields": {
"id": "00000000-0000-0000-0000-000000000135",
"id": "00000000-0000-0000-0000-000000000138",
},
"taskRichText": {
"id": "00000000-0000-0000-0000-000000000136",
"id": "00000000-0000-0000-0000-000000000139",
},
},
},
"note": {
"id": "00000000-0000-0000-0000-000000000137",
"id": "00000000-0000-0000-0000-000000000140",
"widgets": {
"taskRichText": {
"id": "00000000-0000-0000-0000-000000000138",
"id": "00000000-0000-0000-0000-000000000141",
},
},
},
"timeline": {
"id": "00000000-0000-0000-0000-000000000139",
"id": "00000000-0000-0000-0000-000000000142",
"widgets": {
"timeline": {
"id": "00000000-0000-0000-0000-000000000140",
"id": "00000000-0000-0000-0000-000000000143",
},
},
},
},
},
"workflowAutomatedTriggerRecordPage": {
"id": "00000000-0000-0000-0000-000000000146",
"id": "00000000-0000-0000-0000-000000000149",
"tabs": {
"home": {
"id": "00000000-0000-0000-0000-000000000147",
"id": "00000000-0000-0000-0000-000000000150",
"widgets": {
"fields": {
"id": "00000000-0000-0000-0000-000000000148",
"id": "00000000-0000-0000-0000-000000000151",
},
},
},
"timeline": {
"id": "00000000-0000-0000-0000-000000000149",
"id": "00000000-0000-0000-0000-000000000152",
"widgets": {
"timeline": {
"id": "00000000-0000-0000-0000-000000000150",
"id": "00000000-0000-0000-0000-000000000153",
},
},
},
},
},
"workflowRecordPage": {
"id": "00000000-0000-0000-0000-000000000143",
"id": "00000000-0000-0000-0000-000000000146",
"tabs": {
"flow": {
"id": "00000000-0000-0000-0000-000000000144",
"id": "00000000-0000-0000-0000-000000000147",
"widgets": {
"workflow": {
"id": "00000000-0000-0000-0000-000000000145",
"id": "00000000-0000-0000-0000-000000000148",
},
},
},
},
},
"workflowRunRecordPage": {
"id": "00000000-0000-0000-0000-000000000157",
"id": "00000000-0000-0000-0000-000000000160",
"tabs": {
"flow": {
"id": "00000000-0000-0000-0000-000000000161",
"id": "00000000-0000-0000-0000-000000000164",
"widgets": {
"workflowRun": {
"id": "00000000-0000-0000-0000-000000000162",
"id": "00000000-0000-0000-0000-000000000165",
},
},
},
"home": {
"id": "00000000-0000-0000-0000-000000000158",
"id": "00000000-0000-0000-0000-000000000161",
"widgets": {
"fields": {
"id": "00000000-0000-0000-0000-000000000159",
"id": "00000000-0000-0000-0000-000000000162",
},
"workflow": {
"id": "00000000-0000-0000-0000-000000000160",
"id": "00000000-0000-0000-0000-000000000163",
},
},
},
},
},
"workflowVersionRecordPage": {
"id": "00000000-0000-0000-0000-000000000151",
"id": "00000000-0000-0000-0000-000000000154",
"tabs": {
"flow": {
"id": "00000000-0000-0000-0000-000000000155",
"id": "00000000-0000-0000-0000-000000000158",
"widgets": {
"workflowVersion": {
"id": "00000000-0000-0000-0000-000000000156",
"id": "00000000-0000-0000-0000-000000000159",
},
},
},
"home": {
"id": "00000000-0000-0000-0000-000000000152",
"id": "00000000-0000-0000-0000-000000000155",
"widgets": {
"fields": {
"id": "00000000-0000-0000-0000-000000000153",
"id": "00000000-0000-0000-0000-000000000156",
},
"workflow": {
"id": "00000000-0000-0000-0000-000000000154",
"id": "00000000-0000-0000-0000-000000000157",
},
},
},

View File

@@ -14,6 +14,7 @@ import {
} from 'src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/create-standard-field-flat-metadata.util';
import { createStandardRelationFieldFlatMetadata } from 'src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/create-standard-relation-field-flat-metadata.util';
import { getTsVectorColumnExpressionFromFields } from 'src/engine/workspace-manager/utils/get-ts-vector-column-expression.util';
import { SEARCH_FIELDS_FOR_MESSAGE_THREAD } from 'src/modules/messaging/common/standard-objects/message-thread.workspace-entity';
export const buildMessageThreadStandardFlatFieldMetadatas = ({
now,
@@ -183,9 +184,9 @@ export const buildMessageThreadStandardFlatFieldMetadatas = ({
isNullable: true,
settings: {
generatedType: 'STORED',
asExpression: getTsVectorColumnExpressionFromFields([
{ name: 'id', type: FieldMetadataType.UUID },
]),
asExpression: getTsVectorColumnExpressionFromFields(
SEARCH_FIELDS_FOR_MESSAGE_THREAD,
),
},
},
standardObjectMetadataRelatedEntityIds,
@@ -193,6 +194,23 @@ export const buildMessageThreadStandardFlatFieldMetadatas = ({
twentyStandardApplicationId,
now,
}),
subject: createStandardFieldFlatMetadata({
objectName,
workspaceId,
context: {
fieldName: 'subject',
type: FieldMetadataType.TEXT,
label: i18nLabel(msg`Subject`),
description: i18nLabel(msg`Subject`),
icon: 'IconMessage',
isNullable: true,
isUIReadOnly: true,
},
standardObjectMetadataRelatedEntityIds,
dependencyFlatEntityMaps,
twentyStandardApplicationId,
now,
}),
messages: createStandardRelationFieldFlatMetadata({
objectName,
workspaceId,

View File

@@ -510,7 +510,7 @@ export const STANDARD_FLAT_OBJECT_METADATA_BUILDERS_BY_OBJECT_NAME = {
icon: 'IconMessage',
isSystem: true,
isAuditLogged: false,
labelIdentifierFieldMetadataName: 'id',
labelIdentifierFieldMetadataName: 'subject',
},
workspaceId,
standardObjectMetadataRelatedEntityIds,

View File

@@ -12,6 +12,7 @@ export { STANDARD_MESSAGE_CHANNEL_MESSAGE_ASSOCIATION_PAGE_LAYOUT_CONFIG } from
export { STANDARD_MESSAGE_CHANNEL_PAGE_LAYOUT_CONFIG } from './standard-message-channel-page-layout.config';
export { STANDARD_MESSAGE_FOLDER_PAGE_LAYOUT_CONFIG } from './standard-message-folder-page-layout.config';
export { STANDARD_MESSAGE_PARTICIPANT_PAGE_LAYOUT_CONFIG } from './standard-message-participant-page-layout.config';
export { STANDARD_MESSAGE_THREAD_PAGE_LAYOUT_CONFIG } from './standard-message-thread-page-layout.config';
export { STANDARD_NOTE_PAGE_LAYOUT_CONFIG } from './standard-note-page-layout.config';
export { STANDARD_OPPORTUNITY_PAGE_LAYOUT_CONFIG } from './standard-opportunity-page-layout.config';
export {

View File

@@ -0,0 +1,33 @@
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import { PageLayoutType } from 'src/engine/metadata-modules/page-layout/enums/page-layout-type.enum';
import {
TAB_PROPS,
WIDGET_PROPS,
} from 'src/engine/workspace-manager/twenty-standard-application/constants/standard-page-layout-tabs.template';
import {
type StandardPageLayoutConfig,
type StandardPageLayoutTabConfig,
} from 'src/engine/workspace-manager/twenty-standard-application/utils/page-layout-config/standard-page-layout-config.type';
const MESSAGE_THREAD_PAGE_TABS = {
home: {
universalIdentifier: '20202020-f639-48a0-9a44-027cf4e3cd15',
...TAB_PROPS.home,
widgets: {
emailThread: {
universalIdentifier: '20202020-d57e-44cb-b220-69a881feb9c3',
...WIDGET_PROPS.emailThread,
},
},
},
} as const satisfies Record<string, StandardPageLayoutTabConfig>;
export const STANDARD_MESSAGE_THREAD_PAGE_LAYOUT_CONFIG = {
name: 'Default Message Thread Layout',
type: PageLayoutType.RECORD_PAGE,
objectUniversalIdentifier: STANDARD_OBJECTS.messageThread.universalIdentifier,
universalIdentifier: '20202020-95bb-40eb-a699-70e7ea02a79e',
defaultTabUniversalIdentifier: null,
tabs: MESSAGE_THREAD_PAGE_TABS,
} as const satisfies StandardPageLayoutConfig;

View File

@@ -61,6 +61,7 @@ const WIDGET_TYPE_TO_CONFIGURATION_TYPE: Partial<
[WidgetType.WORKFLOW_VERSION]: WidgetConfigurationType.WORKFLOW_VERSION,
[WidgetType.WORKFLOW_RUN]: WidgetConfigurationType.WORKFLOW_RUN,
[WidgetType.RECORD_TABLE]: WidgetConfigurationType.RECORD_TABLE,
[WidgetType.EMAIL_THREAD]: WidgetConfigurationType.EMAIL_THREAD,
};
const RECORD_PAGE_FIELDS_VIEW_NAME_BY_OBJECT: Partial<
@@ -135,10 +136,9 @@ const buildRecordPageWidgetConfigurations = ({
const baseConfig = { configurationType };
return {
// @ts-expect-error ignore - configurationType is validated but TS can't match to discriminated union
configuration: baseConfig,
// @ts-expect-error ignore - we'd need to implement for each widget type (including unused GRAPH type) to be able to match to the discriminated union
universalConfiguration: baseConfig,
configuration: baseConfig as AllPageLayoutWidgetConfiguration,
universalConfiguration:
baseConfig as CreateStandardPageLayoutWidgetContext['universalConfiguration'],
};
};

View File

@@ -8,6 +8,18 @@ export const computeStandardMessageThreadViewFields = (
args: Omit<CreateStandardViewFieldArgs<'messageThread'>, 'context'>,
): Record<string, FlatViewField> => {
return {
allMessageThreadsSubject: createStandardViewFieldFlatMetadata({
...args,
objectName: 'messageThread',
context: {
viewName: 'allMessageThreads',
viewFieldName: 'subject',
fieldName: 'subject',
position: 0,
isVisible: true,
size: 300,
},
}),
allMessageThreadsMessages: createStandardViewFieldFlatMetadata({
...args,
objectName: 'messageThread',
@@ -15,9 +27,21 @@ export const computeStandardMessageThreadViewFields = (
viewName: 'allMessageThreads',
viewFieldName: 'messages',
fieldName: 'messages',
position: 0,
position: 1,
isVisible: true,
size: 180,
size: 150,
},
}),
allMessageThreadsUpdatedAt: createStandardViewFieldFlatMetadata({
...args,
objectName: 'messageThread',
context: {
viewName: 'allMessageThreads',
viewFieldName: 'updatedAt',
fieldName: 'updatedAt',
position: 2,
isVisible: true,
size: 150,
},
}),
allMessageThreadsCreatedAt: createStandardViewFieldFlatMetadata({
@@ -27,7 +51,7 @@ export const computeStandardMessageThreadViewFields = (
viewName: 'allMessageThreads',
viewFieldName: 'createdAt',
fieldName: 'createdAt',
position: 1,
position: 3,
isVisible: true,
size: 150,
},

View File

@@ -1,4 +1,4 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`ALL_UNIVERSAL_FLAT_ENTITY_FOREIGN_KEY_AGGREGATOR_PROPERTIES should match snapshot 1`] = `
{

View File

@@ -361,6 +361,7 @@ export const fromUniversalConfigurationToFlatPageLayoutWidgetConfiguration = ({
case WidgetConfigurationType.WORKFLOW_RUN:
case WidgetConfigurationType.IFRAME:
case WidgetConfigurationType.STANDALONE_RICH_TEXT:
case WidgetConfigurationType.EMAIL_THREAD:
return universalConfiguration;
}
};

View File

@@ -1,7 +1,17 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity';
import { type FieldTypeAndNameMetadata } from 'src/engine/workspace-manager/utils/get-ts-vector-column-expression.util';
import { type EntityRelation } from 'src/engine/workspace-manager/workspace-migration/types/entity-relation.interface';
import { type MessageWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message.workspace-entity';
const SUBJECT_FIELD_NAME = 'subject';
export const SEARCH_FIELDS_FOR_MESSAGE_THREAD: FieldTypeAndNameMetadata[] = [
{ name: SUBJECT_FIELD_NAME, type: FieldMetadataType.TEXT },
];
export class MessageThreadWorkspaceEntity extends BaseWorkspaceEntity {
subject: string | null;
messages: EntityRelation<MessageWorkspaceEntity[]>;
}

View File

@@ -25,7 +25,7 @@ type MessageAccumulator = {
| 'text'
| 'messageThreadId'
>;
threadToCreate?: Pick<MessageThreadWorkspaceEntity, 'id'>;
threadToCreate?: Pick<MessageThreadWorkspaceEntity, 'id' | 'subject'>;
messageChannelMessageAssociationToCreate?: Pick<
MessageChannelMessageAssociationWorkspaceEntity,
| 'id'
@@ -199,10 +199,52 @@ export class MessagingMessageService {
.map((accumulator) => accumulator.threadToCreate)
.filter(isDefined);
await messageThreadRepository.insert(
messageThreadsToCreate,
transactionManager,
);
const threadSubjectUpdates = new Map<
string,
{ subject: string; receivedAt: number }
>();
for (const message of messages) {
const messageAccumulator = messageAccumulatorMap.get(
message.externalId,
);
if (!isDefined(messageAccumulator)) {
continue;
}
if (
isDefined(messageAccumulator.existingThreadInDB) &&
isDefined(messageAccumulator.messageToCreate) &&
isDefined(message.subject)
) {
const threadId = messageAccumulator.existingThreadInDB.id;
const existing = threadSubjectUpdates.get(threadId);
const receivedAt = message.receivedAt?.getTime() ?? 0;
if (!isDefined(existing) || receivedAt > existing.receivedAt) {
threadSubjectUpdates.set(threadId, {
subject: message.subject,
receivedAt,
});
}
}
}
const threadsToUpsert = [
...messageThreadsToCreate,
...Array.from(threadSubjectUpdates.entries()).map(
([id, { subject }]) => ({ id, subject }),
),
];
if (threadsToUpsert.length > 0) {
await messageThreadRepository.upsert(
threadsToUpsert,
['id'],
transactionManager,
);
}
const messagesToCreate = Array.from(messageAccumulatorMap.values())
.map((accumulator) => accumulator.messageToCreate)
@@ -451,6 +493,7 @@ export class MessagingMessageService {
messageAccumulator.threadToCreate = {
id: newOrExistingMessageThreadId,
subject: message.subject,
};
}

View File

@@ -1838,15 +1838,24 @@ export const STANDARD_OBJECTS = {
searchVector: {
universalIdentifier: 'c63e091f-6528-4657-ad2a-b0a158f9e483',
},
subject: {
universalIdentifier: 'a8ddbf8c-1137-45d1-b89e-5ffbd83f67c8',
},
},
indexes: {},
views: {
allMessageThreads: {
universalIdentifier: '20202020-d002-4d02-8d02-ae55a9ba2002',
viewFields: {
subject: {
universalIdentifier: 'e5f0d32b-2b6a-47bc-b3bd-f32c96594ec1',
},
messages: {
universalIdentifier: '20202020-df02-4d02-8d02-ae55a9ba2f01',
},
updatedAt: {
universalIdentifier: 'af2c6ac9-7083-4609-8172-d518441f5e9e',
},
createdAt: {
universalIdentifier: '20202020-df02-4d02-8d02-ae55a9ba2f02',
},

View File

@@ -3,7 +3,6 @@ export enum SidePanelPages {
ViewRecord = 'view-record',
MergeRecords = 'merge-records',
UpdateRecords = 'update-records',
ViewEmailThread = 'view-email-thread',
ViewCalendarEvent = 'view-calendar-event',
EditRichText = 'edit-rich-text',
Copilot = 'copilot',

View File

@@ -186,6 +186,7 @@ export type {
NotesConfiguration,
FilesConfiguration,
EmailsConfiguration,
EmailThreadConfiguration,
CalendarConfiguration,
WorkflowConfiguration,
WorkflowVersionConfiguration,

View File

@@ -151,6 +151,10 @@ export type EmailsConfiguration = {
configurationType: 'EMAILS';
};
export type EmailThreadConfiguration = {
configurationType: 'EMAIL_THREAD';
};
export type CalendarConfiguration = {
configurationType: 'CALENDAR';
};
@@ -189,4 +193,5 @@ export type PageLayoutWidgetConfiguration =
| CalendarConfiguration
| WorkflowConfiguration
| WorkflowVersionConfiguration
| WorkflowRunConfiguration;
| WorkflowRunConfiguration
| EmailThreadConfiguration;