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:
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+24
@@ -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>
|
||||
);
|
||||
};
|
||||
-117
@@ -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;
|
||||
};
|
||||
+96
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+31
@@ -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 />
|
||||
</>
|
||||
);
|
||||
};
|
||||
+38
-40
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user