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:
+7
-3
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+11
-16
@@ -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,
|
||||
+6
-3
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+2
@@ -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 />,
|
||||
|
||||
+78
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+61
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export const DEFAULT_MESSAGE_THREAD_RECORD_PAGE_LAYOUT_ID =
|
||||
'default-message-thread-page-layout';
|
||||
+3
@@ -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 = ({
|
||||
|
||||
+4
@@ -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({
|
||||
|
||||
+7
-5
@@ -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)}
|
||||
/>
|
||||
+91
@@ -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 />],
|
||||
[
|
||||
|
||||
-89
@@ -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,
|
||||
};
|
||||
};
|
||||
-183
@@ -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>
|
||||
);
|
||||
};
|
||||
-255
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
-10
@@ -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,
|
||||
});
|
||||
+7
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user