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:
@@ -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 {
|
||||
|
||||
+2
-5
@@ -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 {
|
||||
|
||||
+4
-4
@@ -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';
|
||||
|
||||
+2
-2
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
+14
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
+17
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
+5
@@ -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} />;
|
||||
},
|
||||
};
|
||||
|
||||
+32
-2
@@ -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>;
|
||||
};
|
||||
|
||||
+11
-7
@@ -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>
|
||||
);
|
||||
};
|
||||
+224
@@ -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);
|
||||
},
|
||||
};
|
||||
+2
-2
@@ -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';
|
||||
+6
-10
@@ -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();
|
||||
+2
-5
@@ -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', () => {
|
||||
+12
-11
@@ -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(),
|
||||
+6
-5
@@ -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,
|
||||
+18
-17
@@ -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(),
|
||||
+2
-2
@@ -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,
|
||||
+7
-7
@@ -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,
|
||||
+3
-3
@@ -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) => ({
|
||||
+2
-2
@@ -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',
|
||||
+3
-2
@@ -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'
|
||||
>;
|
||||
|
||||
+2
-2
@@ -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[];
|
||||
};
|
||||
+6
-6
@@ -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',
|
||||
+2
-2
@@ -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',
|
||||
+2
-2
@@ -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',
|
||||
+6
-6
@@ -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 {
|
||||
+3
-3
@@ -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) => {
|
||||
+7
@@ -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,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
};
|
||||
+3
-3
@@ -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>;
|
||||
};
|
||||
+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>
|
||||
),
|
||||
};
|
||||
+8
-2
@@ -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,
|
||||
}))}
|
||||
/>
|
||||
+101
@@ -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;
|
||||
}
|
||||
};
|
||||
+1
-1
@@ -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: {
|
||||
+1
-1
@@ -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: {
|
||||
+1
-1
@@ -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: {
|
||||
+2
-2
@@ -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: {
|
||||
+1
-1
@@ -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],
|
||||
};
|
||||
+1
-1
@@ -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: {
|
||||
+1
-1
@@ -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
Reference in New Issue
Block a user