Use record page layouts in the merge records feature (#18961)

There was a design issue. It revealed that we weren't using record page
layouts in the merge records feature.

## Before

<img width="802" height="1283" alt="image"
src="https://github.com/user-attachments/assets/e4238144-e10e-47a6-83e6-7cc03ca89b15"
/>

## After


https://github.com/user-attachments/assets/c0fa9cf5-2b28-4696-bf20-8271dce9e62c
This commit is contained in:
Baptiste Devessier
2026-03-26 10:54:33 +01:00
committed by GitHub
parent d126d54bbc
commit cfefe9273b
11 changed files with 151 additions and 213 deletions
@@ -1,10 +1,8 @@
import { usePerformMergePreview } from '@/object-record/record-merge/hooks/usePerformMergePreview';
import { SummaryCard } from '@/object-record/record-show/components/SummaryCard';
import { CardType } from '@/object-record/record-show/types/CardType';
import { getCardComponent } from '@/object-record/record-show/utils/getCardComponent';
import { PageLayoutSingleTabRenderer } from '@/page-layout/components/PageLayoutSingleTabRenderer';
import { usePageLayoutIdForRecord } from '@/page-layout/hooks/usePageLayoutIdForRecord';
import { LayoutRenderingProvider } from '@/ui/layout/contexts/LayoutRenderingContext';
import { isDefined } from 'twenty-shared/utils';
import { Section } from 'twenty-ui/layout';
import { PageLayoutType } from '~/generated-metadata/graphql';
type MergePreviewTabProps = {
@@ -18,7 +16,16 @@ export const MergePreviewTab = ({
objectNameSingular,
});
if (!isDefined(mergePreviewRecord) || isGeneratingPreview) {
const { pageLayoutId } = usePageLayoutIdForRecord({
id: mergePreviewRecord?.id ?? '',
targetObjectNameSingular: objectNameSingular,
});
if (
!isDefined(mergePreviewRecord) ||
isGeneratingPreview ||
!isDefined(pageLayoutId)
) {
return null;
}
@@ -35,17 +42,7 @@ export const MergePreviewTab = ({
isInSidePanel: true,
}}
>
<Section>
<SummaryCard
objectNameSingular={objectNameSingular}
objectRecordId={recordId}
isInSidePanel={true}
/>
{getCardComponent(CardType.FieldCard, {
showDuplicatesSection: false,
})}
</Section>
<PageLayoutSingleTabRenderer pageLayoutId={pageLayoutId} />
</LayoutRenderingProvider>
);
};
@@ -1,12 +1,11 @@
import { SummaryCard } from '@/object-record/record-show/components/SummaryCard';
import { CardType } from '@/object-record/record-show/types/CardType';
import { getCardComponent } from '@/object-record/record-show/utils/getCardComponent';
import { RecordShowEffect } from '@/object-record/record-show/components/RecordShowEffect';
import { PageLayoutSingleTabRenderer } from '@/page-layout/components/PageLayoutSingleTabRenderer';
import { usePageLayoutIdForRecord } from '@/page-layout/hooks/usePageLayoutIdForRecord';
import { LayoutRenderingProvider } from '@/ui/layout/contexts/LayoutRenderingContext';
import { Section } from 'twenty-ui/layout';
import { isDefined } from 'twenty-shared/utils';
import { PageLayoutType } from '~/generated-metadata/graphql';
type MergeRecordTabProps = {
isInSidePanel?: boolean;
objectNameSingular: string;
recordId: string;
};
@@ -15,6 +14,11 @@ export const MergeRecordTab = ({
objectNameSingular,
recordId,
}: MergeRecordTabProps) => {
const { pageLayoutId } = usePageLayoutIdForRecord({
id: recordId,
targetObjectNameSingular: objectNameSingular,
});
return (
<LayoutRenderingProvider
value={{
@@ -26,17 +30,13 @@ export const MergeRecordTab = ({
isInSidePanel: true,
}}
>
<Section>
<SummaryCard
objectNameSingular={objectNameSingular}
objectRecordId={recordId}
isInSidePanel={true}
/>
{getCardComponent(CardType.FieldCard, {
showDuplicatesSection: false,
})}
</Section>
<RecordShowEffect
objectNameSingular={objectNameSingular}
recordId={recordId}
/>
{isDefined(pageLayoutId) && (
<PageLayoutSingleTabRenderer pageLayoutId={pageLayoutId} />
)}
</LayoutRenderingProvider>
);
};
@@ -8,11 +8,11 @@ import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTab
import { TabListComponentInstanceContext } from '@/ui/layout/tab-list/states/contexts/TabListComponentInstanceContext';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { SidePanelPageComponentInstanceContext } from '@/side-panel/states/contexts/SidePanelPageComponentInstanceContext';
import { useMergeRecordsContainerTabs } from '@/object-record/record-merge/hooks/useMergeRecordsContainerTabs';
import { useMergeRecordsSelectedRecords } from '@/object-record/record-merge/hooks/useMergeRecordsSelectedRecords';
import { MergeRecordsTabId } from '@/object-record/record-merge/types/MergeRecordsTabId';
import { SidePanelPageComponentInstanceContext } from '@/side-panel/states/contexts/SidePanelPageComponentInstanceContext';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useMergeRecordsContainerTabs } from '@/object-record/record-merge/hooks/useMergeRecordsContainerTabs';
import { MergePreviewTab } from './MergePreviewTab';
import { MergeRecordTab } from './MergeRecordTab';
import { MergeRecordsFooter } from './MergeRecordsFooter';
@@ -36,7 +36,6 @@ const StyledContentContainer = styled.div`
background: ${themeCssVariables.background.primary};
flex: 1;
overflow-y: auto;
padding-bottom: ${themeCssVariables.spacing[16]};
`;
type MergeRecordsContainerProps = {
@@ -1,15 +1,15 @@
import { t } from '@lingui/core/macro';
import { useMergeRecordsSelectedRecords } from '@/object-record/record-merge/hooks/useMergeRecordsSelectedRecords';
import { useMergeRecordsSettings } from '@/object-record/record-merge/hooks/useMergeRecordsSettings';
import { Select } from '@/ui/input/components/Select';
import { styled } from '@linaria/react';
import { Section } from 'twenty-ui/layout';
import { getPositionNumberIcon } from '@/object-record/record-merge/utils/getPositionNumberIcon';
import { getPositionWordLabel } from '@/object-record/record-merge/utils/getPositionWordLabel';
import { Select } from '@/ui/input/components/Select';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledSectionContainer = styled.div`
margin: ${themeCssVariables.spacing[4]};
padding: ${themeCssVariables.spacing[2]};
width: auto;
`;
@@ -1,27 +0,0 @@
import { RecordFieldList } from '@/object-record/record-field-list/components/RecordFieldList';
import { RecordFieldsScopeContextProvider } from '@/object-record/record-field-list/contexts/RecordFieldsScopeContext';
import { type FieldCardConfiguration } from '@/object-record/record-show/types/CardConfiguration';
import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext';
import { useTargetRecord } from '@/ui/layout/contexts/useTargetRecord';
type FieldsCardProps = {
configuration?: FieldCardConfiguration;
};
export const FieldsCard = ({ configuration }: FieldsCardProps) => {
const targetRecord = useTargetRecord();
const { isInSidePanel } = useLayoutRenderingContext();
const instanceId = `fields-card-${targetRecord.id}${isInSidePanel ? '-side-panel' : ''}`;
return (
<RecordFieldsScopeContextProvider value={{ scopeInstanceId: instanceId }}>
<RecordFieldList
instanceId={instanceId}
objectNameSingular={targetRecord.targetObjectNameSingular}
objectRecordId={targetRecord.id}
showDuplicatesSection={configuration?.showDuplicatesSection ?? true}
/>
</RecordFieldsScopeContextProvider>
);
};
@@ -1,29 +0,0 @@
import { type CardType } from '@/object-record/record-show/types/CardType';
// Card configuration types - each card type can define its own configuration
export type FieldCardConfiguration = {
showDuplicatesSection?: boolean;
};
// For cards that don't need configuration, use undefined
export type EmptyCardConfiguration = undefined;
// Type mapping from CardType to its specific configuration type
// This creates precise typing: each CardType is linked to exactly one configuration type
export type CardTypeToConfiguration = {
[CardType.FieldCard]: FieldCardConfiguration;
[CardType.TimelineCard]: EmptyCardConfiguration;
[CardType.TaskCard]: EmptyCardConfiguration;
[CardType.NoteCard]: EmptyCardConfiguration;
[CardType.FileCard]: EmptyCardConfiguration;
[CardType.EmailCard]: EmptyCardConfiguration;
[CardType.CalendarCard]: EmptyCardConfiguration;
[CardType.FieldRichTextCard]: EmptyCardConfiguration;
[CardType.WorkflowCard]: EmptyCardConfiguration;
[CardType.WorkflowVersionCard]: EmptyCardConfiguration;
[CardType.WorkflowRunCard]: EmptyCardConfiguration;
[CardType.DashboardCard]: EmptyCardConfiguration;
};
// Union type for all card configurations (for general use)
export type CardConfiguration = FieldCardConfiguration | EmptyCardConfiguration;
@@ -1,14 +0,0 @@
export enum CardType {
FieldCard = 'FieldCard',
TimelineCard = 'TimelineCard',
TaskCard = 'TaskCard',
NoteCard = 'NoteCard',
FileCard = 'FileCard',
EmailCard = 'EmailCard',
CalendarCard = 'CalendarCard',
WorkflowCard = 'WorkflowCard',
WorkflowVersionCard = 'WorkflowVersionCard',
WorkflowRunCard = 'WorkflowRunCard',
FieldRichTextCard = 'FieldRichTextCard',
DashboardCard = 'DashboardCard',
}
@@ -1,94 +0,0 @@
import { CalendarEventsCard } from '@/activities/calendar/components/CalendarEventsCard';
import { EmailsCard } from '@/activities/emails/components/EmailsCard';
import { FilesCard } from '@/activities/files/components/FilesCard';
import { NotesCard } from '@/activities/notes/components/NotesCard';
import { TasksCard } from '@/activities/tasks/components/TasksCard';
import { TimelineCard } from '@/activities/timeline-activities/components/TimelineCard';
import { FieldsCard } from '@/object-record/record-show/components/FieldsCard';
import {
type CardConfiguration,
type CardTypeToConfiguration,
type FieldCardConfiguration,
} from '@/object-record/record-show/types/CardConfiguration';
import { CardType } from '@/object-record/record-show/types/CardType';
import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext';
import { FieldRichTextCard } from '@/ui/layout/show-page/components/FieldRichTextCard';
import { WorkflowCard } from '@/workflow/workflow-diagram/components/WorkflowCard';
import { WorkflowRunCard } from '@/workflow/workflow-diagram/components/WorkflowRunCard';
import { WorkflowVersionCard } from '@/workflow/workflow-diagram/components/WorkflowVersionCard';
import { assertUnreachable } from 'twenty-shared/utils';
const CardRenderer = <T extends CardConfiguration>({
Component,
configuration,
}: {
Component: React.ComponentType<{ configuration?: T }> | React.ComponentType;
configuration?: T;
}) => {
const { targetRecordIdentifier } = useLayoutRenderingContext();
if (!targetRecordIdentifier) {
return null;
}
// TypeScript can't infer if Component accepts configuration prop or not
// So we cast to the more permissive type and let the component ignore unused props
const ComponentWithConfig = Component as React.ComponentType<{
configuration?: T;
}>;
return <ComponentWithConfig configuration={configuration} />;
};
// Generic function with precise type mapping from CardType to Configuration
// TypeScript will enforce that the correct configuration type is passed for each card type
export const getCardComponent = <T extends CardType>(
cardType: T,
configuration?: CardTypeToConfiguration[T],
): JSX.Element | null => {
switch (cardType) {
case CardType.TimelineCard:
return <CardRenderer Component={TimelineCard} />;
case CardType.FieldCard:
return (
<CardRenderer
Component={FieldsCard}
configuration={configuration as FieldCardConfiguration | undefined}
/>
);
case CardType.FieldRichTextCard:
return <CardRenderer Component={FieldRichTextCard} />;
case CardType.TaskCard:
return <CardRenderer Component={TasksCard} />;
case CardType.NoteCard:
return <CardRenderer Component={NotesCard} />;
case CardType.FileCard:
return <CardRenderer Component={FilesCard} />;
case CardType.EmailCard:
return <CardRenderer Component={EmailsCard} />;
case CardType.CalendarCard:
return <CardRenderer Component={CalendarEventsCard} />;
case CardType.WorkflowCard:
return <CardRenderer Component={WorkflowCard} />;
case CardType.WorkflowVersionCard:
return <CardRenderer Component={WorkflowVersionCard} />;
case CardType.WorkflowRunCard:
return <CardRenderer Component={WorkflowRunCard} />;
case CardType.DashboardCard:
throw new Error('Dashboard are handled separately currently');
default:
assertUnreachable(cardType);
}
};
@@ -0,0 +1,115 @@
import { SummaryCard } from '@/object-record/record-show/components/SummaryCard';
import { PageLayoutContent } from '@/page-layout/components/PageLayoutContent';
import { PageLayoutEditModeProvider } from '@/page-layout/components/PageLayoutEditModeProvider';
import { PageLayoutInitializationQueryEffect } from '@/page-layout/components/PageLayoutInitializationQueryEffect';
import { PageLayoutRecordPageCustomizationSessionRegistrationEffect } from '@/page-layout/components/PageLayoutRecordPageCustomizationSessionRegistrationEffect';
import { PageLayoutRelationWidgetsSyncEffect } from '@/page-layout/components/PageLayoutRelationWidgetsSyncEffect';
import { PageLayoutContentProvider } from '@/page-layout/contexts/PageLayoutContentContext';
import { useCurrentPageLayoutOrThrow } from '@/page-layout/hooks/useCurrentPageLayoutOrThrow';
import { usePageLayoutTabWithVisibleWidgetsOrThrow } from '@/page-layout/hooks/usePageLayoutTabWithVisibleWidgetsOrThrow';
import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext';
import { pageLayoutIsInitializedComponentState } from '@/page-layout/states/pageLayoutIsInitializedComponentState';
import { getTabLayoutMode } from '@/page-layout/utils/getTabLayoutMode';
import { getTabListInstanceIdFromPageLayoutAndRecord } from '@/page-layout/utils/getTabListInstanceIdFromPageLayoutAndRecord';
import { sortTabsByPosition } from '@/page-layout/utils/sortTabsByPosition';
import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext';
import { useTargetRecord } from '@/ui/layout/contexts/useTargetRecord';
import { TabListComponentInstanceContext } from '@/ui/layout/tab-list/states/contexts/TabListComponentInstanceContext';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useFeatureFlagsMap } from '@/workspace/hooks/useFeatureFlagsMap';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
type PageLayoutSingleTabRendererProps = {
pageLayoutId: string;
};
const PageLayoutSingleTabRendererContent = () => {
const pageLayoutIsInitialized = useAtomComponentStateValue(
pageLayoutIsInitializedComponentState,
);
if (!pageLayoutIsInitialized) {
return null;
}
return <PageLayoutSingleTabRendererInner />;
};
const PageLayoutSingleTabRendererInner = () => {
const { currentPageLayout } = useCurrentPageLayoutOrThrow();
const targetRecordIdentifier = useTargetRecord();
const { isInSidePanel } = useLayoutRenderingContext();
const sortedTabs = sortTabsByPosition(currentPageLayout.tabs);
const firstTab = sortedTabs[0];
const firstTabWithVisibleWidgets = usePageLayoutTabWithVisibleWidgetsOrThrow(
firstTab.id,
);
const layoutMode = getTabLayoutMode({
tab: firstTabWithVisibleWidgets,
pageLayoutType: currentPageLayout.type,
});
return (
<>
<SummaryCard
objectNameSingular={targetRecordIdentifier.targetObjectNameSingular}
objectRecordId={targetRecordIdentifier.id}
isInSidePanel={isInSidePanel}
/>
<PageLayoutContentProvider
value={{
tabId: firstTab.id,
layoutMode,
}}
>
<PageLayoutContent />
</PageLayoutContentProvider>
</>
);
};
export const PageLayoutSingleTabRenderer = ({
pageLayoutId,
}: PageLayoutSingleTabRendererProps) => {
const { targetRecordIdentifier, layoutType } = useLayoutRenderingContext();
const featureFlags = useFeatureFlagsMap();
const isRecordPageLayoutEditingEnabled =
featureFlags[FeatureFlagKey.IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED];
const tabListInstanceId = getTabListInstanceIdFromPageLayoutAndRecord({
pageLayoutId,
layoutType,
targetRecordIdentifier,
});
return (
<PageLayoutComponentInstanceContext.Provider
value={{
instanceId: pageLayoutId,
}}
>
<TabListComponentInstanceContext.Provider
value={{
instanceId: tabListInstanceId,
}}
>
<PageLayoutEditModeProvider
layoutType={layoutType}
pageLayoutId={pageLayoutId}
>
<PageLayoutInitializationQueryEffect pageLayoutId={pageLayoutId} />
<PageLayoutRecordPageCustomizationSessionRegistrationEffect />
{!isRecordPageLayoutEditingEnabled && (
<PageLayoutRelationWidgetsSyncEffect pageLayoutId={pageLayoutId} />
)}
<PageLayoutSingleTabRendererContent />
</PageLayoutEditModeProvider>
</TabListComponentInstanceContext.Provider>
</PageLayoutComponentInstanceContext.Provider>
);
};
@@ -1,7 +0,0 @@
import { type CardConfiguration } from '@/object-record/record-show/types/CardConfiguration';
import { type CardType } from '@/object-record/record-show/types/CardType';
export type LayoutCard = {
type: CardType;
configuration?: CardConfiguration;
};
@@ -1,4 +1,3 @@
import { type LayoutCard } from '@/ui/layout/tab-list/types/LayoutCard';
import { type IconComponent } from 'twenty-ui/display';
export type SingleTabProps<T extends string = string> = {
@@ -8,6 +7,5 @@ export type SingleTabProps<T extends string = string> = {
hide?: boolean;
disabled?: boolean;
pill?: string | React.ReactElement;
cards?: LayoutCard[];
logo?: string;
};