Page layout refactoring (#14535)

In this PR:
- Refactored the page layout renderer
- Converted all the states to component states
- Removed the page layout edition in settings

TODOs in next PRs:
- Fix bug with the drag selector not taking the scroll into account to
display the dragged area
- Readd the tab edition in edit mode
This commit is contained in:
Raphaël Bosi
2025-09-16 17:42:51 +02:00
committed by GitHub
parent e69131790c
commit 3b868c3d2a
90 changed files with 2070 additions and 1928 deletions
@@ -0,0 +1,154 @@
import { useNavigatePageLayoutCommandMenu } from '@/command-menu/pages/page-layout/hooks/useNavigatePageLayoutCommandMenu';
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import { PageLayoutGridLayoutDragSelector } from '@/page-layout/components/PageLayoutGridLayoutDragSelector';
import { PageLayoutGridOverlay } from '@/page-layout/components/PageLayoutGridOverlay';
import { EMPTY_LAYOUT } from '@/page-layout/constants/EmptyLayout';
import {
PAGE_LAYOUT_CONFIG,
type PageLayoutBreakpoint,
} from '@/page-layout/constants/PageLayoutBreakpoints';
import { useCurrentPageLayout } from '@/page-layout/hooks/useCurrentPageLayout';
import { usePageLayoutHandleLayoutChange } from '@/page-layout/hooks/usePageLayoutHandleLayoutChange';
import { isPageLayoutInEditModeComponentState } from '@/page-layout/states/isPageLayoutInEditModeComponentState';
import { pageLayoutCurrentBreakpointComponentState } from '@/page-layout/states/pageLayoutCurrentBreakpointComponentState';
import { pageLayoutCurrentLayoutsComponentState } from '@/page-layout/states/pageLayoutCurrentLayoutsComponentState';
import { WidgetPlaceholder } from '@/page-layout/widgets/components/WidgetPlaceholder';
import { WidgetRenderer } from '@/page-layout/widgets/components/WidgetRenderer';
import { type Widget } from '@/page-layout/widgets/types/Widget';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import styled from '@emotion/styled';
import { useRef } from 'react';
import {
Responsive,
WidthProvider,
type ResponsiveProps,
} from 'react-grid-layout';
import { isDefined } from 'twenty-shared/utils';
const StyledGridContainer = styled.div`
background: ${({ theme }) => theme.background.primary};
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;
}
`;
type ExtendedResponsiveProps = ResponsiveProps & {
maxCols?: number;
preventCollision?: boolean;
};
const ResponsiveGridLayout = WidthProvider(
Responsive,
) as React.ComponentType<ExtendedResponsiveProps>;
export const PageLayoutGridLayout = () => {
const setPageLayoutCurrentBreakpoint = useSetRecoilComponentState(
pageLayoutCurrentBreakpointComponentState,
);
const { handleLayoutChange } = usePageLayoutHandleLayoutChange();
const gridContainerRef = useRef<HTMLDivElement>(null);
const isPageLayoutInEditMode = useRecoilComponentValue(
isPageLayoutInEditModeComponentState,
);
const pageLayoutCurrentLayouts = useRecoilComponentValue(
pageLayoutCurrentLayoutsComponentState,
);
const activeTabId = useRecoilComponentValue(activeTabIdComponentState);
const { navigatePageLayoutCommandMenu } = useNavigatePageLayoutCommandMenu();
const { currentPageLayout } = useCurrentPageLayout();
if (!isDefined(activeTabId) || !isDefined(currentPageLayout)) {
return null;
}
const activeTabWidgets = currentPageLayout?.tabs.find(
(tab) => tab.id === activeTabId,
)?.widgets;
const isLayoutEmpty =
!isDefined(activeTabWidgets) || activeTabWidgets.length === 0;
const layouts = isLayoutEmpty
? EMPTY_LAYOUT
: pageLayoutCurrentLayouts[activeTabId] || EMPTY_LAYOUT;
return (
<>
<StyledGridContainer ref={gridContainerRef}>
{isPageLayoutInEditMode && (
<>
<PageLayoutGridOverlay />
<PageLayoutGridLayoutDragSelector
gridContainerRef={gridContainerRef}
/>
</>
)}
<ResponsiveGridLayout
className="layout"
layouts={layouts}
breakpoints={PAGE_LAYOUT_CONFIG.breakpoints}
cols={PAGE_LAYOUT_CONFIG.columns}
rowHeight={55}
maxCols={12}
containerPadding={[0, 0]}
margin={[8, 8]}
isDraggable={isPageLayoutInEditMode}
isResizable={isPageLayoutInEditMode}
draggableHandle=".drag-handle"
compactType="vertical"
preventCollision={false}
onLayoutChange={handleLayoutChange}
onBreakpointChange={(newBreakpoint) =>
setPageLayoutCurrentBreakpoint(
newBreakpoint as PageLayoutBreakpoint,
)
}
>
{isLayoutEmpty ? (
<div key="empty-placeholder" data-select-disable="true">
<WidgetPlaceholder
onClick={() => {
navigatePageLayoutCommandMenu({
commandMenuPage:
CommandMenuPages.PageLayoutWidgetTypeSelect,
});
}}
/>
</div>
) : (
activeTabWidgets?.map((widget) => (
<div key={widget.id} data-select-disable="true">
<WidgetRenderer widget={widget as Widget} />
</div>
))
)}
</ResponsiveGridLayout>
</StyledGridContainer>
</>
);
};
@@ -0,0 +1,24 @@
import { useChangePageLayoutDragSelection } from '@/page-layout/hooks/useChangePageLayoutDragSelection';
import { useEndPageLayoutDragSelection } from '@/page-layout/hooks/useEndPageLayoutDragSelection';
import { useStartPageLayoutDragSelection } from '@/page-layout/hooks/useStartPageLayoutDragSelection';
import { DragSelect } from '@/ui/utilities/drag-select/components/DragSelect';
import { type RefObject } from 'react';
export const PageLayoutGridLayoutDragSelector = ({
gridContainerRef,
}: {
gridContainerRef: RefObject<HTMLDivElement>;
}) => {
const { startPageLayoutDragSelection } = useStartPageLayoutDragSelection();
const { changePageLayoutDragSelection } = useChangePageLayoutDragSelection();
const { endPageLayoutDragSelection } = useEndPageLayoutDragSelection();
return (
<DragSelect
selectableItemsContainerRef={gridContainerRef}
onDragSelectionStart={startPageLayoutDragSelection}
onDragSelectionChange={changePageLayoutDragSelection}
onDragSelectionEnd={endPageLayoutDragSelection}
/>
);
};
@@ -0,0 +1,101 @@
import { type PageLayoutBreakpoint } from '@/page-layout/constants/PageLayoutBreakpoints';
import { pageLayoutCurrentBreakpointComponentState } from '@/page-layout/states/pageLayoutCurrentBreakpointComponentState';
import { pageLayoutCurrentLayoutsComponentState } from '@/page-layout/states/pageLayoutCurrentLayoutsComponentState';
import { pageLayoutSelectedCellsComponentState } from '@/page-layout/states/pageLayoutSelectedCellsComponentState';
import { calculateGridCellPosition } from '@/page-layout/utils/calculateGridCellPosition';
import { calculateTotalGridRows } from '@/page-layout/utils/calculateTotalGridRows';
import { generateCellId } from '@/page-layout/utils/generateCellId';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import styled from '@emotion/styled';
import { useMemo } from 'react';
const StyledGridOverlay = styled.div<{
isDragSelecting?: boolean;
breakpoint: PageLayoutBreakpoint;
}>`
position: absolute;
top: ${({ theme }) => theme.spacing(2)};
left: ${({ theme }) => theme.spacing(2)};
right: ${({ theme }) => theme.spacing(2)};
bottom: ${({ theme }) => theme.spacing(2)};
display: grid;
grid-template-columns: ${({ breakpoint }) =>
breakpoint === 'mobile' ? '1fr' : 'repeat(12, 1fr)'};
grid-auto-rows: 55px;
gap: ${({ theme }) => theme.spacing(2)};
pointer-events: ${({ isDragSelecting }) =>
isDragSelecting ? 'auto' : 'none'};
z-index: 0;
`;
const StyledGridCell = styled.div<{ isSelected?: boolean }>`
background: ${({ isSelected, theme }) =>
isSelected ? theme.adaptiveColors.blue1 : 'transparent'};
border: 1px solid
${({ theme, isSelected }) =>
isSelected ? theme.adaptiveColors.blue3 : theme.border.color.light};
border-radius: ${({ theme }) => theme.border.radius.md};
transition: background-color 0.3s ease;
&:hover {
background: ${({ theme }) => theme.background.transparent.lighter};
border-color: ${({ theme }) => theme.border.color.medium};
}
`;
export const PageLayoutGridOverlay = () => {
const pageLayoutCurrentBreakpoint = useRecoilComponentValue(
pageLayoutCurrentBreakpointComponentState,
);
const pageLayoutSelectedCells = useRecoilComponentValue(
pageLayoutSelectedCellsComponentState,
);
const pageLayoutCurrentLayouts = useRecoilComponentValue(
pageLayoutCurrentLayoutsComponentState,
);
const activeTabId = useRecoilComponentValue(activeTabIdComponentState);
const numberOfRows = useMemo(() => {
const currentTabLayouts = pageLayoutCurrentLayouts[activeTabId ?? ''] || {
desktop: [],
mobile: [],
};
return calculateTotalGridRows(currentTabLayouts);
}, [pageLayoutCurrentLayouts, activeTabId]);
const isPageLayoutCurrentBreakpointMobile =
pageLayoutCurrentBreakpoint === 'mobile';
const numberOfColumns = pageLayoutCurrentBreakpoint === 'mobile' ? 1 : 12;
return (
<StyledGridOverlay
isDragSelecting={!isPageLayoutCurrentBreakpointMobile}
breakpoint={pageLayoutCurrentBreakpoint}
>
{Array.from(
{
length: numberOfColumns * numberOfRows,
},
(_, i) => {
const { column, row } = calculateGridCellPosition({
index: i,
numberOfColumns,
});
const cellId = generateCellId(column, row);
return (
<StyledGridCell
key={i}
data-selectable-id={cellId}
isSelected={pageLayoutSelectedCells.has(cellId)}
/>
);
},
)}
</StyledGridOverlay>
);
};
@@ -1,117 +0,0 @@
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,96 @@
import { FIND_ONE_PAGE_LAYOUT } from '@/dashboards/graphql/queries/findOnePageLayout';
import { pageLayoutCurrentLayoutsComponentState } from '@/page-layout/states/pageLayoutCurrentLayoutsComponentState';
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
import { pageLayoutPersistedComponentState } from '@/page-layout/states/pageLayoutPersistedComponentState';
import { type PageLayoutWithData } from '@/page-layout/types/pageLayoutTypes';
import { type TabLayouts } from '@/page-layout/types/tab-layouts';
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue';
import { useQuery } from '@apollo/client';
import { useEffect, useState } from 'react';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
type PageLayoutInitializationQueryEffectProps = {
pageLayoutId: string;
};
export const PageLayoutInitializationQueryEffect = ({
pageLayoutId,
}: PageLayoutInitializationQueryEffectProps) => {
const [isInitialized, setIsInitialized] = useState(false);
const { data } = useQuery(FIND_ONE_PAGE_LAYOUT, {
variables: {
id: pageLayoutId,
},
});
const pageLayout: PageLayoutWithData | undefined = data?.getPageLayout;
const pageLayoutPersistedComponentCallbackState =
useRecoilComponentCallbackState(pageLayoutPersistedComponentState);
const pageLayoutDraftComponentCallbackState = useRecoilComponentCallbackState(
pageLayoutDraftComponentState,
);
const pageLayoutCurrentLayoutsComponentCallbackState =
useRecoilComponentCallbackState(pageLayoutCurrentLayoutsComponentState);
const initializePageLayout = useRecoilCallback(
({ set, snapshot }) =>
(layout: PageLayoutWithData) => {
const currentPersisted = getSnapshotValue(
snapshot,
pageLayoutPersistedComponentCallbackState,
);
if (!isDeeplyEqual(layout, currentPersisted)) {
set(pageLayoutPersistedComponentCallbackState, layout);
set(pageLayoutDraftComponentCallbackState, {
id: layout.id,
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(pageLayoutCurrentLayoutsComponentCallbackState, tabLayouts);
} else {
set(pageLayoutCurrentLayoutsComponentCallbackState, {});
}
}
},
[
pageLayoutCurrentLayoutsComponentCallbackState,
pageLayoutDraftComponentCallbackState,
pageLayoutPersistedComponentCallbackState,
],
);
useEffect(() => {
if (!isInitialized && isDefined(pageLayout)) {
initializePageLayout(pageLayout);
setIsInitialized(true);
}
}, [initializePageLayout, isInitialized, pageLayout]);
return null;
};
@@ -1,156 +1,33 @@
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 { PageLayoutInitializationQueryEffect } from '@/page-layout/components/PageLayoutInitializationQueryEffect';
import { PageLayoutRendererContent } from '@/page-layout/components/PageLayoutRendererContent';
import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext';
import { getTabListInstanceIdFromPageLayoutId } from '@/page-layout/utils/getTabListInstanceIdFromPageLayoutId';
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;
pageLayoutId: string;
};
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]);
export const PageLayoutRenderer = ({
pageLayoutId,
}: PageLayoutRendererProps) => {
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 }}
<PageLayoutComponentInstanceContext.Provider
value={{
instanceId: pageLayoutId,
}}
>
<PageLayoutRendererContent pageLayout={pageLayout} />
</TabListComponentInstanceContext.Provider>
<TabListComponentInstanceContext.Provider
value={{
instanceId: getTabListInstanceIdFromPageLayoutId(pageLayoutId),
}}
>
<PageLayoutInitializationQueryEffect pageLayoutId={pageLayoutId} />
<PageLayoutRendererContent />
</TabListComponentInstanceContext.Provider>
</PageLayoutComponentInstanceContext.Provider>
);
};
@@ -0,0 +1,31 @@
import { PageLayoutGridLayout } from '@/page-layout/components/PageLayoutGridLayout';
import { useCurrentPageLayout } from '@/page-layout/hooks/useCurrentPageLayout';
import { getTabListInstanceIdFromPageLayoutId } from '@/page-layout/utils/getTabListInstanceIdFromPageLayoutId';
import { TabList } from '@/ui/layout/tab-list/components/TabList';
import styled from '@emotion/styled';
import { isDefined } from 'twenty-shared/utils';
const StyledTabList = styled(TabList)`
padding-left: ${({ theme }) => theme.spacing(2)};
`;
export const PageLayoutRendererContent = () => {
const { currentPageLayout } = useCurrentPageLayout();
if (!isDefined(currentPageLayout)) {
return null;
}
return (
<>
<StyledTabList
tabs={currentPageLayout.tabs}
behaveAsLinks={false}
componentInstanceId={getTabListInstanceIdFromPageLayoutId(
currentPageLayout.id,
)}
/>
<PageLayoutGridLayout />
</>
);
};
@@ -1,11 +1,11 @@
import { MockedProvider, type MockedResponse } from '@apollo/client/testing';
import type { Meta, StoryObj } from '@storybook/react';
import { expect, waitFor, within } from '@storybook/test';
import { expect, within } from '@storybook/test';
import { MemoryRouter } from 'react-router-dom';
import { FIND_ONE_PAGE_LAYOUT } from '@/dashboards/graphql/queries/findOnePageLayout';
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';
@@ -13,24 +13,14 @@ import { type PageLayoutWidgetWithData } from '../../types/pageLayoutTypes';
const validatePageLayoutContent = async (canvasElement: HTMLElement) => {
const canvas = within(canvasElement);
await waitFor(async () => {
const revenueElements = canvas.getAllByText('Revenue');
expect(revenueElements).toHaveLength(2);
const goalProgressElements = canvas.getAllByText('Goal Progress');
expect(goalProgressElements).toHaveLength(2);
expect(canvas.getByText('Product Sales')).toBeInTheDocument();
expect(canvas.getByText('Services')).toBeInTheDocument();
expect(canvas.getByText('Support')).toBeInTheDocument();
});
await expect(await canvas.findByText('Revenue')).toBeVisible();
await expect(await canvas.findByText('Goal Progress')).toBeVisible();
await expect(await canvas.findByText('Revenue Sources')).toBeVisible();
await expect(await canvas.findByText('Quarterly Comparison')).toBeVisible();
await expect(await canvas.findByText('$125,000')).toBeVisible();
};
const mixedGraphsPageLayout = {
const mixedGraphsPageLayoutMocks = {
__typename: 'PageLayout',
id: 'mixed-graphs-layout',
name: 'Mixed Graph Dashboard',
type: PageLayoutType.DASHBOARD,
@@ -40,6 +30,7 @@ const mixedGraphsPageLayout = {
deletedAt: null,
tabs: [
{
__typename: 'PageLayoutTab',
id: 'mixed-tab',
title: 'Mixed Graphs',
position: 0,
@@ -49,12 +40,14 @@ const mixedGraphsPageLayout = {
deletedAt: null,
widgets: [
{
__typename: 'PageLayoutWidget',
id: 'number-widget',
pageLayoutTabId: 'mixed-tab',
type: WidgetType.GRAPH,
title: 'Revenue',
objectMetadataId: null,
gridPosition: {
__typename: 'GridPosition',
row: 0,
column: 0,
rowSpan: 2,
@@ -72,12 +65,14 @@ const mixedGraphsPageLayout = {
deletedAt: null,
} as PageLayoutWidgetWithData,
{
__typename: 'PageLayoutWidget',
id: 'gauge-widget',
pageLayoutTabId: 'mixed-tab',
type: WidgetType.GRAPH,
title: 'Goal Progress',
objectMetadataId: null,
gridPosition: {
__typename: 'GridPosition',
row: 0,
column: 3,
rowSpan: 4,
@@ -97,12 +92,14 @@ const mixedGraphsPageLayout = {
deletedAt: null,
} as PageLayoutWidgetWithData,
{
__typename: 'PageLayoutWidget',
id: 'pie-widget',
pageLayoutTabId: 'mixed-tab',
type: WidgetType.GRAPH,
title: 'Revenue Sources',
objectMetadataId: null,
gridPosition: {
__typename: 'GridPosition',
row: 0,
column: 6,
rowSpan: 4,
@@ -123,12 +120,14 @@ const mixedGraphsPageLayout = {
deletedAt: null,
} as PageLayoutWidgetWithData,
{
__typename: 'PageLayoutWidget',
id: 'bar-widget',
pageLayoutTabId: 'mixed-tab',
type: WidgetType.GRAPH,
title: 'Quarterly Comparison',
objectMetadataId: null,
gridPosition: {
__typename: 'GridPosition',
row: 2,
column: 0,
rowSpan: 4,
@@ -161,28 +160,33 @@ const mixedGraphsPageLayout = {
],
};
const graphqlMocks: MockedResponse[] = [
{
request: {
query: FIND_ONE_PAGE_LAYOUT,
variables: {
id: 'mixed-graphs-layout',
},
},
result: {
data: {
getPageLayout: mixedGraphsPageLayoutMocks,
},
},
},
];
const meta: Meta<typeof PageLayoutRenderer> = {
title: 'Modules/PageLayout/PageLayoutRenderer',
component: PageLayoutRenderer,
decorators: [
(Story, { args }: { args: any }) => (
(Story) => (
<MemoryRouter>
<RecoilRoot
initializeState={({ set }) => {
set(
activeTabIdComponentState.atomFamily({
instanceId: 'page-layout-stories',
}),
args.activeTabId,
);
}}
>
<TabListComponentInstanceContext.Provider
value={{ instanceId: 'page-layout-stories' }}
>
<MockedProvider mocks={graphqlMocks} addTypename={false}>
<RecoilRoot>
<Story />
</TabListComponentInstanceContext.Provider>
</RecoilRoot>
</RecoilRoot>
</MockedProvider>
</MemoryRouter>
),
],
@@ -190,7 +194,7 @@ const meta: Meta<typeof PageLayoutRenderer> = {
layout: 'fullscreen',
},
args: {
pageLayout: mixedGraphsPageLayout,
pageLayoutId: mixedGraphsPageLayoutMocks.id,
},
};
@@ -199,9 +203,6 @@ export default meta;
type Story = StoryObj<typeof meta>;
export const DesktopView: Story = {
args: {
activeTabId: 'mixed-tab',
},
parameters: {
viewport: {
defaultViewport: 'desktop1',
@@ -213,9 +214,6 @@ export const DesktopView: Story = {
};
export const MobileView: Story = {
args: {
activeTabId: 'mixed-tab',
},
parameters: {
viewport: {
defaultViewport: 'mobile1',