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:
@@ -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>;
|
||||
};
|
||||
+27
@@ -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>
|
||||
);
|
||||
};
|
||||
+36
@@ -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>
|
||||
);
|
||||
};
|
||||
+41
@@ -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.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
+397
@@ -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>
|
||||
),
|
||||
};
|
||||
Reference in New Issue
Block a user