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
@@ -2547,6 +2547,7 @@ export type PageLayoutTab = {
position: Scalars['Float'];
title: Scalars['String'];
updatedAt: Scalars['DateTime'];
widgets?: Maybe<Array<PageLayoutWidget>>;
};
export enum PageLayoutType {
@@ -2458,6 +2458,7 @@ export type PageLayoutTab = {
position: Scalars['Float'];
title: Scalars['String'];
updatedAt: Scalars['DateTime'];
widgets?: Maybe<Array<PageLayoutWidget>>;
};
export enum PageLayoutType {
@@ -1,9 +1,6 @@
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { useCreatePageLayoutWidget } from '@/settings/page-layout/hooks/useCreatePageLayoutWidget';
import {
GraphType,
WidgetType,
} from '@/settings/page-layout/mocks/mockWidgets';
import { useCreatePageLayoutWidget } from '@/page-layout/hooks/useCreatePageLayoutWidget';
import { GraphType, WidgetType } from '@/page-layout/mocks/mockWidgets';
import styled from '@emotion/styled';
import {
@@ -1,9 +1,9 @@
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { FormTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormTextFieldInput';
import { useCreatePageLayoutIframeWidget } from '@/settings/page-layout/hooks/useCreatePageLayoutIframeWidget';
import { useUpdatePageLayoutWidget } from '@/settings/page-layout/hooks/useUpdatePageLayoutWidget';
import { pageLayoutDraftState } from '@/settings/page-layout/states/pageLayoutDraftState';
import { pageLayoutEditingWidgetIdState } from '@/settings/page-layout/states/pageLayoutEditingWidgetIdState';
import { useCreatePageLayoutIframeWidget } from '@/page-layout/hooks/useCreatePageLayoutIframeWidget';
import { useUpdatePageLayoutWidget } from '@/page-layout/hooks/useUpdatePageLayoutWidget';
import { pageLayoutDraftState } from '@/page-layout/states/pageLayoutDraftState';
import { pageLayoutEditingWidgetIdState } from '@/page-layout/states/pageLayoutEditingWidgetIdState';
import styled from '@emotion/styled';
import { isString } from '@sniptt/guards';
import { useState } from 'react';
@@ -1,7 +1,7 @@
import { useNavigateCommandMenu } from '@/command-menu/hooks/useNavigateCommandMenu';
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import { WidgetType } from '@/settings/page-layout/mocks/mockWidgets';
import { pageLayoutDraggedAreaState } from '@/settings/page-layout/states/pageLayoutDraggedAreaState';
import { WidgetType } from '@/page-layout/mocks/mockWidgets';
import { pageLayoutDraggedAreaState } from '@/page-layout/states/pageLayoutDraggedAreaState';
import styled from '@emotion/styled';
import { useSetRecoilState } from 'recoil';
import { IconChartPie, IconFrame, IconList } from 'twenty-ui/display';
@@ -0,0 +1,36 @@
import { type Dashboard } from '@/dashboards/components/types/Dashboard';
import { FIND_ONE_PAGE_LAYOUT } from '@/dashboards/graphql/queries/findOnePageLayout';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { PageLayoutRenderer } from '@/page-layout/components/PageLayoutRenderer';
import { normalizePageLayoutData } from '@/page-layout/utils/normalizePageLayoutData';
import { useQuery } from '@apollo/client';
type DashboardRendererProps = {
recordId: string;
};
export const DashboardRenderer = ({ recordId }: DashboardRendererProps) => {
const { record: dashboard, loading: dashboardLoading } =
useFindOneRecord<Dashboard>({
objectNameSingular: CoreObjectNameSingular.Dashboard,
objectRecordId: recordId,
});
const { data } = useQuery(FIND_ONE_PAGE_LAYOUT, {
variables: {
id: dashboard?.pageLayoutId,
},
skip: dashboardLoading || !dashboard?.pageLayoutId,
});
const pageLayout = data?.getPageLayout;
if (!pageLayout) {
return null;
}
const normalizedPageLayout = normalizePageLayoutData(pageLayout);
return <PageLayoutRenderer pageLayout={normalizedPageLayout} />;
};
@@ -0,0 +1,6 @@
export type Dashboard = {
__typename: 'Dashboard';
id: string;
title: string;
pageLayoutId: string;
};
@@ -0,0 +1,15 @@
import { PAGE_LAYOUT_TAB_FRAGMENT } from '@/dashboards/graphql/fragments/pageLayoutTabFragment';
import { gql } from '@apollo/client';
export const PAGE_LAYOUT_FRAGMENT = gql`
${PAGE_LAYOUT_TAB_FRAGMENT}
fragment PageLayoutFragment on PageLayout {
id
name
objectMetadataId
type
tabs {
...PageLayoutTabFragment
}
}
`;
@@ -0,0 +1,14 @@
import { PAGE_LAYOUT_WIDGET_FRAGMENT } from '@/dashboards/graphql/fragments/pageLayoutWidgetFragment';
import { gql } from '@apollo/client';
export const PAGE_LAYOUT_TAB_FRAGMENT = gql`
${PAGE_LAYOUT_WIDGET_FRAGMENT}
fragment PageLayoutTabFragment on PageLayoutTab {
id
title
position
widgets {
...PageLayoutWidgetFragment
}
}
`;
@@ -0,0 +1,17 @@
import { gql } from '@apollo/client';
export const PAGE_LAYOUT_WIDGET_FRAGMENT = gql`
fragment PageLayoutWidgetFragment on PageLayoutWidget {
id
title
type
objectMetadataId
gridPosition {
column
columnSpan
row
rowSpan
}
configuration
}
`;
@@ -0,0 +1,11 @@
import { PAGE_LAYOUT_FRAGMENT } from '@/dashboards/graphql/fragments/pageLayoutFragment';
import { gql } from '@apollo/client';
export const FIND_ONE_PAGE_LAYOUT = gql`
${PAGE_LAYOUT_FRAGMENT}
query FindOnePageLayout($id: String!) {
getPageLayout(id: $id) {
...PageLayoutFragment
}
}
`;
@@ -6,6 +6,7 @@ import { Notes } from '@/activities/notes/components/Notes';
import { ObjectTasks } from '@/activities/tasks/components/ObjectTasks';
import { TimelineActivities } from '@/activities/timeline-activities/components/TimelineActivities';
import { type ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { DashboardRenderer } from '@/dashboards/components/DashboardRenderer';
import { FieldsCard } from '@/object-record/record-show/components/FieldsCard';
import { CardType } from '@/object-record/record-show/types/CardType';
import { ListenRecordUpdatesEffect } from '@/subscription/components/ListenRecordUpdatesEffect';
@@ -212,4 +213,8 @@ export const CardComponents: Record<CardType, CardComponentType> = {
</WorkflowVisualizerComponentInstanceContext.Provider>
);
},
[CardType.DashboardCard]: ({ targetableObject }) => {
return <DashboardRenderer recordId={targetableObject.id} />;
},
};
@@ -16,6 +16,7 @@ import { isDefined } from 'twenty-shared/utils';
import {
IconCalendarEvent,
IconHome,
IconLayoutDashboard,
IconMail,
IconNotes,
IconSettings,
@@ -243,6 +244,30 @@ export const useRecordShowContainerTabs = (
files: null,
},
},
[CoreObjectNameSingular.Dashboard]: {
hideSummaryAndFields: true,
hideFieldsInSidePanel: true,
tabs: {
dashboard: {
title: 'Dashboard',
position: 101,
Icon: IconLayoutDashboard,
cards: [{ type: CardType.DashboardCard }],
hide: {
ifMobile: false,
ifDesktop: false,
ifInRightDrawer: false,
ifFeaturesDisabled: [],
ifRequiredObjectsInactive: [],
ifRelationsMissing: [],
},
},
timeline: null,
tasks: null,
notes: null,
files: null,
},
},
}),
[],
);
@@ -277,7 +302,9 @@ export const useRecordShowContainerTabs = (
title,
Icon,
cards,
hide: !(isMobile || isInRightDrawer),
hide:
!(isMobile || isInRightDrawer) ||
recordLayout.hideFieldsInSidePanel,
};
}
@@ -349,7 +376,10 @@ export const useRecordShowContainerTabs = (
id: 'home',
title: 'Home',
Icon: IconHome,
cards: [...tab.cards, ...array[1].cards],
cards: [
...(tab.hide ? [] : tab.cards),
...(array[1].hide ? [] : array[1].cards),
],
hide: false,
},
];
@@ -10,4 +10,5 @@ export enum CardType {
WorkflowVersionCard = 'WorkflowVersionCard',
WorkflowRunCard = 'WorkflowRunCard',
RichTextCard = 'RichTextCard',
DashboardCard = 'DashboardCard',
}
@@ -2,5 +2,6 @@ import { type RecordLayoutTab } from '@/ui/layout/tab-list/types/RecordLayoutTab
export type RecordLayout = {
hideSummaryAndFields?: boolean;
hideFieldsInSidePanel?: boolean;
tabs: Record<string, RecordLayoutTab | null>;
};
@@ -3,6 +3,7 @@ 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,
@@ -10,27 +11,26 @@ import {
} from '../states/pageLayoutCurrentLayoutsState';
import { pageLayoutDraftState } from '../states/pageLayoutDraftState';
import { pageLayoutPersistedState } from '../states/pageLayoutPersistedState';
import {
PageLayoutType,
savedPageLayoutsState,
type SavedPageLayout,
} from '../states/savedPageLayoutsState';
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: SavedPageLayout | undefined) => {
(layout: PageLayoutWithData | undefined) => {
const currentPersisted = getSnapshotValue(
snapshot,
pageLayoutPersistedState,
@@ -98,7 +98,10 @@ export const PageLayoutInitializationEffect = ({
const existingLayout = isEditMode
? savedPageLayouts.find((l) => l.id === layoutId)
: undefined;
initializePageLayout(existingLayout);
const layoutToInitialize = existingLayout || pageLayout;
initializePageLayout(layoutToInitialize);
setIsInitialized(true);
}
}, [
@@ -107,6 +110,7 @@ export const PageLayoutInitializationEffect = ({
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);
},
};
@@ -1,5 +1,5 @@
import { pageLayoutCurrentLayoutsState } from '@/settings/page-layout/states/pageLayoutCurrentLayoutsState';
import { pageLayoutDraftState } from '@/settings/page-layout/states/pageLayoutDraftState';
import { pageLayoutCurrentLayoutsState } from '@/page-layout/states/pageLayoutCurrentLayoutsState';
import { pageLayoutDraftState } from '@/page-layout/states/pageLayoutDraftState';
import { act, renderHook } from '@testing-library/react';
import { RecoilRoot, useRecoilValue } from 'recoil';
import { useCreatePageLayoutTab } from '../useCreatePageLayoutTab';
@@ -1,14 +1,11 @@
import { SETTINGS_PAGE_LAYOUT_TABS_INSTANCE_ID } from '@/settings/page-layout/constants/SettingsPageLayoutTabsInstanceId';
import {
GraphType,
WidgetType,
} from '@/settings/page-layout/mocks/mockWidgets';
import { pageLayoutCurrentLayoutsState } from '@/settings/page-layout/states/pageLayoutCurrentLayoutsState';
import { pageLayoutDraftState } from '@/settings/page-layout/states/pageLayoutDraftState';
import { PageLayoutType } from '@/settings/page-layout/states/savedPageLayoutsState';
import { SETTINGS_PAGE_LAYOUT_TABS_INSTANCE_ID } from '@/page-layout/constants/SettingsPageLayoutTabsInstanceId';
import { GraphType, WidgetType } from '@/page-layout/mocks/mockWidgets';
import { pageLayoutCurrentLayoutsState } from '@/page-layout/states/pageLayoutCurrentLayoutsState';
import { pageLayoutDraftState } from '@/page-layout/states/pageLayoutDraftState';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { act, renderHook } from '@testing-library/react';
import { RecoilRoot, useRecoilValue, useSetRecoilState } from 'recoil';
import { PageLayoutType } from '~/generated/graphql';
import { useCreatePageLayoutWidget } from '../useCreatePageLayoutWidget';
jest.mock('uuid', () => ({
@@ -158,9 +155,8 @@ describe('useCreatePageLayoutWidget', () => {
const widget = result.current.allWidgets[index];
expect(widget.type).toBe(WidgetType.GRAPH);
expect(widget.pageLayoutTabId).toBe('tab-1');
expect(widget.configuration?.graphType).toBe(graphType);
expect(widget.configuration.graphType).toBe(graphType);
expect(widget.id).toBe('widget-mock-uuid');
expect(widget.data).toBeDefined();
});
expect(result.current.pageLayoutCurrentLayouts['tab-1']).toBeDefined();
@@ -1,10 +1,7 @@
import {
GraphType,
WidgetType,
} from '@/settings/page-layout/mocks/mockWidgets';
import { PageLayoutType } from '@/settings/page-layout/states/savedPageLayoutsState';
import { GraphType, WidgetType } from '@/page-layout/mocks/mockWidgets';
import { act, renderHook } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import { PageLayoutType } from '~/generated/graphql';
import { usePageLayoutDraftState } from '../usePageLayoutDraftState';
describe('usePageLayoutDraftState', () => {
@@ -1,16 +1,16 @@
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { SETTINGS_PAGE_LAYOUT_TABS_INSTANCE_ID } from '@/page-layout/constants/SettingsPageLayoutTabsInstanceId';
import { pageLayoutCurrentLayoutsState } from '@/page-layout/states/pageLayoutCurrentLayoutsState';
import { pageLayoutDraftState } from '@/page-layout/states/pageLayoutDraftState';
import { pageLayoutDraggedAreaState } from '@/page-layout/states/pageLayoutDraggedAreaState';
import { type PageLayoutWidgetWithData } from '@/page-layout/types/pageLayoutTypes';
import { addWidgetToTab } from '@/page-layout/utils/addWidgetToTab';
import { createUpdatedTabLayouts } from '@/page-layout/utils/createUpdatedTabLayouts';
import { getDefaultWidgetPosition } from '@/page-layout/utils/getDefaultWidgetPosition';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useRecoilCallback } from 'recoil';
import { v4 as uuidv4 } from 'uuid';
import { SETTINGS_PAGE_LAYOUT_TABS_INSTANCE_ID } from '../constants/SettingsPageLayoutTabsInstanceId';
import { WidgetType } from '../mocks/mockWidgets';
import { pageLayoutCurrentLayoutsState } from '../states/pageLayoutCurrentLayoutsState';
import { pageLayoutDraftState } from '../states/pageLayoutDraftState';
import { pageLayoutDraggedAreaState } from '../states/pageLayoutDraggedAreaState';
import { type PageLayoutWidget } from '../states/savedPageLayoutsState';
import { addWidgetToTab } from '../utils/addWidgetToTab';
import { createUpdatedTabLayouts } from '../utils/createUpdatedTabLayouts';
import { getDefaultWidgetPosition } from '../utils/getDefaultWidgetPosition';
import { WidgetType } from '~/generated/graphql';
export const useCreatePageLayoutIframeWidget = () => {
const activeTabId = useRecoilComponentValue(
@@ -39,7 +39,7 @@ export const useCreatePageLayoutIframeWidget = () => {
defaultSize,
);
const newWidget: PageLayoutWidget = {
const newWidget: PageLayoutWidgetWithData = {
id: widgetId,
pageLayoutTabId: activeTabId,
title,
@@ -53,6 +53,7 @@ export const useCreatePageLayoutIframeWidget = () => {
configuration: {
url,
},
data: {},
objectMetadataId: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -2,7 +2,7 @@ import { useRecoilCallback } from 'recoil';
import { v4 as uuidv4 } from 'uuid';
import { pageLayoutCurrentLayoutsState } from '../states/pageLayoutCurrentLayoutsState';
import { pageLayoutDraftState } from '../states/pageLayoutDraftState';
import { type PageLayoutTab } from '../states/savedPageLayoutsState';
import { type PageLayoutTabWithData } from '../types/pageLayoutTypes';
import { createEmptyTabLayout } from '../utils/createEmptyTabLayout';
export const useCreatePageLayoutTab = () => {
@@ -14,10 +14,11 @@ export const useCreatePageLayoutTab = () => {
.getValue();
const newTabId = `tab-${uuidv4()}`;
const newTab: PageLayoutTab = {
const tabsLength = pageLayoutDraft.tabs.length;
const newTab: PageLayoutTabWithData = {
id: newTabId,
title: title || `Tab ${pageLayoutDraft.tabs.length + 1}`,
position: pageLayoutDraft.tabs.length,
title: title || `Tab ${tabsLength + 1}`,
position: tabsLength,
pageLayoutId: '',
widgets: [],
createdAt: new Date().toISOString(),
@@ -25,7 +26,7 @@ export const useCreatePageLayoutTab = () => {
deletedAt: null,
};
const updatedTabs = [...pageLayoutDraft.tabs, newTab];
const updatedTabs = [...(pageLayoutDraft.tabs || []), newTab];
set(pageLayoutDraftState, (prev) => ({
...prev,
@@ -1,21 +1,22 @@
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useRecoilCallback } from 'recoil';
import { v4 as uuidv4 } from 'uuid';
import { SETTINGS_PAGE_LAYOUT_TABS_INSTANCE_ID } from '../constants/SettingsPageLayoutTabsInstanceId';
import { type GraphType, type WidgetType } from '../mocks/mockWidgets';
import { pageLayoutCurrentLayoutsState } from '../states/pageLayoutCurrentLayoutsState';
import { pageLayoutDraftState } from '../states/pageLayoutDraftState';
import { pageLayoutDraggedAreaState } from '../states/pageLayoutDraggedAreaState';
import { type PageLayoutWidget } from '../states/savedPageLayoutsState';
import { addWidgetToTab } from '../utils/addWidgetToTab';
import { createUpdatedTabLayouts } from '../utils/createUpdatedTabLayouts';
import { SETTINGS_PAGE_LAYOUT_TABS_INSTANCE_ID } from '@/page-layout/constants/SettingsPageLayoutTabsInstanceId';
import { type GraphType } from '@/page-layout/mocks/mockWidgets';
import { pageLayoutCurrentLayoutsState } from '@/page-layout/states/pageLayoutCurrentLayoutsState';
import { pageLayoutDraftState } from '@/page-layout/states/pageLayoutDraftState';
import { pageLayoutDraggedAreaState } from '@/page-layout/states/pageLayoutDraggedAreaState';
import { type PageLayoutWidgetWithData } from '@/page-layout/types/pageLayoutTypes';
import { addWidgetToTab } from '@/page-layout/utils/addWidgetToTab';
import { createUpdatedTabLayouts } from '@/page-layout/utils/createUpdatedTabLayouts';
import {
getDefaultWidgetData,
getWidgetSize,
getWidgetTitle,
} from '../utils/getDefaultWidgetData';
import { getDefaultWidgetPosition } from '../utils/getDefaultWidgetPosition';
} from '@/page-layout/utils/getDefaultWidgetData';
import { getDefaultWidgetPosition } from '@/page-layout/utils/getDefaultWidgetPosition';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useRecoilCallback } from 'recoil';
import { v4 as uuidv4 } from 'uuid';
import { type WidgetType } from '~/generated/graphql';
export const useCreatePageLayoutWidget = () => {
const activeTabId = useRecoilComponentValue(
@@ -45,7 +46,7 @@ export const useCreatePageLayoutWidget = () => {
const allWidgets = pageLayoutDraft.tabs.flatMap((tab) => tab.widgets);
const existingWidgetCount = allWidgets.filter(
(w) =>
w.type === widgetType && w.configuration?.graphType === graphType,
w.type === widgetType && w.configuration.graphType === graphType,
).length;
const title = getWidgetTitle(graphType, existingWidgetCount);
const widgetId = `widget-${uuidv4()}`;
@@ -56,7 +57,7 @@ export const useCreatePageLayoutWidget = () => {
defaultSize,
);
const newWidget: PageLayoutWidget = {
const newWidget: PageLayoutWidgetWithData = {
id: widgetId,
pageLayoutTabId: activeTabId,
title,
@@ -70,7 +71,7 @@ export const useCreatePageLayoutWidget = () => {
configuration: {
graphType,
},
data: widgetData,
data: widgetData as Record<string, unknown>,
objectMetadataId: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -3,7 +3,7 @@ import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { pageLayoutCurrentLayoutsState } from '../states/pageLayoutCurrentLayoutsState';
import { pageLayoutDraftState } from '../states/pageLayoutDraftState';
import { type PageLayoutWidget } from '../states/savedPageLayoutsState';
import { type PageLayoutWidgetWithData } from '../types/pageLayoutTypes';
import { convertLayoutsToWidgets } from '../utils/convertLayoutsToWidgets';
export const usePageLayoutHandleLayoutChange = (activeTabId: string | null) => {
@@ -38,7 +38,7 @@ export const usePageLayoutHandleLayoutChange = (activeTabId: string | null) => {
...prev,
tabs: prev.tabs.map((tab) => {
if (tab.id === activeTabId) {
const tabWidgets: PageLayoutWidget[] = updatedWidgets
const tabWidgets: PageLayoutWidgetWithData[] = updatedWidgets
.filter((w) => w.pageLayoutTabId === activeTabId)
.map((widget) => ({
id: widget.id,
@@ -1,3 +1,8 @@
import { savedPageLayoutsState } from '@/page-layout/states/savedPageLayoutsState';
import {
type PageLayoutWidgetWithData,
type PageLayoutWithData,
} from '@/page-layout/types/pageLayoutTypes';
import { useParams } from 'react-router-dom';
import { useRecoilCallback } from 'recoil';
import { SettingsPath } from 'twenty-shared/types';
@@ -6,11 +11,6 @@ import { v4 as uuidv4 } from 'uuid';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { pageLayoutDraftState } from '../states/pageLayoutDraftState';
import { pageLayoutPersistedState } from '../states/pageLayoutPersistedState';
import {
savedPageLayoutsState,
type PageLayoutWidget,
type SavedPageLayout,
} from '../states/savedPageLayoutsState';
export const usePageLayoutSaveHandler = () => {
const navigateSettings = useNavigateSettings();
@@ -19,7 +19,7 @@ export const usePageLayoutSaveHandler = () => {
const savePageLayout = useRecoilCallback(
({ snapshot, set }) =>
async (widgetsWithPositions?: PageLayoutWidget[]) => {
async (widgetsWithPositions?: PageLayoutWidgetWithData[]) => {
const pageLayoutDraft = snapshot
.getLoadable(pageLayoutDraftState)
.getValue();
@@ -40,7 +40,7 @@ export const usePageLayoutSaveHandler = () => {
}))
: pageLayoutDraft.tabs;
const layoutToSave: SavedPageLayout = {
const layoutToSave: PageLayoutWithData = {
id: isEditMode ? id : uuidv4(),
name: pageLayoutDraft.name,
type: pageLayoutDraft.type,
@@ -1,11 +1,11 @@
import { pageLayoutDraftState } from '@/page-layout/states/pageLayoutDraftState';
import { type PageLayoutWidgetWithData } from '@/page-layout/types/pageLayoutTypes';
import { useRecoilCallback } from 'recoil';
import { type PageLayoutWidget } from '../states/savedPageLayoutsState';
import { pageLayoutDraftState } from '../states/pageLayoutDraftState';
export const useUpdatePageLayoutWidget = () => {
const updatePageLayoutWidget = useRecoilCallback(
({ set }) =>
(widgetId: string, updates: Partial<PageLayoutWidget>) => {
(widgetId: string, updates: Partial<PageLayoutWidgetWithData>) => {
set(pageLayoutDraftState, (prev) => ({
...prev,
tabs: prev.tabs.map((tab) => ({
@@ -1,5 +1,5 @@
import { type Layouts } from 'react-grid-layout';
import { type PageLayoutWidget } from '../states/savedPageLayoutsState';
import { type PageLayoutWidgetWithData } from '../types/pageLayoutTypes';
export enum WidgetType {
VIEW = 'VIEW',
@@ -16,7 +16,7 @@ export enum GraphType {
LINE = 'LINE',
}
export const mockPageLayoutWidgets: PageLayoutWidget[] = [
export const mockPageLayoutWidgets: PageLayoutWidgetWithData[] = [
{
id: 'widget-1',
pageLayoutTabId: 'tab-overview',
@@ -1,8 +1,9 @@
import { createState } from 'twenty-ui/utilities';
import { PageLayoutType, type SavedPageLayout } from './savedPageLayoutsState';
import { PageLayoutType } from '~/generated/graphql';
import { type PageLayoutWithData } from '../types/pageLayoutTypes';
export type DraftPageLayout = Omit<
SavedPageLayout,
PageLayoutWithData,
'id' | 'createdAt' | 'updatedAt' | 'deletedAt'
>;
@@ -1,8 +1,8 @@
import { createState } from 'twenty-ui/utilities';
import { type SavedPageLayout } from './savedPageLayoutsState';
import { type PageLayoutWithData } from '../types/pageLayoutTypes';
export const pageLayoutPersistedState = createState<
SavedPageLayout | undefined
PageLayoutWithData | undefined
>({
key: 'pageLayoutPersistedState',
defaultValue: undefined,
@@ -0,0 +1,7 @@
import { createState } from 'twenty-ui/utilities';
import { type PageLayoutWithData } from '../types/pageLayoutTypes';
export const savedPageLayoutsState = createState<PageLayoutWithData[]>({
key: 'savedPageLayoutsState',
defaultValue: [],
});
@@ -0,0 +1,18 @@
import {
type PageLayout,
type PageLayoutTab,
type PageLayoutWidget,
} from '~/generated/graphql';
// TODO: Remove this once we query the data from the database
export type PageLayoutWidgetWithData = PageLayoutWidget & {
data?: Record<string, any>;
};
export type PageLayoutTabWithData = Omit<PageLayoutTab, 'widgets'> & {
widgets: PageLayoutWidgetWithData[];
};
export type PageLayoutWithData = Omit<PageLayout, 'tabs'> & {
tabs: PageLayoutTabWithData[];
};
@@ -1,12 +1,12 @@
import { WidgetType } from '../../mocks/mockWidgets';
import {
type PageLayoutTab,
type PageLayoutWidget,
} from '../../states/savedPageLayoutsState';
type PageLayoutTabWithData,
type PageLayoutWidgetWithData,
} from '../../types/pageLayoutTypes';
import { addWidgetToTab } from '../addWidgetToTab';
describe('addWidgetToTab', () => {
const mockWidget: PageLayoutWidget = {
const mockWidget: PageLayoutWidgetWithData = {
id: 'widget-1',
pageLayoutTabId: 'tab-1',
title: 'Test Widget',
@@ -18,7 +18,7 @@ describe('addWidgetToTab', () => {
deletedAt: null,
};
const mockTabs: PageLayoutTab[] = [
const mockTabs: PageLayoutTabWithData[] = [
{
id: 'tab-1',
title: 'Tab 1',
@@ -65,7 +65,7 @@ describe('addWidgetToTab', () => {
});
it('should add multiple widgets to the same tab', () => {
const secondWidget: PageLayoutWidget = {
const secondWidget: PageLayoutWidgetWithData = {
...mockWidget,
id: 'widget-2',
title: 'Second Widget',
@@ -1,9 +1,9 @@
import { GraphType, WidgetType } from '../../mocks/mockWidgets';
import { type PageLayoutWidget } from '../../states/savedPageLayoutsState';
import { type PageLayoutWidgetWithData } from '../../types/pageLayoutTypes';
import { convertLayoutsToWidgets } from '../convertLayoutsToWidgets';
describe('convertLayoutsToWidgets', () => {
const mockWidgets: PageLayoutWidget[] = [
const mockWidgets: PageLayoutWidgetWithData[] = [
{
id: 'widget-1',
pageLayoutTabId: 'tab-1',
@@ -1,9 +1,9 @@
import { WidgetType } from '../../mocks/mockWidgets';
import { type PageLayoutTab } from '../../states/savedPageLayoutsState';
import { type PageLayoutTabWithData } from '../../types/pageLayoutTypes';
import { removeWidgetFromTab } from '../removeWidgetFromTab';
describe('removeWidgetFromTab', () => {
const mockTabs: PageLayoutTab[] = [
const mockTabs: PageLayoutTabWithData[] = [
{
id: 'tab-1',
title: 'Tab 1',
@@ -1,13 +1,13 @@
import {
type PageLayoutTab,
type PageLayoutWidget,
} from '../states/savedPageLayoutsState';
type PageLayoutTabWithData,
type PageLayoutWidgetWithData,
} from '../types/pageLayoutTypes';
export const addWidgetToTab = (
tabs: PageLayoutTab[],
tabs: PageLayoutTabWithData[],
activeTabId: string,
newWidget: PageLayoutWidget,
): PageLayoutTab[] => {
newWidget: PageLayoutWidgetWithData,
): PageLayoutTabWithData[] => {
return tabs.map((tab) => {
if (tab.id === activeTabId) {
return {
@@ -1,10 +1,10 @@
import { type PageLayoutWidgetWithData } from '@/page-layout/types/pageLayoutTypes';
import { type Layouts } from 'react-grid-layout';
import { type PageLayoutWidget } from '../states/savedPageLayoutsState';
export const convertLayoutsToWidgets = (
widgets: PageLayoutWidget[],
widgets: PageLayoutWidgetWithData[],
layouts: Layouts,
): PageLayoutWidget[] => {
): PageLayoutWidgetWithData[] => {
const activeLayouts = layouts.desktop || layouts.mobile || [];
return widgets.map((widget) => {
@@ -97,9 +97,16 @@ export const getDefaultWidgetData = (graphType: GraphType) => {
showLegend: true,
showGrid: true,
enablePoints: false,
xAxisLabel: 'Time',
yAxisLabel: 'Value',
curve: 'monotoneX',
displayType: 'shortNumber',
prefix: '$',
suffix: '',
xScale: { type: 'linear' },
yScale: { type: 'linear', min: 0, max: 'auto' },
stackedArea: false,
enableSlices: false,
};
default:
@@ -0,0 +1,17 @@
import { type PageLayout } from '~/generated/graphql';
import { type PageLayoutWithData } from '../types/pageLayoutTypes';
export const normalizePageLayoutData = (
pageLayout: PageLayout,
): PageLayoutWithData => {
return {
...pageLayout,
tabs: (pageLayout.tabs || []).map((tab) => ({
...tab,
widgets: (tab.widgets || []).map((widget) => ({
...widget,
data: undefined,
})),
})),
};
};
@@ -1,10 +1,10 @@
import { type PageLayoutTab } from '../states/savedPageLayoutsState';
import { type PageLayoutTabWithData } from '../types/pageLayoutTypes';
export const removeWidgetFromTab = (
tabs: PageLayoutTab[],
tabs: PageLayoutTabWithData[],
tabId: string,
widgetId: string,
): PageLayoutTab[] => {
): PageLayoutTabWithData[] => {
return tabs.map((tab) => {
if (tab.id === tabId) {
return {
@@ -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>
),
};
@@ -131,7 +131,10 @@ export const GraphWidgetPieChart = ({
if (!item) return null;
const dataItem = data.find((d) => d.id === datum.id);
const formattedValue = formatGraphValue(item.value, formatOptions);
const formattedValue = formatGraphValue(
displayType === 'percentage' ? item.percentage / 100 : item.value,
formatOptions,
);
const formattedWithPercentage = `${formattedValue} (${item.percentage.toFixed(1)}%)`;
return (
@@ -217,7 +220,10 @@ export const GraphWidgetPieChart = ({
items={enrichedData.map((item) => ({
id: item.id,
label: item.label || item.id,
formattedValue: formatGraphValue(item.value, formatOptions),
formattedValue: formatGraphValue(
displayType === 'percentage' ? item.percentage / 100 : item.value,
formatOptions,
),
color: item.colorScheme.solid,
}))}
/>
@@ -0,0 +1,101 @@
import { GraphType } from '@/page-layout/mocks/mockWidgets';
import { getDefaultWidgetData } from '@/page-layout/utils/getDefaultWidgetData';
import { GraphWidgetBarChart } from '@/page-layout/widgets/graph/components/GraphWidgetBarChart';
import { GraphWidgetGaugeChart } from '@/page-layout/widgets/graph/components/GraphWidgetGaugeChart';
import { GraphWidgetLineChart } from '@/page-layout/widgets/graph/components/GraphWidgetLineChart';
import { GraphWidgetNumberChart } from '@/page-layout/widgets/graph/components/GraphWidgetNumberChart';
import { GraphWidgetPieChart } from '@/page-layout/widgets/graph/components/GraphWidgetPieChart';
import { type GraphWidget } from '@/page-layout/widgets/graph/types/GraphWidget';
type GraphWidgetRendererProps = {
widget: GraphWidget;
};
export const GraphWidgetRenderer = ({ widget }: GraphWidgetRendererProps) => {
const graphType = widget.configuration?.graphType;
if (!Object.values(GraphType).includes(graphType)) {
return null;
}
const data = widget.data ?? getDefaultWidgetData(graphType);
if (!data) {
return null;
}
switch (graphType as GraphType) {
case GraphType.NUMBER:
return (
<GraphWidgetNumberChart
value={data.value}
trendPercentage={data.trendPercentage}
/>
);
case GraphType.GAUGE:
return (
<GraphWidgetGaugeChart
data={{
value: data.value,
min: data.min,
max: data.max,
label: data.label,
}}
displayType="percentage"
showValue
id={`gauge-chart-${widget.id}`}
/>
);
case GraphType.PIE:
return (
<GraphWidgetPieChart
data={data.items}
showLegend
displayType="percentage"
id={`pie-chart-${widget.id}`}
/>
);
case GraphType.BAR:
return (
<GraphWidgetBarChart
data={data.items}
indexBy={data.indexBy}
keys={data.keys}
seriesLabels={data.seriesLabels}
layout={data.layout}
showLegend
showGrid
displayType="number"
id={`bar-chart-${widget.id}`}
/>
);
case GraphType.LINE:
return (
<GraphWidgetLineChart
id={`line-chart-${widget.id}`}
data={data.series}
enableArea={data.enableArea}
showLegend={data.showLegend}
showGrid={data.showGrid}
enablePoints={data.enablePoints}
xAxisLabel={data.xAxisLabel}
yAxisLabel={data.yAxisLabel}
displayType={data.displayType}
prefix={data.prefix}
suffix={data.suffix}
xScale={data.xScale}
yScale={data.yScale}
curve={data.curve}
stackedArea={data.stackedArea}
enableSlices={data.enableSlices}
/>
);
default:
return null;
}
};
@@ -4,7 +4,7 @@ import { CatalogDecorator, ComponentDecorator } from 'twenty-ui/testing';
import { GraphWidgetBarChart } from '../GraphWidgetBarChart';
const meta: Meta<typeof GraphWidgetBarChart> = {
title: 'Modules/Dashboards/Graphs/GraphWidgetBarChart',
title: 'Modules/PageLayout/Widgets/GraphWidgetBarChart',
component: GraphWidgetBarChart,
decorators: [ComponentDecorator],
parameters: {
@@ -4,7 +4,7 @@ import { CatalogDecorator, ComponentDecorator } from 'twenty-ui/testing';
import { GraphWidgetGaugeChart } from '../GraphWidgetGaugeChart';
const meta: Meta<typeof GraphWidgetGaugeChart> = {
title: 'Modules/Dashboards/Graphs/GraphWidgetGaugeChart',
title: 'Modules/PageLayout/Widgets/GraphWidgetGaugeChart',
component: GraphWidgetGaugeChart,
decorators: [ComponentDecorator],
parameters: {
@@ -4,7 +4,7 @@ import { ComponentDecorator } from 'twenty-ui/testing';
import { GraphWidgetLegend } from '../GraphWidgetLegend';
const meta: Meta<typeof GraphWidgetLegend> = {
title: 'Modules/Dashboards/Graphs/GraphWidgetLegend',
title: 'Modules/PageLayout/Widgets/GraphWidgetLegend',
component: GraphWidgetLegend,
decorators: [ComponentDecorator],
parameters: {
@@ -1,11 +1,11 @@
import { type Meta, type StoryObj } from '@storybook/react';
import { type ComponentProps } from 'react';
import { CatalogDecorator, ComponentDecorator } from 'twenty-ui/testing';
import { GraphWidgetLineChart } from '../GraphWidgetLineChart';
import { type ComponentProps } from 'react';
const meta: Meta<typeof GraphWidgetLineChart> = {
title: 'Modules/Dashboards/Graphs/GraphWidgetLineChart',
title: 'Modules/PageLayout/Widgets/GraphWidgetLineChart',
component: GraphWidgetLineChart,
decorators: [ComponentDecorator],
parameters: {
@@ -4,7 +4,7 @@ import { ComponentDecorator } from 'twenty-ui/testing';
import { GraphWidgetNumberChart } from '../GraphWidgetNumberChart';
const meta: Meta<typeof GraphWidgetNumberChart> = {
title: 'Modules/Dashboards/Graphs/GraphWidgetNumberChart',
title: 'Modules/PageLayout/Widgets/GraphWidgetNumberChart',
component: GraphWidgetNumberChart,
decorators: [ComponentDecorator],
};
@@ -4,7 +4,7 @@ import { CatalogDecorator, ComponentDecorator } from 'twenty-ui/testing';
import { GraphWidgetPieChart } from '../GraphWidgetPieChart';
const meta: Meta<typeof GraphWidgetPieChart> = {
title: 'Modules/Dashboards/Graphs/GraphWidgetPieChart',
title: 'Modules/PageLayout/Widgets/GraphWidgetPieChart',
component: GraphWidgetPieChart,
decorators: [ComponentDecorator],
parameters: {
@@ -4,7 +4,7 @@ import { ComponentDecorator } from 'twenty-ui/testing';
import { GraphWidgetTooltip } from '../GraphWidgetTooltip';
const meta: Meta<typeof GraphWidgetTooltip> = {
title: 'Modules/Dashboards/Graphs/GraphWidgetTooltip',
title: 'Modules/PageLayout/Widgets/GraphWidgetTooltip',
component: GraphWidgetTooltip,
decorators: [ComponentDecorator],
parameters: {

Some files were not shown because too many files have changed in this diff Show More