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

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

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

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

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

---------

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