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:
+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({
|
||||
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
import { styled } from '@linaria/react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { EmailThreadMessage } from '@/activities/emails/components/EmailThreadMessage';
|
||||
import { type EmailThreadMessageWithSender } from '@/activities/emails/types/EmailThreadMessageWithSender';
|
||||
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: ${themeCssVariables.spacing[4]} ${themeCssVariables.spacing[6]};
|
||||
`;
|
||||
|
||||
export const EmailThreadIntermediaryMessages = ({
|
||||
messages,
|
||||
}: {
|
||||
messages: EmailThreadMessageWithSender[];
|
||||
}) => {
|
||||
const [areMessagesOpen, setAreMessagesOpen] = useState(false);
|
||||
const messagesLength = messages.length;
|
||||
|
||||
if (messagesLength === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return areMessagesOpen ? (
|
||||
messages.map((message) => (
|
||||
<EmailThreadMessage
|
||||
key={message.id}
|
||||
sender={message.sender}
|
||||
participants={message.messageParticipants}
|
||||
body={message.text}
|
||||
sentAt={message.receivedAt}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<StyledButtonContainer>
|
||||
<Button
|
||||
Icon={IconArrowsVertical}
|
||||
title={t`${messagesLength} emails`}
|
||||
size="small"
|
||||
onClick={() => setAreMessagesOpen(true)}
|
||||
/>
|
||||
</StyledButtonContainer>
|
||||
);
|
||||
};
|
||||
+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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user