Create the Dashboard record show page (#14423)

Closes https://github.com/twentyhq/core-team-issues/issues/1438

- Reorganized PageLayout module
- Created `DashboardRenderer` and `PageLayoutRenderer`
- Created stories for the `PageLayoutRenderer`
- Refactored the Widget components


https://github.com/user-attachments/assets/27e9ac8f-b237-4c21-8494-3fab6d65af3a
This commit is contained in:
Raphaël Bosi
2025-09-11 18:26:26 +02:00
committed by GitHub
parent 140b416b22
commit e80c552ae7
124 changed files with 1528 additions and 808 deletions
@@ -0,0 +1,117 @@
import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue';
import { useEffect, useState } from 'react';
import { useRecoilCallback, useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { v4 as uuidv4 } from 'uuid';
import { PageLayoutType } from '~/generated/graphql';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
import {
pageLayoutCurrentLayoutsState,
type TabLayouts,
} from '../states/pageLayoutCurrentLayoutsState';
import { pageLayoutDraftState } from '../states/pageLayoutDraftState';
import { pageLayoutPersistedState } from '../states/pageLayoutPersistedState';
import { savedPageLayoutsState } from '../states/savedPageLayoutsState';
import { type PageLayoutWithData } from '../types/pageLayoutTypes';
type PageLayoutInitializationEffectProps = {
layoutId: string | undefined;
isEditMode: boolean;
pageLayout?: PageLayoutWithData;
};
export const PageLayoutInitializationEffect = ({
layoutId,
isEditMode,
pageLayout,
}: PageLayoutInitializationEffectProps) => {
const [isInitialized, setIsInitialized] = useState(false);
const savedPageLayouts = useRecoilValue(savedPageLayoutsState);
const initializePageLayout = useRecoilCallback(
({ set, snapshot }) =>
(layout: PageLayoutWithData | undefined) => {
const currentPersisted = getSnapshotValue(
snapshot,
pageLayoutPersistedState,
);
if (isDefined(layout)) {
if (!isDeeplyEqual(layout, currentPersisted)) {
set(pageLayoutPersistedState, layout);
set(pageLayoutDraftState, {
name: layout.name,
type: layout.type,
objectMetadataId: layout.objectMetadataId,
tabs: layout.tabs,
});
if (layout.tabs.length > 0) {
const tabLayouts: TabLayouts = {};
layout.tabs.forEach((tab) => {
const layouts = tab.widgets.map((w) => ({
i: w.id,
x: w.gridPosition.column,
y: w.gridPosition.row,
w: w.gridPosition.columnSpan,
h: w.gridPosition.rowSpan,
}));
tabLayouts[tab.id] = {
desktop: layouts,
mobile: layouts.map((l) => ({ ...l, w: 1, x: 0 })),
};
});
set(pageLayoutCurrentLayoutsState, tabLayouts);
} else {
set(pageLayoutCurrentLayoutsState, {});
}
}
} else {
const defaultTab = {
id: `tab-${uuidv4()}`,
title: 'Main',
position: 0,
pageLayoutId: '',
widgets: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
};
set(pageLayoutDraftState, {
name: '',
type: PageLayoutType.DASHBOARD,
objectMetadataId: null,
tabs: [defaultTab],
});
set(pageLayoutPersistedState, undefined);
set(pageLayoutCurrentLayoutsState, {
[defaultTab.id]: { desktop: [], mobile: [] },
});
}
},
[],
);
useEffect(() => {
if (!isInitialized) {
const existingLayout = isEditMode
? savedPageLayouts.find((l) => l.id === layoutId)
: undefined;
const layoutToInitialize = existingLayout || pageLayout;
initializePageLayout(layoutToInitialize);
setIsInitialized(true);
}
}, [
layoutId,
savedPageLayouts,
initializePageLayout,
isInitialized,
isEditMode,
pageLayout,
]);
return null;
};
@@ -0,0 +1,156 @@
import { PageLayoutInitializationEffect } from '@/page-layout/components/PageLayoutInitializationEffect';
import { EMPTY_LAYOUT } from '@/page-layout/constants/EmptyLayout';
import {
PAGE_LAYOUT_CONFIG,
type PageLayoutBreakpoint,
} from '@/page-layout/constants/PageLayoutBreakpoints';
import { pageLayoutCurrentBreakpointState } from '@/page-layout/states/pageLayoutCurrentBreakpointState';
import { pageLayoutCurrentLayoutsState } from '@/page-layout/states/pageLayoutCurrentLayoutsState';
import { WidgetRenderer } from '@/page-layout/widgets/components/WidgetRenderer';
import { type Widget } from '@/page-layout/widgets/types/Widget';
import { TabList } from '@/ui/layout/tab-list/components/TabList';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { TabListComponentInstanceContext } from '@/ui/layout/tab-list/states/contexts/TabListComponentInstanceContext';
import { type SingleTabProps } from '@/ui/layout/tab-list/types/SingleTabProps';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import styled from '@emotion/styled';
import { useMemo } from 'react';
import {
Responsive,
WidthProvider,
type ResponsiveProps,
} from 'react-grid-layout';
import 'react-grid-layout/css/styles.css';
import 'react-resizable/css/styles.css';
import { useRecoilState, useRecoilValue } from 'recoil';
import { type PageLayoutWithData } from '../types/pageLayoutTypes';
const StyledGridContainer = styled.div`
background: ${({ theme }) => theme.background.secondary};
box-sizing: border-box;
flex: 1;
min-height: 100%;
overflow-y: auto;
overflow-x: hidden;
position: relative;
padding: ${({ theme }) => theme.spacing(2)};
width: 100%;
user-select: none;
.react-grid-placeholder {
background: ${({ theme }) => theme.adaptiveColors.blue3} !important;
border-radius: ${({ theme }) => theme.border.radius.sm};
}
.react-grid-item:not(.react-draggable-dragging) {
user-select: auto;
}
`;
const StyledTabList = styled(TabList)`
padding-left: ${({ theme }) => theme.spacing(2)};
`;
type ExtendedResponsiveProps = ResponsiveProps & {
maxCols?: number;
preventCollision?: boolean;
};
const ResponsiveGridLayout = WidthProvider(
Responsive,
) as React.ComponentType<ExtendedResponsiveProps>;
type PageLayoutRendererProps = {
pageLayout: PageLayoutWithData;
};
type PageLayoutRendererContentProps = {
pageLayout: PageLayoutWithData;
};
const PageLayoutRendererContent = ({
pageLayout,
}: PageLayoutRendererContentProps) => {
const [, setPageLayoutCurrentBreakpoint] = useRecoilState(
pageLayoutCurrentBreakpointState,
);
const pageLayoutCurrentLayouts = useRecoilValue(
pageLayoutCurrentLayoutsState,
);
const activeTabId = useRecoilComponentValue(activeTabIdComponentState);
const activeTabWidgets = pageLayout.tabs.find(
(tab) => tab.id === activeTabId,
)?.widgets;
const tabListTabs: SingleTabProps[] = useMemo(() => {
return [...pageLayout.tabs]
.sort((a, b) => a.position - b.position)
.map((tab) => ({
id: tab.id,
title: tab.title,
}));
}, [pageLayout.tabs]);
return (
<>
<PageLayoutInitializationEffect
layoutId={pageLayout.id}
isEditMode={false}
pageLayout={pageLayout}
/>
{pageLayout.tabs.length > 0 && (
<StyledTabList
tabs={tabListTabs}
behaveAsLinks={false}
componentInstanceId={pageLayout.id}
/>
)}
<StyledGridContainer>
<ResponsiveGridLayout
className="layout"
layouts={
!activeTabId
? EMPTY_LAYOUT
: pageLayoutCurrentLayouts[activeTabId] || EMPTY_LAYOUT
}
breakpoints={PAGE_LAYOUT_CONFIG.breakpoints}
cols={PAGE_LAYOUT_CONFIG.columns}
rowHeight={55}
maxCols={12}
containerPadding={[0, 0]}
margin={[8, 8]}
isDraggable={false}
isResizable={false}
draggableHandle=".drag-handle"
compactType="vertical"
preventCollision={false}
onBreakpointChange={(newBreakpoint) =>
setPageLayoutCurrentBreakpoint(
newBreakpoint as PageLayoutBreakpoint,
)
}
>
{activeTabWidgets?.map((widget) => (
<div key={widget.id} data-select-disable="true">
<WidgetRenderer widget={widget as Widget} />
</div>
))}
</ResponsiveGridLayout>
</StyledGridContainer>
</>
);
};
export const PageLayoutRenderer = ({ pageLayout }: PageLayoutRendererProps) => {
return (
<TabListComponentInstanceContext.Provider
value={{ instanceId: pageLayout.id }}
>
<PageLayoutRendererContent pageLayout={pageLayout} />
</TabListComponentInstanceContext.Provider>
);
};
@@ -0,0 +1,224 @@
import type { Meta, StoryObj } from '@storybook/react';
import { expect, within } from '@storybook/test';
import { MemoryRouter } from 'react-router-dom';
import { PageLayoutRenderer } from '@/page-layout/components/PageLayoutRenderer';
import { GraphType, WidgetType } from '@/page-layout/mocks/mockWidgets';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { TabListComponentInstanceContext } from '@/ui/layout/tab-list/states/contexts/TabListComponentInstanceContext';
import { RecoilRoot } from 'recoil';
import { PageLayoutType } from '~/generated/graphql';
import { type PageLayoutWidgetWithData } from '../../types/pageLayoutTypes';
const validatePageLayoutContent = async (canvasElement: HTMLElement) => {
const canvas = within(canvasElement);
const revenueElements = await canvas.findAllByText('Revenue');
await expect(revenueElements).toHaveLength(2);
const goalProgressElements = await canvas.findAllByText('Goal Progress');
await expect(goalProgressElements).toHaveLength(2);
await expect(await canvas.findByText('Revenue Sources')).toBeVisible();
await expect(await canvas.findByText('Quarterly Comparison')).toBeVisible();
await expect(await canvas.findByText('$125,000')).toBeVisible();
await expect(await canvas.findByText('Product Sales')).toBeVisible();
await expect(await canvas.findByText('Services')).toBeVisible();
await expect(await canvas.findByText('Support')).toBeVisible();
};
const mixedGraphsPageLayout = {
id: 'mixed-graphs-layout',
name: 'Mixed Graph Dashboard',
type: PageLayoutType.DASHBOARD,
objectMetadataId: null,
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
deletedAt: null,
tabs: [
{
id: 'mixed-tab',
title: 'Mixed Graphs',
position: 0,
pageLayoutId: 'mixed-graphs-layout',
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
deletedAt: null,
widgets: [
{
id: 'number-widget',
pageLayoutTabId: 'mixed-tab',
type: WidgetType.GRAPH,
title: 'Revenue',
objectMetadataId: null,
gridPosition: {
row: 0,
column: 0,
rowSpan: 2,
columnSpan: 3,
},
configuration: {
graphType: GraphType.NUMBER,
},
data: {
value: '$125,000',
trendPercentage: 8.3,
},
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
deletedAt: null,
} as PageLayoutWidgetWithData,
{
id: 'gauge-widget',
pageLayoutTabId: 'mixed-tab',
type: WidgetType.GRAPH,
title: 'Goal Progress',
objectMetadataId: null,
gridPosition: {
row: 0,
column: 3,
rowSpan: 4,
columnSpan: 3,
},
configuration: {
graphType: GraphType.GAUGE,
},
data: {
value: 0.75,
min: 0,
max: 1,
label: 'Goal Progress',
},
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
deletedAt: null,
} as PageLayoutWidgetWithData,
{
id: 'pie-widget',
pageLayoutTabId: 'mixed-tab',
type: WidgetType.GRAPH,
title: 'Revenue Sources',
objectMetadataId: null,
gridPosition: {
row: 0,
column: 6,
rowSpan: 4,
columnSpan: 3,
},
configuration: {
graphType: GraphType.PIE,
},
data: {
items: [
{ id: 'product', value: 60, label: 'Product Sales' },
{ id: 'services', value: 30, label: 'Services' },
{ id: 'support', value: 10, label: 'Support' },
],
},
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
deletedAt: null,
} as PageLayoutWidgetWithData,
{
id: 'bar-widget',
pageLayoutTabId: 'mixed-tab',
type: WidgetType.GRAPH,
title: 'Quarterly Comparison',
objectMetadataId: null,
gridPosition: {
row: 2,
column: 0,
rowSpan: 4,
columnSpan: 6,
},
configuration: {
graphType: GraphType.BAR,
},
data: {
items: [
{ quarter: 'Q1', revenue: 100000, expenses: 80000 },
{ quarter: 'Q2', revenue: 125000, expenses: 90000 },
{ quarter: 'Q3', revenue: 150000, expenses: 95000 },
{ quarter: 'Q4', revenue: 180000, expenses: 100000 },
],
indexBy: 'quarter',
keys: ['revenue', 'expenses'],
layout: 'vertical',
seriesLabels: {
revenue: 'Revenue',
expenses: 'Expenses',
},
},
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
deletedAt: null,
} as PageLayoutWidgetWithData,
],
},
],
};
const meta: Meta<typeof PageLayoutRenderer> = {
title: 'Modules/PageLayout/PageLayoutRenderer',
component: PageLayoutRenderer,
decorators: [
(Story, { args }: { args: any }) => (
<MemoryRouter>
<RecoilRoot
initializeState={({ set }) => {
set(
activeTabIdComponentState.atomFamily({
instanceId: 'page-layout-stories',
}),
args.activeTabId,
);
}}
>
<TabListComponentInstanceContext.Provider
value={{ instanceId: 'page-layout-stories' }}
>
<Story />
</TabListComponentInstanceContext.Provider>
</RecoilRoot>
</MemoryRouter>
),
],
parameters: {
layout: 'fullscreen',
},
args: {
pageLayout: mixedGraphsPageLayout,
},
};
export default meta;
type Story = StoryObj<typeof meta>;
export const DesktopView: Story = {
args: {
activeTabId: 'mixed-tab',
},
parameters: {
viewport: {
defaultViewport: 'desktop1',
},
},
play: async ({ canvasElement }) => {
await validatePageLayoutContent(canvasElement);
},
};
export const MobileView: Story = {
args: {
activeTabId: 'mixed-tab',
},
parameters: {
viewport: {
defaultViewport: 'mobile1',
},
},
play: async ({ canvasElement }) => {
await validatePageLayoutContent(canvasElement);
},
};