mirror of
https://github.com/twentyhq/twenty.git
synced 2026-04-24 00:41:53 -04:00
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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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 />,
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export const DEFAULT_MESSAGE_THREAD_RECORD_PAGE_LAYOUT_ID =
|
||||
'default-message-thread-page-layout';
|
||||
@@ -159,6 +159,9 @@ export const PAGE_LAYOUT_WIDGET_FRAGMENT = gql`
|
||||
... on EmailsConfiguration {
|
||||
configurationType
|
||||
}
|
||||
... on EmailThreadConfiguration {
|
||||
configurationType
|
||||
}
|
||||
... on FieldConfiguration {
|
||||
configurationType
|
||||
fieldDisplayMode
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = ({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 />],
|
||||
[
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -17,4 +17,5 @@ export enum WidgetType {
|
||||
WORKFLOW_RUN = 'WORKFLOW_RUN',
|
||||
FRONT_COMPONENT = 'FRONT_COMPONENT',
|
||||
RECORD_TABLE = 'RECORD_TABLE',
|
||||
EMAIL_THREAD = 'EMAIL_THREAD',
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
@@ -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'],
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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`] = `
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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[]>;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -186,6 +186,7 @@ export type {
|
||||
NotesConfiguration,
|
||||
FilesConfiguration,
|
||||
EmailsConfiguration,
|
||||
EmailThreadConfiguration,
|
||||
CalendarConfiguration,
|
||||
WorkflowConfiguration,
|
||||
WorkflowVersionConfiguration,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user