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:
+117
@@ -0,0 +1,117 @@
|
||||
import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRecoilCallback, useRecoilValue } from 'recoil';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { PageLayoutType } from '~/generated/graphql';
|
||||
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
|
||||
import {
|
||||
pageLayoutCurrentLayoutsState,
|
||||
type TabLayouts,
|
||||
} from '../states/pageLayoutCurrentLayoutsState';
|
||||
import { pageLayoutDraftState } from '../states/pageLayoutDraftState';
|
||||
import { pageLayoutPersistedState } from '../states/pageLayoutPersistedState';
|
||||
import { savedPageLayoutsState } from '../states/savedPageLayoutsState';
|
||||
import { type PageLayoutWithData } from '../types/pageLayoutTypes';
|
||||
|
||||
type PageLayoutInitializationEffectProps = {
|
||||
layoutId: string | undefined;
|
||||
isEditMode: boolean;
|
||||
pageLayout?: PageLayoutWithData;
|
||||
};
|
||||
|
||||
export const PageLayoutInitializationEffect = ({
|
||||
layoutId,
|
||||
isEditMode,
|
||||
pageLayout,
|
||||
}: PageLayoutInitializationEffectProps) => {
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const savedPageLayouts = useRecoilValue(savedPageLayoutsState);
|
||||
|
||||
const initializePageLayout = useRecoilCallback(
|
||||
({ set, snapshot }) =>
|
||||
(layout: PageLayoutWithData | undefined) => {
|
||||
const currentPersisted = getSnapshotValue(
|
||||
snapshot,
|
||||
pageLayoutPersistedState,
|
||||
);
|
||||
|
||||
if (isDefined(layout)) {
|
||||
if (!isDeeplyEqual(layout, currentPersisted)) {
|
||||
set(pageLayoutPersistedState, layout);
|
||||
set(pageLayoutDraftState, {
|
||||
name: layout.name,
|
||||
type: layout.type,
|
||||
objectMetadataId: layout.objectMetadataId,
|
||||
tabs: layout.tabs,
|
||||
});
|
||||
|
||||
if (layout.tabs.length > 0) {
|
||||
const tabLayouts: TabLayouts = {};
|
||||
layout.tabs.forEach((tab) => {
|
||||
const layouts = tab.widgets.map((w) => ({
|
||||
i: w.id,
|
||||
x: w.gridPosition.column,
|
||||
y: w.gridPosition.row,
|
||||
w: w.gridPosition.columnSpan,
|
||||
h: w.gridPosition.rowSpan,
|
||||
}));
|
||||
tabLayouts[tab.id] = {
|
||||
desktop: layouts,
|
||||
mobile: layouts.map((l) => ({ ...l, w: 1, x: 0 })),
|
||||
};
|
||||
});
|
||||
set(pageLayoutCurrentLayoutsState, tabLayouts);
|
||||
} else {
|
||||
set(pageLayoutCurrentLayoutsState, {});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const defaultTab = {
|
||||
id: `tab-${uuidv4()}`,
|
||||
title: 'Main',
|
||||
position: 0,
|
||||
pageLayoutId: '',
|
||||
widgets: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
set(pageLayoutDraftState, {
|
||||
name: '',
|
||||
type: PageLayoutType.DASHBOARD,
|
||||
objectMetadataId: null,
|
||||
tabs: [defaultTab],
|
||||
});
|
||||
set(pageLayoutPersistedState, undefined);
|
||||
set(pageLayoutCurrentLayoutsState, {
|
||||
[defaultTab.id]: { desktop: [], mobile: [] },
|
||||
});
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInitialized) {
|
||||
const existingLayout = isEditMode
|
||||
? savedPageLayouts.find((l) => l.id === layoutId)
|
||||
: undefined;
|
||||
|
||||
const layoutToInitialize = existingLayout || pageLayout;
|
||||
|
||||
initializePageLayout(layoutToInitialize);
|
||||
setIsInitialized(true);
|
||||
}
|
||||
}, [
|
||||
layoutId,
|
||||
savedPageLayouts,
|
||||
initializePageLayout,
|
||||
isInitialized,
|
||||
isEditMode,
|
||||
pageLayout,
|
||||
]);
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,156 @@
|
||||
import { PageLayoutInitializationEffect } from '@/page-layout/components/PageLayoutInitializationEffect';
|
||||
import { EMPTY_LAYOUT } from '@/page-layout/constants/EmptyLayout';
|
||||
import {
|
||||
PAGE_LAYOUT_CONFIG,
|
||||
type PageLayoutBreakpoint,
|
||||
} from '@/page-layout/constants/PageLayoutBreakpoints';
|
||||
import { pageLayoutCurrentBreakpointState } from '@/page-layout/states/pageLayoutCurrentBreakpointState';
|
||||
import { pageLayoutCurrentLayoutsState } from '@/page-layout/states/pageLayoutCurrentLayoutsState';
|
||||
import { WidgetRenderer } from '@/page-layout/widgets/components/WidgetRenderer';
|
||||
import { type Widget } from '@/page-layout/widgets/types/Widget';
|
||||
import { TabList } from '@/ui/layout/tab-list/components/TabList';
|
||||
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
|
||||
import { TabListComponentInstanceContext } from '@/ui/layout/tab-list/states/contexts/TabListComponentInstanceContext';
|
||||
import { type SingleTabProps } from '@/ui/layout/tab-list/types/SingleTabProps';
|
||||
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
|
||||
import styled from '@emotion/styled';
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
Responsive,
|
||||
WidthProvider,
|
||||
type ResponsiveProps,
|
||||
} from 'react-grid-layout';
|
||||
import 'react-grid-layout/css/styles.css';
|
||||
import 'react-resizable/css/styles.css';
|
||||
import { useRecoilState, useRecoilValue } from 'recoil';
|
||||
import { type PageLayoutWithData } from '../types/pageLayoutTypes';
|
||||
|
||||
const StyledGridContainer = styled.div`
|
||||
background: ${({ theme }) => theme.background.secondary};
|
||||
box-sizing: border-box;
|
||||
flex: 1;
|
||||
min-height: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
position: relative;
|
||||
padding: ${({ theme }) => theme.spacing(2)};
|
||||
width: 100%;
|
||||
user-select: none;
|
||||
|
||||
.react-grid-placeholder {
|
||||
background: ${({ theme }) => theme.adaptiveColors.blue3} !important;
|
||||
|
||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
}
|
||||
|
||||
.react-grid-item:not(.react-draggable-dragging) {
|
||||
user-select: auto;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledTabList = styled(TabList)`
|
||||
padding-left: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
type ExtendedResponsiveProps = ResponsiveProps & {
|
||||
maxCols?: number;
|
||||
preventCollision?: boolean;
|
||||
};
|
||||
|
||||
const ResponsiveGridLayout = WidthProvider(
|
||||
Responsive,
|
||||
) as React.ComponentType<ExtendedResponsiveProps>;
|
||||
|
||||
type PageLayoutRendererProps = {
|
||||
pageLayout: PageLayoutWithData;
|
||||
};
|
||||
|
||||
type PageLayoutRendererContentProps = {
|
||||
pageLayout: PageLayoutWithData;
|
||||
};
|
||||
|
||||
const PageLayoutRendererContent = ({
|
||||
pageLayout,
|
||||
}: PageLayoutRendererContentProps) => {
|
||||
const [, setPageLayoutCurrentBreakpoint] = useRecoilState(
|
||||
pageLayoutCurrentBreakpointState,
|
||||
);
|
||||
|
||||
const pageLayoutCurrentLayouts = useRecoilValue(
|
||||
pageLayoutCurrentLayoutsState,
|
||||
);
|
||||
|
||||
const activeTabId = useRecoilComponentValue(activeTabIdComponentState);
|
||||
|
||||
const activeTabWidgets = pageLayout.tabs.find(
|
||||
(tab) => tab.id === activeTabId,
|
||||
)?.widgets;
|
||||
|
||||
const tabListTabs: SingleTabProps[] = useMemo(() => {
|
||||
return [...pageLayout.tabs]
|
||||
.sort((a, b) => a.position - b.position)
|
||||
.map((tab) => ({
|
||||
id: tab.id,
|
||||
title: tab.title,
|
||||
}));
|
||||
}, [pageLayout.tabs]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageLayoutInitializationEffect
|
||||
layoutId={pageLayout.id}
|
||||
isEditMode={false}
|
||||
pageLayout={pageLayout}
|
||||
/>
|
||||
{pageLayout.tabs.length > 0 && (
|
||||
<StyledTabList
|
||||
tabs={tabListTabs}
|
||||
behaveAsLinks={false}
|
||||
componentInstanceId={pageLayout.id}
|
||||
/>
|
||||
)}
|
||||
<StyledGridContainer>
|
||||
<ResponsiveGridLayout
|
||||
className="layout"
|
||||
layouts={
|
||||
!activeTabId
|
||||
? EMPTY_LAYOUT
|
||||
: pageLayoutCurrentLayouts[activeTabId] || EMPTY_LAYOUT
|
||||
}
|
||||
breakpoints={PAGE_LAYOUT_CONFIG.breakpoints}
|
||||
cols={PAGE_LAYOUT_CONFIG.columns}
|
||||
rowHeight={55}
|
||||
maxCols={12}
|
||||
containerPadding={[0, 0]}
|
||||
margin={[8, 8]}
|
||||
isDraggable={false}
|
||||
isResizable={false}
|
||||
draggableHandle=".drag-handle"
|
||||
compactType="vertical"
|
||||
preventCollision={false}
|
||||
onBreakpointChange={(newBreakpoint) =>
|
||||
setPageLayoutCurrentBreakpoint(
|
||||
newBreakpoint as PageLayoutBreakpoint,
|
||||
)
|
||||
}
|
||||
>
|
||||
{activeTabWidgets?.map((widget) => (
|
||||
<div key={widget.id} data-select-disable="true">
|
||||
<WidgetRenderer widget={widget as Widget} />
|
||||
</div>
|
||||
))}
|
||||
</ResponsiveGridLayout>
|
||||
</StyledGridContainer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const PageLayoutRenderer = ({ pageLayout }: PageLayoutRendererProps) => {
|
||||
return (
|
||||
<TabListComponentInstanceContext.Provider
|
||||
value={{ instanceId: pageLayout.id }}
|
||||
>
|
||||
<PageLayoutRendererContent pageLayout={pageLayout} />
|
||||
</TabListComponentInstanceContext.Provider>
|
||||
);
|
||||
};
|
||||
+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);
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user