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,34 @@
import styled from '@emotion/styled';
import { type ReactNode } from 'react';
import { isDefined } from 'twenty-shared/utils';
const StyledContainer = styled.div<{ onClick?: () => void }>`
background: ${({ theme }) => theme.background.secondary};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.md};
box-sizing: border-box;
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
position: relative;
padding: ${({ theme }) => theme.spacing(4)};
&:hover {
cursor: ${({ onClick }) => (isDefined(onClick) ? 'pointer' : 'default')};
border: 1px solid ${({ theme }) => theme.color.blue};
background: ${({ theme }) => theme.background.primary};
}
`;
type WidgetContainerProps = {
children?: ReactNode;
onClick?: () => void;
};
export const WidgetContainer = ({
children,
onClick,
}: WidgetContainerProps) => {
return <StyledContainer onClick={onClick}>{children}</StyledContainer>;
};
@@ -0,0 +1,27 @@
import { GraphWidgetRenderer } from '@/page-layout/widgets/graph/components/GraphWidgetRenderer';
import { IframeWidget } from '@/page-layout/widgets/iframe/components/IframeWidget';
import { type Widget } from '@/page-layout/widgets/types/Widget';
import { isString } from '@sniptt/guards';
import { WidgetType } from '~/generated/graphql';
type WidgetContentRendererProps = {
widget: Widget;
};
export const WidgetContentRenderer = ({
widget,
}: WidgetContentRendererProps) => {
switch (widget.type) {
case WidgetType.GRAPH:
return <GraphWidgetRenderer widget={widget} />;
case WidgetType.IFRAME: {
const url = widget.configuration?.url;
return (
<IframeWidget url={isString(url) ? url : ''} title={widget.title} />
);
}
default:
return null;
}
};
@@ -0,0 +1,75 @@
import styled from '@emotion/styled';
import { IconGripVertical, IconPencil, IconX } from 'twenty-ui/display';
import { IconButton } from 'twenty-ui/input';
const StyledHeader = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
`;
const StyledDragHandleButton = styled(IconButton)`
cursor: grab;
display: flex;
align-items: center;
user-select: none;
&:active {
cursor: grabbing;
}
`;
const StyledTitle = styled.span`
color: ${({ theme }) => theme.font.color.secondary};
flex: 1;
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.medium};
user-select: none;
`;
type WidgetHeaderProps = {
displayDragHandle: boolean;
isEmpty?: boolean;
title: string;
onRemove?: () => void;
onEdit?: () => void;
};
export const WidgetHeader = ({
displayDragHandle = false,
isEmpty = false,
title,
onEdit,
onRemove,
}: WidgetHeaderProps) => {
return (
<StyledHeader>
{displayDragHandle && (
<StyledDragHandleButton
Icon={IconGripVertical}
className="drag-handle"
variant="tertiary"
size="small"
disabled={isEmpty}
/>
)}
<StyledTitle>{isEmpty ? 'Add Widget' : title}</StyledTitle>
{!isEmpty && onEdit && (
<IconButton
onClick={onEdit}
Icon={IconPencil}
variant="tertiary"
size="small"
/>
)}
{!isEmpty && onRemove && (
<IconButton
onClick={onRemove}
Icon={IconX}
variant="tertiary"
size="small"
/>
)}
</StyledHeader>
);
};
@@ -0,0 +1,36 @@
import { WidgetContainer } from '@/page-layout/widgets/components/WidgetContainer';
import { WidgetHeader } from '@/page-layout/widgets/components/WidgetHeader';
import {
AnimatedPlaceholder,
AnimatedPlaceholderEmptyContainer,
AnimatedPlaceholderEmptySubTitle,
AnimatedPlaceholderEmptyTextContainer,
AnimatedPlaceholderEmptyTitle,
EMPTY_PLACEHOLDER_TRANSITION_PROPS,
} from 'twenty-ui/layout';
type WidgetPlaceholderProps = {
onClick: () => void;
};
export const WidgetPlaceholder = ({ onClick }: WidgetPlaceholderProps) => {
return (
<WidgetContainer onClick={onClick}>
<WidgetHeader displayDragHandle={true} title="Add Widget" isEmpty />
<AnimatedPlaceholderEmptyContainer
// eslint-disable-next-line react/jsx-props-no-spreading
{...EMPTY_PLACEHOLDER_TRANSITION_PROPS}
>
<AnimatedPlaceholder type="noWidgets" />
<AnimatedPlaceholderEmptyTextContainer>
<AnimatedPlaceholderEmptyTitle>
No widgets yet
</AnimatedPlaceholderEmptyTitle>
<AnimatedPlaceholderEmptySubTitle>
Click to add your first widget
</AnimatedPlaceholderEmptySubTitle>
</AnimatedPlaceholderEmptyTextContainer>
</AnimatedPlaceholderEmptyContainer>
</WidgetContainer>
);
};
@@ -0,0 +1,41 @@
import { WidgetContainer } from '@/page-layout/widgets/components/WidgetContainer';
import { WidgetContentRenderer } from '@/page-layout/widgets/components/WidgetContentRenderer';
import { WidgetHeader } from '@/page-layout/widgets/components/WidgetHeader';
import { type Widget as WidgetType } from '@/page-layout/widgets/types/Widget';
import styled from '@emotion/styled';
type WidgetRendererProps = {
widget: WidgetType;
displayDragHandle?: boolean;
onEdit?: () => void;
onRemove?: () => void;
};
const StyledContent = styled.div`
align-items: center;
display: flex;
height: 100%;
width: 100%;
justify-content: center;
`;
export const WidgetRenderer = ({
widget,
displayDragHandle = false,
onEdit,
onRemove,
}: WidgetRendererProps) => {
return (
<WidgetContainer>
<WidgetHeader
displayDragHandle={displayDragHandle}
title={widget.title}
onEdit={onEdit}
onRemove={onRemove}
/>
<StyledContent>
<WidgetContentRenderer widget={widget} />
</StyledContent>
</WidgetContainer>
);
};
@@ -0,0 +1,41 @@
import { WidgetPlaceholder } from '@/page-layout/widgets/components/WidgetPlaceholder';
import { type Meta, type StoryObj } from '@storybook/react';
import { ComponentDecorator } from 'twenty-ui/testing';
const meta: Meta<typeof WidgetPlaceholder> = {
title: 'Modules/PageLayout/Widgets/WidgetPlaceholder',
component: WidgetPlaceholder,
decorators: [ComponentDecorator],
parameters: {
layout: 'centered',
docs: {
description: {
component:
'A placeholder widget that appears when no widgets are present. Shows an empty state with a call-to-action to add the first widget.',
},
},
},
argTypes: {
onClick: {
action: 'onClick',
description:
'Callback function triggered when the placeholder is clicked',
},
},
};
export default meta;
type Story = StoryObj<typeof WidgetPlaceholder>;
export const Default: Story = {
args: {
onClick: () => {},
},
parameters: {
docs: {
description: {
story: 'Default widget placeholder state.',
},
},
},
};
@@ -0,0 +1,397 @@
import { GraphType } from '@/page-layout/mocks/mockWidgets';
import { WidgetRenderer } from '@/page-layout/widgets/components/WidgetRenderer';
import { type Meta, type StoryObj } from '@storybook/react';
import { ComponentDecorator } from 'twenty-ui/testing';
import { WidgetType } from '~/generated/graphql';
const meta: Meta<typeof WidgetRenderer> = {
title: 'Modules/PageLayout/Widgets/WidgetRenderer',
component: WidgetRenderer,
decorators: [ComponentDecorator],
parameters: {
layout: 'centered',
},
argTypes: {
widget: {
control: 'object',
description: 'Widget',
},
displayDragHandle: {
control: 'boolean',
description: 'Display drag handle',
},
onRemove: {
action: 'onRemove',
},
onEdit: {
action: 'onEdit',
},
},
};
export default meta;
type Story = StoryObj<typeof WidgetRenderer>;
export const WithNumberChart: Story = {
args: {
widget: {
title: 'Sales Pipeline',
type: WidgetType.GRAPH,
configuration: {
graphType: GraphType.NUMBER,
},
gridPosition: {
row: 0,
column: 0,
rowSpan: 2,
columnSpan: 3,
},
id: 'widget-1',
pageLayoutTabId: 'tab-overview',
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
data: {
value: '1,234',
trendPercentage: 12.5,
},
},
displayDragHandle: true,
},
render: (args) => (
<div style={{ width: '300px', height: '100px' }}>
<WidgetRenderer
widget={args.widget}
displayDragHandle={args.displayDragHandle}
onRemove={args.onRemove}
onEdit={args.onEdit}
/>
</div>
),
};
export const WithGaugeChart: Story = {
args: {
widget: {
title: 'Conversion Rate',
type: WidgetType.GRAPH,
configuration: {
graphType: GraphType.GAUGE,
},
data: {
value: 0.5,
min: 0,
max: 1,
label: 'Conversion rate',
},
createdAt: '2024-01-01T00:00:00Z',
gridPosition: {
row: 0,
column: 0,
rowSpan: 5,
columnSpan: 3,
},
id: 'widget-1',
pageLayoutTabId: 'tab-overview',
updatedAt: '2024-01-01T00:00:00Z',
},
},
render: (args) => (
<div style={{ width: '300px', height: '400px' }}>
<WidgetRenderer
widget={args.widget}
displayDragHandle={args.displayDragHandle}
onRemove={args.onRemove}
onEdit={args.onEdit}
/>
</div>
),
};
export const WithPieChart: Story = {
args: {
widget: {
title: 'Lead Distribution',
type: WidgetType.GRAPH,
configuration: {
graphType: GraphType.PIE,
},
data: {
items: [
{
id: 'qualified',
value: 35,
label: 'Qualified',
to: '/leads/qualified',
},
{
id: 'contacted',
value: 25,
label: 'Contacted',
to: '/leads/contacted',
},
{
id: 'unqualified',
value: 20,
label: 'Unqualified',
to: '/leads/unqualified',
},
{
id: 'proposal',
value: 15,
label: 'Proposal',
to: '/leads/proposal',
},
{
id: 'negotiation',
value: 5,
label: 'Negotiation',
to: '/leads/negotiation',
},
],
},
createdAt: '2024-01-01T00:00:00Z',
gridPosition: {
row: 0,
column: 0,
rowSpan: 5,
columnSpan: 3,
},
id: 'widget-1',
pageLayoutTabId: 'tab-overview',
updatedAt: '2024-01-01T00:00:00Z',
},
},
render: (args) => (
<div style={{ width: '300px', height: '500px' }}>
<WidgetRenderer
widget={args.widget}
displayDragHandle={args.displayDragHandle}
onRemove={args.onRemove}
onEdit={args.onEdit}
/>
</div>
),
};
export const SmallWidget: Story = {
args: {
widget: {
title: 'Small Widget (2x2 grid)',
type: WidgetType.GRAPH,
configuration: {
graphType: GraphType.NUMBER,
},
data: {
value: '42',
trendPercentage: 5,
},
createdAt: '2024-01-01T00:00:00Z',
gridPosition: {
row: 0,
column: 0,
rowSpan: 2,
columnSpan: 2,
},
id: 'widget-1',
pageLayoutTabId: 'tab-overview',
updatedAt: '2024-01-01T00:00:00Z',
},
},
parameters: {
docs: {
description: {
story: 'Simulates a 2x2 grid cell widget',
},
},
},
render: (args) => (
<div style={{ width: '300px', height: '100px' }}>
<WidgetRenderer
widget={args.widget}
displayDragHandle={args.displayDragHandle}
onRemove={args.onRemove}
onEdit={args.onEdit}
/>
</div>
),
};
export const MediumWidget: Story = {
args: {
widget: {
title: 'Medium Widget (4x3 grid)',
type: WidgetType.GRAPH,
configuration: {
graphType: GraphType.GAUGE,
},
data: {
value: 0.75,
min: 0,
max: 1,
label: 'Progress',
},
createdAt: '2024-01-01T00:00:00Z',
gridPosition: {
row: 0,
column: 0,
rowSpan: 3,
columnSpan: 4,
},
id: 'widget-1',
pageLayoutTabId: 'tab-overview',
updatedAt: '2024-01-01T00:00:00Z',
},
},
parameters: {
docs: {
description: {
story: 'Simulates a 4x3 grid cell widget',
},
},
},
render: (args) => (
<div style={{ width: '400px', height: '250px' }}>
<WidgetRenderer
widget={args.widget}
displayDragHandle={args.displayDragHandle}
onRemove={args.onRemove}
onEdit={args.onEdit}
/>
</div>
),
};
export const LargeWidget: Story = {
args: {
widget: {
title: 'Large Widget (6x4 grid)',
type: WidgetType.GRAPH,
configuration: {
graphType: GraphType.PIE,
},
data: {
items: [
{ id: 'a', value: 40, label: 'Category A', to: '/a' },
{ id: 'b', value: 30, label: 'Category B', to: '/b' },
{ id: 'c', value: 20, label: 'Category C', to: '/c' },
{ id: 'd', value: 10, label: 'Category D', to: '/d' },
],
},
createdAt: '2024-01-01T00:00:00Z',
gridPosition: {
row: 0,
column: 0,
rowSpan: 4,
columnSpan: 6,
},
id: 'widget-1',
pageLayoutTabId: 'tab-overview',
updatedAt: '2024-01-01T00:00:00Z',
},
},
parameters: {
docs: {
description: {
story: 'Simulates a 6x4 grid cell widget',
},
},
},
render: (args) => (
<div style={{ width: '600px', height: '400px' }}>
<WidgetRenderer
widget={args.widget}
displayDragHandle={args.displayDragHandle}
onRemove={args.onRemove}
onEdit={args.onEdit}
/>
</div>
),
};
export const WideWidget: Story = {
args: {
widget: {
title: 'Wide Widget (8x2 grid)',
type: WidgetType.GRAPH,
configuration: {
graphType: GraphType.NUMBER,
},
data: {
value: '1,234,567',
trendPercentage: 23.4,
},
createdAt: '2024-01-01T00:00:00Z',
gridPosition: {
row: 0,
column: 0,
rowSpan: 2,
columnSpan: 8,
},
id: 'widget-1',
pageLayoutTabId: 'tab-overview',
updatedAt: '2024-01-01T00:00:00Z',
},
},
parameters: {
docs: {
description: {
story: 'Simulates a wide 8x2 grid cell widget',
},
},
},
render: (args) => (
<div style={{ width: '800px', height: '200px' }}>
<WidgetRenderer
widget={args.widget}
displayDragHandle={args.displayDragHandle}
onRemove={args.onRemove}
onEdit={args.onEdit}
/>
</div>
),
};
export const TallWidget: Story = {
args: {
widget: {
title: 'Tall Widget (3x6 grid)',
type: WidgetType.GRAPH,
configuration: {
graphType: GraphType.GAUGE,
},
data: {
value: 0.33,
min: 0,
max: 1,
label: 'Utilization',
},
createdAt: '2024-01-01T00:00:00Z',
gridPosition: {
row: 0,
column: 0,
rowSpan: 6,
columnSpan: 3,
},
id: 'widget-1',
pageLayoutTabId: 'tab-overview',
updatedAt: '2024-01-01T00:00:00Z',
},
},
parameters: {
docs: {
description: {
story: 'Simulates a tall 3x6 grid cell widget',
},
},
},
render: (args) => (
<div style={{ width: '300px', height: '500px' }}>
<WidgetRenderer
widget={args.widget}
displayDragHandle={args.displayDragHandle}
onRemove={args.onRemove}
onEdit={args.onEdit}
/>
</div>
),
};