Scaffold all company cards as widgets (#15149)

This PR doesn't support mobile and side-panel modes. The fields tab will
be displayed in these cases.

## Demo


https://github.com/user-attachments/assets/0b2e699c-2b8e-4212-8c75-00d7d2e25237

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
Baptiste Devessier
2025-10-18 22:26:27 +02:00
committed by GitHub
parent 357a12f902
commit f6d133f285
20 changed files with 363 additions and 40 deletions
@@ -4477,9 +4477,15 @@ export type Webhook = {
export type WidgetConfiguration = BarChartConfiguration | GaugeChartConfiguration | IframeConfiguration | LineChartConfiguration | NumberChartConfiguration | PieChartConfiguration;
export enum WidgetType {
CALENDAR = 'CALENDAR',
EMAILS = 'EMAILS',
FIELDS = 'FIELDS',
FILES = 'FILES',
GRAPH = 'GRAPH',
IFRAME = 'IFRAME',
NOTES = 'NOTES',
TASKS = 'TASKS',
TIMELINE = 'TIMELINE',
VIEW = 'VIEW'
}
@@ -4305,9 +4305,15 @@ export type Webhook = {
export type WidgetConfiguration = BarChartConfiguration | GaugeChartConfiguration | IframeConfiguration | LineChartConfiguration | NumberChartConfiguration | PieChartConfiguration;
export enum WidgetType {
CALENDAR = 'CALENDAR',
EMAILS = 'EMAILS',
FIELDS = 'FIELDS',
FILES = 'FILES',
GRAPH = 'GRAPH',
IFRAME = 'IFRAME',
NOTES = 'NOTES',
TASKS = 'TASKS',
TIMELINE = 'TIMELINE',
VIEW = 'VIEW'
}
@@ -13,7 +13,6 @@ import { pageLayoutCurrentBreakpointComponentState } from '@/page-layout/states/
import { pageLayoutCurrentLayoutsComponentState } from '@/page-layout/states/pageLayoutCurrentLayoutsComponentState';
import { WidgetPlaceholder } from '@/page-layout/widgets/components/WidgetPlaceholder';
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';
@@ -63,7 +62,11 @@ const StyledVerticalListContainer = styled.div`
gap: ${({ theme }) => theme.spacing(2)};
`;
export const PageLayoutGridLayout = () => {
type PageLayoutGridLayoutProps = {
tabId: string;
};
export const PageLayoutGridLayout = ({ tabId }: PageLayoutGridLayoutProps) => {
const isRecordPageEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_RECORD_PAGE_LAYOUT_ENABLED,
);
@@ -84,19 +87,11 @@ export const PageLayoutGridLayout = () => {
pageLayoutCurrentLayoutsComponentState,
);
const activeTabId = useRecoilComponentValue(activeTabIdComponentState);
const { currentPageLayout } = useCurrentPageLayout();
const activeTab = currentPageLayout?.tabs.find(
(tab) => tab.id === activeTabId,
);
const activeTab = currentPageLayout?.tabs.find((tab) => tab.id === tabId);
if (
!isDefined(activeTabId) ||
!isDefined(currentPageLayout) ||
!isDefined(activeTab)
) {
if (!isDefined(currentPageLayout) || !isDefined(activeTab)) {
return null;
}
@@ -107,7 +102,7 @@ export const PageLayoutGridLayout = () => {
const layouts = isLayoutEmpty
? EMPTY_LAYOUT
: (pageLayoutCurrentLayouts[activeTabId] ?? EMPTY_LAYOUT);
: (pageLayoutCurrentLayouts[tabId] ?? EMPTY_LAYOUT);
const Widgets = isLayoutEmpty ? (
<div key="empty-placeholder" data-select-disable="true">
@@ -1,15 +1,21 @@
import { SummaryCard } from '@/object-record/record-show/components/SummaryCard';
import { PageLayoutGridLayout } from '@/page-layout/components/PageLayoutGridLayout';
import { useCreatePageLayoutTab } from '@/page-layout/hooks/useCreatePageLayoutTab';
import { useCurrentPageLayout } from '@/page-layout/hooks/useCurrentPageLayout';
import { isPageLayoutInEditModeComponentState } from '@/page-layout/states/isPageLayoutInEditModeComponentState';
import { getTabListInstanceIdFromPageLayoutId } from '@/page-layout/utils/getTabListInstanceIdFromPageLayoutId';
import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext';
import { useTargetRecord } from '@/ui/layout/contexts/useTargetRecord';
import { ShowPageContainer } from '@/ui/layout/page/components/ShowPageContainer';
import { ShowPageLeftContainer } from '@/ui/layout/show-page/components/ShowPageLeftContainer';
import { TabList } from '@/ui/layout/tab-list/components/TabList';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import styled from '@emotion/styled';
import { isDefined } from 'twenty-shared/utils';
const StyledContainer = styled.div`
const StyledTabsAndDashboardContainer = styled.div`
display: flex;
flex-direction: column;
height: 100%;
@@ -18,6 +24,16 @@ const StyledContainer = styled.div`
background: ${({ theme }) => theme.background.primary};
`;
const StyledShowPageRightContainer = styled.div`
display: flex;
flex-direction: column;
height: 100%;
justify-content: start;
width: 100%;
height: 100%;
overflow: auto;
`;
const StyledTabList = styled(TabList)`
padding-left: ${({ theme }) => theme.spacing(2)};
`;
@@ -29,10 +45,15 @@ const StyledScrollWrapper = styled(ScrollWrapper)`
export const PageLayoutRendererContent = () => {
const { currentPageLayout } = useCurrentPageLayout();
const targetRecordIdentifier = useTargetRecord();
const { isInRightDrawer } = useLayoutRenderingContext();
const isPageLayoutInEditMode = useRecoilComponentValue(
isPageLayoutInEditModeComponentState,
);
const activeTabId = useRecoilComponentValue(activeTabIdComponentState);
const { createPageLayoutTab } = useCreatePageLayoutTab(currentPageLayout?.id);
const handleAddTab = isPageLayoutInEditMode ? createPageLayoutTab : undefined;
@@ -41,22 +62,47 @@ export const PageLayoutRendererContent = () => {
return null;
}
const tabsToRenderInTabList = currentPageLayout.tabs.filter(
(tab) => tab.selfDisplayMode !== 'pinned-left',
);
const pinnedLeftTab = currentPageLayout.tabs.find(
(tab) => tab.selfDisplayMode === 'pinned-left',
);
return (
<StyledContainer>
<StyledTabList
tabs={currentPageLayout.tabs}
behaveAsLinks={false}
componentInstanceId={getTabListInstanceIdFromPageLayoutId(
currentPageLayout.id,
)}
onAddTab={handleAddTab}
/>
<StyledScrollWrapper
componentInstanceId={`scroll-wrapper-page-layout-${currentPageLayout.id}`}
defaultEnableXScroll={false}
>
<PageLayoutGridLayout />
</StyledScrollWrapper>
</StyledContainer>
<ShowPageContainer>
{isDefined(pinnedLeftTab) && (
<ShowPageLeftContainer forceMobile={false}>
<SummaryCard
objectNameSingular={targetRecordIdentifier.targetObjectNameSingular}
objectRecordId={targetRecordIdentifier.id}
isInRightDrawer={isInRightDrawer}
/>
<PageLayoutGridLayout tabId={pinnedLeftTab.id} />
</ShowPageLeftContainer>
)}
<StyledShowPageRightContainer>
<StyledTabsAndDashboardContainer>
<StyledTabList
tabs={tabsToRenderInTabList}
behaveAsLinks={false}
componentInstanceId={getTabListInstanceIdFromPageLayoutId(
currentPageLayout.id,
)}
onAddTab={handleAddTab}
/>
<StyledScrollWrapper
componentInstanceId={`scroll-wrapper-page-layout-${currentPageLayout.id}`}
defaultEnableXScroll={false}
>
{isDefined(activeTabId) && (
<PageLayoutGridLayout tabId={activeTabId} />
)}
</StyledScrollWrapper>
</StyledTabsAndDashboardContainer>
</StyledShowPageRightContainer>
</ShowPageContainer>
);
};
@@ -10,8 +10,10 @@ import { MemoryRouter } from 'react-router-dom';
import { FIND_ONE_PAGE_LAYOUT } from '@/dashboards/graphql/queries/findOnePageLayout';
import { ApolloCoreClientContext } from '@/object-metadata/contexts/ApolloCoreClientContext';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { PageLayoutRenderer } from '@/page-layout/components/PageLayoutRenderer';
import { generateGroupByQuery } from '@/page-layout/widgets/graph/utils/generateGroupByQuery';
import { LayoutRenderingProvider } from '@/ui/layout/contexts/LayoutRenderingContext';
import {
GraphOrderBy,
GraphType,
@@ -270,7 +272,18 @@ const meta: Meta<typeof PageLayoutRenderer> = {
<MemoryRouter>
<JestMetadataAndApolloMocksWrapper>
<CoreClientProviderWrapper>
<Story />
<LayoutRenderingProvider
value={{
isInRightDrawer: false,
layoutType: PageLayoutType.DASHBOARD,
targetRecordIdentifier: {
targetObjectNameSingular: CoreObjectNameSingular.Dashboard,
id: mixedGraphsPageLayoutMocks.id,
},
}}
>
<Story />
</LayoutRenderingProvider>
</CoreClientProviderWrapper>
</JestMetadataAndApolloMocksWrapper>
</MemoryRouter>
@@ -24,6 +24,7 @@ export const DEFAULT_PAGE_LAYOUT: PageLayout = {
title: 'Fields',
position: 100,
layoutMode: 'vertical-list',
selfDisplayMode: 'pinned-left',
pageLayoutId: DEFAULT_PAGE_LAYOUT_ID,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -67,7 +68,7 @@ export const DEFAULT_PAGE_LAYOUT: PageLayout = {
id: 'default-widget-timeline',
pageLayoutTabId: 'default-tab-timeline',
title: 'Timeline',
type: WidgetType.VIEW,
type: WidgetType.TIMELINE,
objectMetadataId: null,
gridPosition: {
__typename: 'GridPosition',
@@ -100,7 +101,7 @@ export const DEFAULT_PAGE_LAYOUT: PageLayout = {
id: 'default-widget-tasks',
pageLayoutTabId: 'default-tab-tasks',
title: 'Tasks',
type: WidgetType.VIEW,
type: WidgetType.TASKS,
objectMetadataId: null,
gridPosition: {
__typename: 'GridPosition',
@@ -133,7 +134,7 @@ export const DEFAULT_PAGE_LAYOUT: PageLayout = {
id: 'default-widget-notes',
pageLayoutTabId: 'default-tab-notes',
title: 'Notes',
type: WidgetType.VIEW,
type: WidgetType.NOTES,
objectMetadataId: null,
gridPosition: {
__typename: 'GridPosition',
@@ -166,7 +167,7 @@ export const DEFAULT_PAGE_LAYOUT: PageLayout = {
id: 'default-widget-files',
pageLayoutTabId: 'default-tab-files',
title: 'Files',
type: WidgetType.VIEW,
type: WidgetType.FILES,
objectMetadataId: null,
gridPosition: {
__typename: 'GridPosition',
@@ -199,7 +200,7 @@ export const DEFAULT_PAGE_LAYOUT: PageLayout = {
id: 'default-widget-emails',
pageLayoutTabId: 'default-tab-emails',
title: 'Emails',
type: WidgetType.VIEW,
type: WidgetType.EMAILS,
objectMetadataId: null,
gridPosition: {
__typename: 'GridPosition',
@@ -232,7 +233,7 @@ export const DEFAULT_PAGE_LAYOUT: PageLayout = {
id: 'default-widget-calendar',
pageLayoutTabId: 'default-tab-calendar',
title: 'Calendar',
type: WidgetType.VIEW,
type: WidgetType.CALENDAR,
objectMetadataId: null,
gridPosition: {
__typename: 'GridPosition',
@@ -13,4 +13,5 @@ export type PageLayoutTab = Omit<PageLayoutTabGenerated, 'widgets'> & {
* Only available behind IS_RECORD_PAGE_LAYOUT_ENABLED for now.
*/
layoutMode?: 'grid' | 'vertical-list';
selfDisplayMode?: 'pinned-left';
};
@@ -0,0 +1,27 @@
import { CalendarEventsCard } from '@/activities/calendar/components/CalendarEventsCard';
import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext';
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;
width: 100%;
`;
type CalendarWidgetProps = {
widget: PageLayoutWidget;
};
export const CalendarWidget = ({ widget: _widget }: CalendarWidgetProps) => {
const { isInRightDrawer } = useLayoutRenderingContext();
return (
<RightDrawerProvider value={{ isInRightDrawer }}>
<StyledContainer>
<CalendarEventsCard />
</StyledContainer>
</RightDrawerProvider>
);
};
@@ -1,6 +1,12 @@
import { CalendarWidget } from '@/page-layout/widgets/calendar/components/CalendarWidget';
import { EmailWidget } from '@/page-layout/widgets/emails/components/EmailWidget';
import { FieldsWidget } from '@/page-layout/widgets/fields/components/FieldsWidget';
import { FileWidget } from '@/page-layout/widgets/files/components/FileWidget';
import { GraphWidgetRenderer } from '@/page-layout/widgets/graph/components/GraphWidgetRenderer';
import { IframeWidget } from '@/page-layout/widgets/iframe/components/IframeWidget';
import { NoteWidget } from '@/page-layout/widgets/notes/components/NoteWidget';
import { TaskWidget } from '@/page-layout/widgets/tasks/components/TaskWidget';
import { TimelineWidget } from '@/page-layout/widgets/timeline/components/TimelineWidget';
import { type PageLayoutWidget, WidgetType } from '~/generated/graphql';
type WidgetContentRendererProps = {
@@ -20,6 +26,24 @@ export const WidgetContentRenderer = ({
case WidgetType.FIELDS:
return <FieldsWidget widget={widget} />;
case WidgetType.TIMELINE:
return <TimelineWidget widget={widget} />;
case WidgetType.TASKS:
return <TaskWidget widget={widget} />;
case WidgetType.NOTES:
return <NoteWidget widget={widget} />;
case WidgetType.FILES:
return <FileWidget widget={widget} />;
case WidgetType.EMAILS:
return <EmailWidget widget={widget} />;
case WidgetType.CALENDAR:
return <CalendarWidget widget={widget} />;
default:
return null;
}
@@ -0,0 +1,27 @@
import { EmailsCard } from '@/activities/emails/components/EmailsCard';
import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext';
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;
width: 100%;
`;
type EmailWidgetProps = {
widget: PageLayoutWidget;
};
export const EmailWidget = ({ widget: _widget }: EmailWidgetProps) => {
const { isInRightDrawer } = useLayoutRenderingContext();
return (
<RightDrawerProvider value={{ isInRightDrawer }}>
<StyledContainer>
<EmailsCard />
</StyledContainer>
</RightDrawerProvider>
);
};
@@ -8,6 +8,7 @@ import { type PageLayoutWidget } from '~/generated/graphql';
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
width: 100%;
`;
type FieldsWidgetProps = {
@@ -0,0 +1,27 @@
import { FilesCard } from '@/activities/files/components/FilesCard';
import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext';
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;
width: 100%;
`;
type FileWidgetProps = {
widget: PageLayoutWidget;
};
export const FileWidget = ({ widget: _widget }: FileWidgetProps) => {
const { isInRightDrawer } = useLayoutRenderingContext();
return (
<RightDrawerProvider value={{ isInRightDrawer }}>
<StyledContainer>
<FilesCard />
</StyledContainer>
</RightDrawerProvider>
);
};
@@ -0,0 +1,27 @@
import { NotesCard } from '@/activities/notes/components/NotesCard';
import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext';
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;
width: 100%;
`;
type NoteWidgetProps = {
widget: PageLayoutWidget;
};
export const NoteWidget = ({ widget: _widget }: NoteWidgetProps) => {
const { isInRightDrawer } = useLayoutRenderingContext();
return (
<RightDrawerProvider value={{ isInRightDrawer }}>
<StyledContainer>
<NotesCard />
</StyledContainer>
</RightDrawerProvider>
);
};
@@ -0,0 +1,27 @@
import { TasksCard } from '@/activities/tasks/components/TasksCard';
import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext';
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;
width: 100%;
`;
type TaskWidgetProps = {
widget: PageLayoutWidget;
};
export const TaskWidget = ({ widget: _widget }: TaskWidgetProps) => {
const { isInRightDrawer } = useLayoutRenderingContext();
return (
<RightDrawerProvider value={{ isInRightDrawer }}>
<StyledContainer>
<TasksCard />
</StyledContainer>
</RightDrawerProvider>
);
};
@@ -0,0 +1,27 @@
import { TimelineCard } from '@/activities/timeline-activities/components/TimelineCard';
import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext';
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;
width: 100%;
`;
type TimelineWidgetProps = {
widget: PageLayoutWidget;
};
export const TimelineWidget = ({ widget: _widget }: TimelineWidgetProps) => {
const { isInRightDrawer } = useLayoutRenderingContext();
return (
<RightDrawerProvider value={{ isInRightDrawer }}>
<StyledContainer>
<TimelineCard />
</StyledContainer>
</RightDrawerProvider>
);
};
@@ -1,5 +1,5 @@
import styled from '@emotion/styled';
import { type ReactElement } from 'react';
import { type ReactNode } from 'react';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
@@ -24,7 +24,7 @@ const StyledScrollWrapper = styled(ScrollWrapper)`
`;
export type ShowPageContainerProps = {
children: ReactElement[] | ReactElement;
children: ReactNode;
};
export const ShowPageContainer = ({ children }: ShowPageContainerProps) => {
@@ -0,0 +1,45 @@
import { type MigrationInterface, type QueryRunner } from 'typeorm';
export class AddNewWidgetTypes1760628085765 implements MigrationInterface {
name = 'AddNewWidgetTypes1760628085765';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TYPE "core"."pageLayoutWidget_type_enum" RENAME TO "pageLayoutWidget_type_enum_old"`,
);
await queryRunner.query(
`CREATE TYPE "core"."pageLayoutWidget_type_enum" AS ENUM('VIEW', 'IFRAME', 'FIELDS', 'GRAPH', 'TIMELINE', 'TASKS', 'NOTES', 'FILES', 'EMAILS', 'CALENDAR')`,
);
await queryRunner.query(
`ALTER TABLE "core"."pageLayoutWidget" ALTER COLUMN "type" DROP DEFAULT`,
);
await queryRunner.query(
`ALTER TABLE "core"."pageLayoutWidget" ALTER COLUMN "type" TYPE "core"."pageLayoutWidget_type_enum" USING "type"::"text"::"core"."pageLayoutWidget_type_enum"`,
);
await queryRunner.query(
`ALTER TABLE "core"."pageLayoutWidget" ALTER COLUMN "type" SET DEFAULT 'VIEW'`,
);
await queryRunner.query(
`DROP TYPE "core"."pageLayoutWidget_type_enum_old"`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TYPE "core"."pageLayoutWidget_type_enum_old" AS ENUM('FIELDS', 'GRAPH', 'IFRAME', 'VIEW')`,
);
await queryRunner.query(
`ALTER TABLE "core"."pageLayoutWidget" ALTER COLUMN "type" DROP DEFAULT`,
);
await queryRunner.query(
`ALTER TABLE "core"."pageLayoutWidget" ALTER COLUMN "type" TYPE "core"."pageLayoutWidget_type_enum_old" USING "type"::"text"::"core"."pageLayoutWidget_type_enum_old"`,
);
await queryRunner.query(
`ALTER TABLE "core"."pageLayoutWidget" ALTER COLUMN "type" SET DEFAULT 'VIEW'`,
);
await queryRunner.query(`DROP TYPE "core"."pageLayoutWidget_type_enum"`);
await queryRunner.query(
`ALTER TYPE "core"."pageLayoutWidget_type_enum_old" RENAME TO "pageLayoutWidget_type_enum"`,
);
}
}
@@ -1132,7 +1132,18 @@ export const computeMetadataSchemaComponents = (
title: { type: 'string' },
type: {
type: 'string',
enum: ['VIEW', 'IFRAME', 'FIELDS', 'GRAPH'],
enum: [
'VIEW',
'IFRAME',
'FIELDS',
'GRAPH',
'TIMELINE',
'TASKS',
'NOTES',
'FILES',
'EMAILS',
'CALENDAR',
],
default: 'VIEW',
},
objectMetadataId: { type: 'string', format: 'uuid' },
@@ -3,4 +3,10 @@ export enum WidgetType {
IFRAME = 'IFRAME',
FIELDS = 'FIELDS',
GRAPH = 'GRAPH',
TIMELINE = 'TIMELINE',
TASKS = 'TASKS',
NOTES = 'NOTES',
FILES = 'FILES',
EMAILS = 'EMAILS',
CALENDAR = 'CALENDAR',
}
@@ -268,6 +268,12 @@ export function getValidConfigForWidgetType(widgetType: string): any {
}
case 'VIEW':
case 'FIELDS':
case 'TIMELINE':
case 'TASKS':
case 'NOTES':
case 'FILES':
case 'EMAILS':
case 'CALENDAR':
return null;
default:
return null;