Companies show page as record page (#15132)

Example showing two widgets positioned in a vertical list:


https://github.com/user-attachments/assets/cf3e9793-7d80-4cab-b708-aad9f2bca7d8
This commit is contained in:
Baptiste Devessier
2025-10-16 16:45:06 +02:00
committed by GitHub
parent b2247a7a3d
commit 1b6c9e851f
14 changed files with 405 additions and 48 deletions
@@ -1250,6 +1250,7 @@ export enum FeatureFlagKey {
IS_PAGE_LAYOUT_ENABLED = 'IS_PAGE_LAYOUT_ENABLED',
IS_POSTGRESQL_INTEGRATION_ENABLED = 'IS_POSTGRESQL_INTEGRATION_ENABLED',
IS_PUBLIC_DOMAIN_ENABLED = 'IS_PUBLIC_DOMAIN_ENABLED',
IS_RECORD_PAGE_LAYOUT_ENABLED = 'IS_RECORD_PAGE_LAYOUT_ENABLED',
IS_RELATION_CONNECT_ENABLED = 'IS_RELATION_CONNECT_ENABLED',
IS_STRIPE_INTEGRATION_ENABLED = 'IS_STRIPE_INTEGRATION_ENABLED',
IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED',
@@ -1214,6 +1214,7 @@ export enum FeatureFlagKey {
IS_PAGE_LAYOUT_ENABLED = 'IS_PAGE_LAYOUT_ENABLED',
IS_POSTGRESQL_INTEGRATION_ENABLED = 'IS_POSTGRESQL_INTEGRATION_ENABLED',
IS_PUBLIC_DOMAIN_ENABLED = 'IS_PUBLIC_DOMAIN_ENABLED',
IS_RECORD_PAGE_LAYOUT_ENABLED = 'IS_RECORD_PAGE_LAYOUT_ENABLED',
IS_RELATION_CONNECT_ENABLED = 'IS_RELATION_CONNECT_ENABLED',
IS_STRIPE_INTEGRATION_ENABLED = 'IS_STRIPE_INTEGRATION_ENABLED',
IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED',
@@ -4,7 +4,8 @@ import { RecordShowContainer } from '@/object-record/record-show/components/Reco
import { RecordShowEffect } from '@/object-record/record-show/components/RecordShowEffect';
import { LayoutRenderingProvider } from '@/ui/layout/contexts/LayoutRenderingContext';
import { type TargetRecordIdentifier } from '@/ui/layout/contexts/TargetRecordIdentifier';
import { PageLayoutType } from '~/generated/graphql';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { FeatureFlagKey, PageLayoutType } from '~/generated/graphql';
export const PageLayoutDispatcher = ({
targetRecordIdentifier,
@@ -13,9 +14,16 @@ export const PageLayoutDispatcher = ({
targetRecordIdentifier: TargetRecordIdentifier;
isInRightDrawer?: boolean;
}) => {
const isRecordPageEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_RECORD_PAGE_LAYOUT_ENABLED,
);
if (
targetRecordIdentifier.targetObjectNameSingular ===
CoreObjectNameSingular.Dashboard
CoreObjectNameSingular.Dashboard ||
(targetRecordIdentifier.targetObjectNameSingular ===
CoreObjectNameSingular.Company &&
isRecordPageEnabled)
) {
return (
<PageLayoutRecordPageRenderer
@@ -16,6 +16,7 @@ import { WidgetRenderer } from '@/page-layout/widgets/components/WidgetRenderer'
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import styled from '@emotion/styled';
import { useRef } from 'react';
import {
@@ -24,6 +25,7 @@ import {
type ResponsiveProps,
} from 'react-grid-layout';
import { isDefined } from 'twenty-shared/utils';
import { FeatureFlagKey } from '~/generated/graphql';
const StyledGridContainer = styled.div`
background: ${({ theme }) => theme.background.primary};
@@ -55,7 +57,17 @@ const ResponsiveGridLayout = WidthProvider(
Responsive,
) as React.ComponentType<ExtendedResponsiveProps>;
const StyledVerticalListContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(2)};
`;
export const PageLayoutGridLayout = () => {
const isRecordPageEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_RECORD_PAGE_LAYOUT_ENABLED,
);
const setPageLayoutCurrentBreakpoint = useSetRecoilComponentState(
pageLayoutCurrentBreakpointComponentState,
);
@@ -76,20 +88,38 @@ export const PageLayoutGridLayout = () => {
const { currentPageLayout } = useCurrentPageLayout();
if (!isDefined(activeTabId) || !isDefined(currentPageLayout)) {
const activeTab = currentPageLayout?.tabs.find(
(tab) => tab.id === activeTabId,
);
if (
!isDefined(activeTabId) ||
!isDefined(currentPageLayout) ||
!isDefined(activeTab)
) {
return null;
}
const activeTabWidgets = currentPageLayout.tabs.find(
(tab) => tab.id === activeTabId,
)?.widgets;
const activeTabWidgets = activeTab.widgets;
const isLayoutEmpty =
!isDefined(activeTabWidgets) || activeTabWidgets.length === 0;
const layouts = isLayoutEmpty
? EMPTY_LAYOUT
: pageLayoutCurrentLayouts[activeTabId] || EMPTY_LAYOUT;
: (pageLayoutCurrentLayouts[activeTabId] ?? EMPTY_LAYOUT);
const Widgets = isLayoutEmpty ? (
<div key="empty-placeholder" data-select-disable="true">
<WidgetPlaceholder />
</div>
) : (
activeTabWidgets?.map((widget) => (
<div key={widget.id} data-select-disable="true">
<WidgetRenderer widget={widget} />
</div>
))
);
return (
<>
@@ -102,43 +132,42 @@ export const PageLayoutGridLayout = () => {
/>
</>
)}
<ResponsiveGridLayout
className="layout"
layouts={layouts}
breakpoints={PAGE_LAYOUT_CONFIG.breakpoints}
cols={PAGE_LAYOUT_CONFIG.columns}
rowHeight={55}
maxCols={12}
containerPadding={[0, 0]}
margin={[8, 8]}
isDraggable={isPageLayoutInEditMode}
isResizable={isPageLayoutInEditMode}
draggableHandle=".drag-handle"
compactType="vertical"
preventCollision={false}
resizeHandle={
isPageLayoutInEditMode ? <PageLayoutGridResizeHandle /> : undefined
}
resizeHandles={['se']}
onLayoutChange={handleLayoutChange}
onBreakpointChange={(newBreakpoint) =>
setPageLayoutCurrentBreakpoint(
newBreakpoint as PageLayoutBreakpoint,
)
}
>
{isLayoutEmpty ? (
<div key="empty-placeholder" data-select-disable="true">
<WidgetPlaceholder />
</div>
) : (
activeTabWidgets?.map((widget) => (
<div key={widget.id} data-select-disable="true">
<WidgetRenderer widget={widget} />
</div>
))
)}
</ResponsiveGridLayout>
{isRecordPageEnabled &&
!isPageLayoutInEditMode &&
activeTab.layoutMode === 'vertical-list' ? (
<StyledVerticalListContainer>{Widgets}</StyledVerticalListContainer>
) : (
<ResponsiveGridLayout
className="layout"
layouts={layouts}
breakpoints={PAGE_LAYOUT_CONFIG.breakpoints}
cols={PAGE_LAYOUT_CONFIG.columns}
rowHeight={55}
maxCols={12}
containerPadding={[0, 0]}
margin={[8, 8]}
isDraggable={isPageLayoutInEditMode}
isResizable={isPageLayoutInEditMode}
draggableHandle=".drag-handle"
compactType="vertical"
preventCollision={false}
resizeHandle={
isPageLayoutInEditMode ? (
<PageLayoutGridResizeHandle />
) : undefined
}
resizeHandles={['se']}
onLayoutChange={handleLayoutChange}
onBreakpointChange={(newBreakpoint) =>
setPageLayoutCurrentBreakpoint(
newBreakpoint as PageLayoutBreakpoint,
)
}
>
{Widgets}
</ResponsiveGridLayout>
)}
</StyledGridContainer>
</>
);
@@ -1,4 +1,6 @@
import { FIND_ONE_PAGE_LAYOUT } from '@/dashboards/graphql/queries/findOnePageLayout';
import { DEFAULT_PAGE_LAYOUT } from '@/page-layout/constants/DefaultPageLayout';
import { DEFAULT_PAGE_LAYOUT_ID } from '@/page-layout/constants/DefaultPageLayoutId';
import { pageLayoutCurrentLayoutsComponentState } from '@/page-layout/states/pageLayoutCurrentLayoutsComponentState';
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
import { pageLayoutPersistedComponentState } from '@/page-layout/states/pageLayoutPersistedComponentState';
@@ -24,15 +26,20 @@ export const PageLayoutInitializationQueryEffect = ({
}: PageLayoutInitializationQueryEffectProps) => {
const [isInitialized, setIsInitialized] = useState(false);
const isDefaultLayout = pageLayoutId === DEFAULT_PAGE_LAYOUT_ID;
const { data } = useQuery(FIND_ONE_PAGE_LAYOUT, {
variables: {
id: pageLayoutId,
},
skip: isDefaultLayout,
});
const pageLayout: PageLayout | undefined = data?.getPageLayout
? transformPageLayout(data.getPageLayout)
: undefined;
const pageLayout: PageLayout | undefined = isDefaultLayout
? DEFAULT_PAGE_LAYOUT
: data?.getPageLayout
? transformPageLayout(data.getPageLayout)
: undefined;
const pageLayoutPersistedComponentCallbackState =
useRecoilComponentCallbackState(pageLayoutPersistedComponentState);
@@ -0,0 +1,252 @@
import { DEFAULT_PAGE_LAYOUT_ID } from '@/page-layout/constants/DefaultPageLayoutId';
import { type PageLayout } from '@/page-layout/types/PageLayout';
import { PageLayoutType, WidgetType } from '~/generated/graphql';
/**
* Default mock PageLayout used when pageLayoutId equals DEFAULT_PAGE_LAYOUT_ID.
* This combines base record layout tabs (fields, timeline, tasks, notes, files)
* with company-specific tabs (emails, calendar).
*/
export const DEFAULT_PAGE_LAYOUT: PageLayout = {
__typename: 'PageLayout',
id: DEFAULT_PAGE_LAYOUT_ID,
name: 'Default Record Layout',
type: PageLayoutType.RECORD_PAGE,
objectMetadataId: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
tabs: [
// Fields tab (position 100)
{
__typename: 'PageLayoutTab',
id: 'default-tab-fields',
title: 'Fields',
position: 100,
layoutMode: 'vertical-list',
pageLayoutId: DEFAULT_PAGE_LAYOUT_ID,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
widgets: [
{
__typename: 'PageLayoutWidget',
id: 'default-widget-fields',
pageLayoutTabId: 'default-tab-fields',
title: 'Fields',
type: WidgetType.FIELDS,
objectMetadataId: null,
gridPosition: {
__typename: 'GridPosition',
row: 0,
column: 0,
rowSpan: 12,
columnSpan: 12,
},
configuration: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
},
],
},
// Timeline tab (position 200)
{
__typename: 'PageLayoutTab',
id: 'default-tab-timeline',
title: 'Timeline',
position: 200,
layoutMode: 'vertical-list',
pageLayoutId: DEFAULT_PAGE_LAYOUT_ID,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
widgets: [
{
__typename: 'PageLayoutWidget',
id: 'default-widget-timeline',
pageLayoutTabId: 'default-tab-timeline',
title: 'Timeline',
type: WidgetType.VIEW,
objectMetadataId: null,
gridPosition: {
__typename: 'GridPosition',
row: 0,
column: 0,
rowSpan: 6,
columnSpan: 12,
},
configuration: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
},
],
},
// Tasks tab (position 300)
{
__typename: 'PageLayoutTab',
id: 'default-tab-tasks',
title: 'Tasks',
position: 300,
layoutMode: 'vertical-list',
pageLayoutId: DEFAULT_PAGE_LAYOUT_ID,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
widgets: [
{
__typename: 'PageLayoutWidget',
id: 'default-widget-tasks',
pageLayoutTabId: 'default-tab-tasks',
title: 'Tasks',
type: WidgetType.VIEW,
objectMetadataId: null,
gridPosition: {
__typename: 'GridPosition',
row: 0,
column: 0,
rowSpan: 6,
columnSpan: 12,
},
configuration: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
},
],
},
// Notes tab (position 400)
{
__typename: 'PageLayoutTab',
id: 'default-tab-notes',
title: 'Notes',
position: 400,
layoutMode: 'vertical-list',
pageLayoutId: DEFAULT_PAGE_LAYOUT_ID,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
widgets: [
{
__typename: 'PageLayoutWidget',
id: 'default-widget-notes',
pageLayoutTabId: 'default-tab-notes',
title: 'Notes',
type: WidgetType.VIEW,
objectMetadataId: null,
gridPosition: {
__typename: 'GridPosition',
row: 0,
column: 0,
rowSpan: 6,
columnSpan: 12,
},
configuration: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
},
],
},
// Files tab (position 500)
{
__typename: 'PageLayoutTab',
id: 'default-tab-files',
title: 'Files',
position: 500,
layoutMode: 'vertical-list',
pageLayoutId: DEFAULT_PAGE_LAYOUT_ID,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
widgets: [
{
__typename: 'PageLayoutWidget',
id: 'default-widget-files',
pageLayoutTabId: 'default-tab-files',
title: 'Files',
type: WidgetType.VIEW,
objectMetadataId: null,
gridPosition: {
__typename: 'GridPosition',
row: 0,
column: 0,
rowSpan: 6,
columnSpan: 12,
},
configuration: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
},
],
},
// Emails tab (position 600) - Company specific
{
__typename: 'PageLayoutTab',
id: 'default-tab-emails',
title: 'Emails',
position: 600,
layoutMode: 'vertical-list',
pageLayoutId: DEFAULT_PAGE_LAYOUT_ID,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
widgets: [
{
__typename: 'PageLayoutWidget',
id: 'default-widget-emails',
pageLayoutTabId: 'default-tab-emails',
title: 'Emails',
type: WidgetType.VIEW,
objectMetadataId: null,
gridPosition: {
__typename: 'GridPosition',
row: 0,
column: 0,
rowSpan: 6,
columnSpan: 12,
},
configuration: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
},
],
},
// Calendar tab (position 700) - Company specific
{
__typename: 'PageLayoutTab',
id: 'default-tab-calendar',
title: 'Calendar',
position: 700,
layoutMode: 'vertical-list',
pageLayoutId: DEFAULT_PAGE_LAYOUT_ID,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
widgets: [
{
__typename: 'PageLayoutWidget',
id: 'default-widget-calendar',
pageLayoutTabId: 'default-tab-calendar',
title: 'Calendar',
type: WidgetType.VIEW,
objectMetadataId: null,
gridPosition: {
__typename: 'GridPosition',
row: 0,
column: 0,
rowSpan: 6,
columnSpan: 12,
},
configuration: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
},
],
},
],
};
@@ -0,0 +1 @@
export const DEFAULT_PAGE_LAYOUT_ID = 'DEFAULT_LAYOUT_ID';
@@ -1,5 +1,6 @@
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { DEFAULT_PAGE_LAYOUT_ID } from '@/page-layout/constants/DefaultPageLayoutId';
import { type TargetRecordIdentifier } from '@/ui/layout/contexts/TargetRecordIdentifier';
import { isDefined } from 'twenty-shared/utils';
@@ -28,6 +29,6 @@ export const useRecordPageLayoutId = ({
// TODO: implement a default layout
return {
pageLayoutId: 'DEFAULT_LAYOUT_ID',
pageLayoutId: DEFAULT_PAGE_LAYOUT_ID,
};
};
@@ -5,4 +5,8 @@ import {
export type PageLayoutTab = Omit<PageLayoutTabGenerated, 'widgets'> & {
widgets: PageLayoutWidget[];
/**
* Only available behind IS_RECORD_PAGE_LAYOUT_ENABLED for now.
*/
layoutMode?: 'grid' | 'vertical-list';
};
@@ -1,3 +1,4 @@
import { FieldsWidget } from '@/page-layout/widgets/fields/components/FieldsWidget';
import { GraphWidgetRenderer } from '@/page-layout/widgets/graph/components/GraphWidgetRenderer';
import { IframeWidget } from '@/page-layout/widgets/iframe/components/IframeWidget';
import { type PageLayoutWidget, WidgetType } from '~/generated/graphql';
@@ -16,6 +17,9 @@ export const WidgetContentRenderer = ({
case WidgetType.IFRAME:
return <IframeWidget widget={widget} />;
case WidgetType.FIELDS:
return <FieldsWidget widget={widget} />;
default:
return null;
}
@@ -0,0 +1,34 @@
import { RecordFieldList } from '@/object-record/record-field-list/components/RecordFieldList';
import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext';
import { useTargetRecord } from '@/ui/layout/contexts/useTargetRecord';
import { RightDrawerProvider } from '@/ui/layout/right-drawer/contexts/RightDrawerContext';
import styled from '@emotion/styled';
import { type PageLayoutWidget } from '~/generated/graphql';
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
`;
type FieldsWidgetProps = {
widget: PageLayoutWidget;
};
export const FieldsWidget = ({ widget: _widget }: FieldsWidgetProps) => {
const targetRecord = useTargetRecord();
const { isInRightDrawer } = useLayoutRenderingContext();
return (
<RightDrawerProvider value={{ isInRightDrawer }}>
<StyledContainer>
<RecordFieldList
instanceId={`fields-widget-${targetRecord.id}-${isInRightDrawer ? 'right-drawer' : ''}`}
objectNameSingular={targetRecord.targetObjectNameSingular}
objectRecordId={targetRecord.id}
// TODO: pick the value from the widget configuration
showDuplicatesSection={true}
/>
</StyledContainer>
</RightDrawerProvider>
);
};
@@ -13,6 +13,7 @@ export enum FeatureFlagKey {
IS_CORE_VIEW_ENABLED = 'IS_CORE_VIEW_ENABLED',
IS_WORKSPACE_MIGRATION_V2_ENABLED = 'IS_WORKSPACE_MIGRATION_V2_ENABLED',
IS_PAGE_LAYOUT_ENABLED = 'IS_PAGE_LAYOUT_ENABLED',
IS_RECORD_PAGE_LAYOUT_ENABLED = 'IS_RECORD_PAGE_LAYOUT_ENABLED',
IS_MESSAGE_FOLDER_CONTROL_ENABLED = 'IS_MESSAGE_FOLDER_CONTROL_ENABLED',
IS_WORKFLOW_ITERATOR_ENABLED = 'IS_WORKFLOW_ITERATOR_ENABLED',
IS_CALENDAR_VIEW_ENABLED = 'IS_CALENDAR_VIEW_ENABLED',
@@ -136,6 +136,7 @@ describe('WorkspaceEntityManager', () => {
IS_CORE_VIEW_ENABLED: false,
IS_WORKSPACE_MIGRATION_V2_ENABLED: false,
IS_PAGE_LAYOUT_ENABLED: false,
IS_RECORD_PAGE_LAYOUT_ENABLED: false,
IS_MESSAGE_FOLDER_CONTROL_ENABLED: false,
IS_WORKFLOW_ITERATOR_ENABLED: false,
IS_CALENDAR_VIEW_ENABLED: false,
@@ -168,6 +169,14 @@ describe('WorkspaceEntityManager', () => {
IS_CORE_VIEW_ENABLED: false,
IS_WORKSPACE_MIGRATION_V2_ENABLED: false,
IS_PAGE_LAYOUT_ENABLED: false,
IS_RECORD_PAGE_LAYOUT_ENABLED: false,
IS_MESSAGE_FOLDER_CONTROL_ENABLED: false,
IS_CALENDAR_VIEW_ENABLED: false,
IS_GROUP_BY_ENABLED: false,
IS_PUBLIC_DOMAIN_ENABLED: false,
IS_EMAILING_DOMAIN_ENABLED: false,
IS_DYNAMIC_SEARCH_FIELDS_ENABLED: false,
IS_COMMON_API_ENABLED: false,
IS_WORKFLOW_ITERATOR_ENABLED: false,
},
permissionsPerRoleId: {},
@@ -71,6 +71,11 @@ export const seedFeatureFlags = async (
workspaceId: workspaceId,
value: true,
},
{
key: FeatureFlagKey.IS_RECORD_PAGE_LAYOUT_ENABLED,
workspaceId: workspaceId,
value: false,
},
{
key: FeatureFlagKey.IS_WORKFLOW_ITERATOR_ENABLED,
workspaceId: workspaceId,